mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 18:31:39 +08:00
Compare commits
905 Commits
pe/exec-ap
...
codex-oaut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6630e27549 | ||
|
|
af11d23e51 | ||
|
|
8245c06e85 | ||
|
|
61d69f093f | ||
|
|
007aa38370 | ||
|
|
cefb265b28 | ||
|
|
deaa1832d3 | ||
|
|
896ca87f17 | ||
|
|
0c30a7dc4a | ||
|
|
ae5b363e0d | ||
|
|
6dd435c676 | ||
|
|
781edbd933 | ||
|
|
5eb43f8a80 | ||
|
|
efc851e7d5 | ||
|
|
b741ddb66f | ||
|
|
d756e1c500 | ||
|
|
7c9127c94d | ||
|
|
0241a6e7ae | ||
|
|
99e44f623e | ||
|
|
247e536fa6 | ||
|
|
0c7220f5da | ||
|
|
34c441c746 | ||
|
|
7552634996 | ||
|
|
736e7de1ae | ||
|
|
b6940b5dc4 | ||
|
|
a26aba67a8 | ||
|
|
b00d3065cf | ||
|
|
86b87df7e3 | ||
|
|
bd04b1ea7c | ||
|
|
d012065ecf | ||
|
|
ce5dcb0ab2 | ||
|
|
bbbed264b6 | ||
|
|
a0702e195d | ||
|
|
f6840acc21 | ||
|
|
6f416537ee | ||
|
|
77c3bdb3ca | ||
|
|
489ea84819 | ||
|
|
936dfaaac9 | ||
|
|
9fc5346a97 | ||
|
|
af12082ec8 | ||
|
|
c9b17c5142 | ||
|
|
10cb0a5ec0 | ||
|
|
5e97045345 | ||
|
|
59aef2ff0d | ||
|
|
769fd0b14a | ||
|
|
9f1472ed8f | ||
|
|
46de078b2a | ||
|
|
de8a82a693 | ||
|
|
571f364cd7 | ||
|
|
7fc691a426 | ||
|
|
d8b973638e | ||
|
|
664611c1a5 | ||
|
|
9210dfc091 | ||
|
|
87b2046575 | ||
|
|
9a816f41a9 | ||
|
|
d5247d0bfb | ||
|
|
30333b2e0b | ||
|
|
6788aa1943 | ||
|
|
48bf0374c8 | ||
|
|
718cc1b9b6 | ||
|
|
67c56f34c6 | ||
|
|
5bb94caef8 | ||
|
|
00d3dcaa75 | ||
|
|
6ab32bed5c | ||
|
|
a003960f26 | ||
|
|
c876fecbe7 | ||
|
|
884aa1b2eb | ||
|
|
c94c513714 | ||
|
|
a0358bbf18 | ||
|
|
d93c59732b | ||
|
|
bb4d88e557 | ||
|
|
a03a8d91f6 | ||
|
|
d9c6c5f600 | ||
|
|
e730e9bd0b | ||
|
|
933f01cb39 | ||
|
|
5b90a48e9d | ||
|
|
d22bcfc23a | ||
|
|
81d22c817d | ||
|
|
adc6adccd8 | ||
|
|
faf2a6cb9e | ||
|
|
21bedd3964 | ||
|
|
5cc0dbce86 | ||
|
|
9364b21e51 | ||
|
|
99d7c7077e | ||
|
|
8fc48af091 | ||
|
|
cc91ff04cc | ||
|
|
e842869003 | ||
|
|
dcd98bf1ef | ||
|
|
d70dc4be19 | ||
|
|
a54a8813bf | ||
|
|
f9d35dc681 | ||
|
|
cff5244a5b | ||
|
|
9d24fde283 | ||
|
|
dc04503a7e | ||
|
|
fe7d13ca84 | ||
|
|
ffa6cd888f | ||
|
|
69255f8f32 | ||
|
|
683ad75b31 | ||
|
|
29118a0f0f | ||
|
|
ab684f5088 | ||
|
|
513195b462 | ||
|
|
bdcaac06c6 | ||
|
|
c21ca883b0 | ||
|
|
6ea907cec1 | ||
|
|
0def3e20e4 | ||
|
|
2890b1a24a | ||
|
|
937a756f7f | ||
|
|
66d1d13889 | ||
|
|
ba86716999 | ||
|
|
31a189db0a | ||
|
|
52759294ca | ||
|
|
fea89cd384 | ||
|
|
04ebdc6da5 | ||
|
|
7b1fbe1c37 | ||
|
|
c3531fcd7b | ||
|
|
fc7a531f6c | ||
|
|
ca2b9ad289 | ||
|
|
19ff77e9c9 | ||
|
|
bb73f0a5c3 | ||
|
|
3e94290460 | ||
|
|
47d66fe343 | ||
|
|
a15797ad11 | ||
|
|
07e61fc847 | ||
|
|
a28f1297ab | ||
|
|
a00c58363a | ||
|
|
fc47c1f55e | ||
|
|
4a9138556e | ||
|
|
111bad1065 | ||
|
|
1bafc23ae3 | ||
|
|
e43282e701 | ||
|
|
f4bdfd46a9 | ||
|
|
57db041365 | ||
|
|
84329182a7 | ||
|
|
01e7f64629 | ||
|
|
6f6da5f5ba | ||
|
|
2b396131e4 | ||
|
|
ebfb834dcd | ||
|
|
3551e98433 | ||
|
|
1fdc73ae4b | ||
|
|
84af5e6e76 | ||
|
|
9914e25638 | ||
|
|
8b0537c409 | ||
|
|
fcecbd8655 | ||
|
|
249f79be42 | ||
|
|
86faf654db | ||
|
|
976da39038 | ||
|
|
3784270670 | ||
|
|
de022bb69d | ||
|
|
0d28040092 | ||
|
|
bfa5b39648 | ||
|
|
a1b05aae7c | ||
|
|
82f69a269b | ||
|
|
b2dc4492f0 | ||
|
|
b6c8807ca0 | ||
|
|
c56067e34f | ||
|
|
56308a7144 | ||
|
|
ab1fedb63f | ||
|
|
89c59a89fb | ||
|
|
0a95e53602 | ||
|
|
fda0baf98d | ||
|
|
136c927140 | ||
|
|
77a1b7625d | ||
|
|
85e468d275 | ||
|
|
c9a0f03dd7 | ||
|
|
0ddf51cf71 | ||
|
|
1a8625529e | ||
|
|
6b1c8687b5 | ||
|
|
03f61cd1b5 | ||
|
|
ff79299d68 | ||
|
|
8523e0930e | ||
|
|
6bd430ee35 | ||
|
|
e2f82d4d30 | ||
|
|
70dd31506b | ||
|
|
edab653178 | ||
|
|
0d8c9ca914 | ||
|
|
0acfb7ba13 | ||
|
|
4ee8a2ac2e | ||
|
|
7b489560f3 | ||
|
|
37207c6925 | ||
|
|
e53612a639 | ||
|
|
d24cfcfa21 | ||
|
|
2b1c01f769 | ||
|
|
a12e3022db | ||
|
|
40b8dd88d8 | ||
|
|
b859654641 | ||
|
|
cc6d222ae3 | ||
|
|
b59ab5b1f0 | ||
|
|
f483f59b6c | ||
|
|
c222ef01e9 | ||
|
|
0050b8e89a | ||
|
|
6b4aec9fb9 | ||
|
|
940a950e47 | ||
|
|
d11c2e421d | ||
|
|
c99a29d0a8 | ||
|
|
a7ba47c4ee | ||
|
|
a5fa944c69 | ||
|
|
f3a984dcbb | ||
|
|
4711bb529a | ||
|
|
4d6b3845f1 | ||
|
|
d6fc2f34a3 | ||
|
|
3222e35322 | ||
|
|
ea5b5d78d5 | ||
|
|
5d01be1070 | ||
|
|
b3ec11b052 | ||
|
|
bf64de9191 | ||
|
|
beccdde5bf | ||
|
|
a80476fbe9 | ||
|
|
6f933656e5 | ||
|
|
1b0a5d1627 | ||
|
|
fb61de8c88 | ||
|
|
9bd97d2c60 | ||
|
|
a9176e9190 | ||
|
|
88ad5cb2f4 | ||
|
|
25e489395a | ||
|
|
1e1e45b72b | ||
|
|
ea5f2abb48 | ||
|
|
23961fe472 | ||
|
|
0a4b30191d | ||
|
|
37a9f58d1b | ||
|
|
5ed8bbc694 | ||
|
|
9ff3b9f4ef | ||
|
|
fd293bd2a7 | ||
|
|
01fce88082 | ||
|
|
192a782b99 | ||
|
|
6981051682 | ||
|
|
e201fbfbd2 | ||
|
|
ec8ed79646 | ||
|
|
3b21a3f4c4 | ||
|
|
27088c6919 | ||
|
|
65471a2da6 | ||
|
|
014b527e23 | ||
|
|
b2a0bfab43 | ||
|
|
0e47815e6e | ||
|
|
49e3f8c3ee | ||
|
|
170f72d5a1 | ||
|
|
17e2ccf179 | ||
|
|
d0a74dbfbe | ||
|
|
b01a078d83 | ||
|
|
03125c8e13 | ||
|
|
62a330e752 | ||
|
|
cc4e30b3d9 | ||
|
|
e32e0f3f7f | ||
|
|
6a3377255d | ||
|
|
8df350030d | ||
|
|
b7356e4e58 | ||
|
|
b010852dc6 | ||
|
|
cd1cae5be9 | ||
|
|
93c613cec4 | ||
|
|
55cfe00a3a | ||
|
|
06a6d2b5c9 | ||
|
|
a1bdffc212 | ||
|
|
ab265dbce9 | ||
|
|
a483f70a8a | ||
|
|
95343affbb | ||
|
|
1008b8213b | ||
|
|
229490a489 | ||
|
|
5f0bec4479 | ||
|
|
7dc2e50ac3 | ||
|
|
e399a92e6c | ||
|
|
36e76ef424 | ||
|
|
ddd3d69b86 | ||
|
|
ae4806ed9a | ||
|
|
0a4de3de57 | ||
|
|
eb7f3b7b50 | ||
|
|
a4c81c6f35 | ||
|
|
b8e9ab9385 | ||
|
|
c8a35c4645 | ||
|
|
577e64db63 | ||
|
|
60d200f797 | ||
|
|
7f4bd454fe | ||
|
|
221f5349b5 | ||
|
|
ee9813f478 | ||
|
|
cbe68ba1a1 | ||
|
|
d391434f4e | ||
|
|
4faeb378ee | ||
|
|
1f9ebb9dda | ||
|
|
0aabaebba1 | ||
|
|
6fe3088bc6 | ||
|
|
7f499643b2 | ||
|
|
777a113973 | ||
|
|
bc9e601491 | ||
|
|
0df9f297b6 | ||
|
|
f015c3ff52 | ||
|
|
15a0156a8c | ||
|
|
fad1c8a071 | ||
|
|
e2c92be90b | ||
|
|
ba06376c79 | ||
|
|
c2004fe662 | ||
|
|
373e3fc719 | ||
|
|
08f66133ef | ||
|
|
dca9cecaee | ||
|
|
d4c6bdfeae | ||
|
|
6b5eba1f43 | ||
|
|
1b77145687 | ||
|
|
7cda26aa6c | ||
|
|
61150870e2 | ||
|
|
75a011977d | ||
|
|
8aabb79a83 | ||
|
|
cabb55380f | ||
|
|
0ab1449215 | ||
|
|
c5e8bd08b8 | ||
|
|
de5f1fa99a | ||
|
|
26e64bda14 | ||
|
|
f52db027a0 | ||
|
|
98af51748d | ||
|
|
0212188cb6 | ||
|
|
5f5e3b4511 | ||
|
|
48bb3b0a74 | ||
|
|
4c6fe55d20 | ||
|
|
814386a10b | ||
|
|
c17a48ccfd | ||
|
|
9a4fb3ed7e | ||
|
|
ee915cfede | ||
|
|
8961eae3f0 | ||
|
|
7f4462e5c0 | ||
|
|
01d95b9757 | ||
|
|
504f0dfa36 | ||
|
|
b77f36fb1c | ||
|
|
9b7e431b89 | ||
|
|
faf96ff99b | ||
|
|
4399eee6e0 | ||
|
|
016c34ff1d | ||
|
|
1d5b5db4d2 | ||
|
|
aef8d1771d | ||
|
|
7ddcca6c77 | ||
|
|
c452a1e7e5 | ||
|
|
01087cb936 | ||
|
|
180cecda85 | ||
|
|
4f80cc1943 | ||
|
|
ebd8b00cc3 | ||
|
|
b25a0d013b | ||
|
|
7d5afcbb3f | ||
|
|
bbf3eec786 | ||
|
|
ec0cf9af04 | ||
|
|
46c8864048 | ||
|
|
23c58081d0 | ||
|
|
205c595b13 | ||
|
|
178e510aae | ||
|
|
7f943b5d8f | ||
|
|
5955f354f7 | ||
|
|
efb7e4742f | ||
|
|
b33deb4159 | ||
|
|
652712e0ad | ||
|
|
9f2c0a80b4 | ||
|
|
da1925cb67 | ||
|
|
277a4b6952 | ||
|
|
229323d37a | ||
|
|
0e6f314dbb | ||
|
|
cf0657852f | ||
|
|
66dcc4ee8f | ||
|
|
1b1580cbc3 | ||
|
|
e72f601925 | ||
|
|
94b6d9f8b2 | ||
|
|
6dbd5bd446 | ||
|
|
2bb00f6726 | ||
|
|
95eac52e92 | ||
|
|
e0b53cae41 | ||
|
|
02182d5a30 | ||
|
|
159b3002e4 | ||
|
|
a901396ad1 | ||
|
|
c49647ee23 | ||
|
|
db606a8475 | ||
|
|
6ccca4ae95 | ||
|
|
b248b4816b | ||
|
|
d2ad7d6b4c | ||
|
|
bde07ddb15 | ||
|
|
04061bc801 | ||
|
|
88c49f9e68 | ||
|
|
f39f56a096 | ||
|
|
2000227e9e | ||
|
|
f43e83c937 | ||
|
|
8a8f9dc8cb | ||
|
|
0fb1de5f73 | ||
|
|
b7f9bf5a5c | ||
|
|
3260da003d | ||
|
|
ec67290e0b | ||
|
|
c89632b647 | ||
|
|
5813fa4584 | ||
|
|
233765b361 | ||
|
|
e3b77d6d2c | ||
|
|
248169b646 | ||
|
|
88fe39bc8b | ||
|
|
43c6c260de | ||
|
|
4a360ac1cc | ||
|
|
3eb2d64392 | ||
|
|
b05c6158c0 | ||
|
|
ec7495c993 | ||
|
|
ec10d12112 | ||
|
|
3cc8b2a3d0 | ||
|
|
a2d0d6b0c2 | ||
|
|
40db92f609 | ||
|
|
3faddfb506 | ||
|
|
2fd02c2060 | ||
|
|
624d920351 | ||
|
|
0604d25101 | ||
|
|
1e8d9666b0 | ||
|
|
1c5fda115f | ||
|
|
a329b9e1ee | ||
|
|
e427262044 | ||
|
|
9ec9fbf58d | ||
|
|
de743c5a54 | ||
|
|
194f0786d4 | ||
|
|
8284c035a0 | ||
|
|
ae80adbefb | ||
|
|
c9b6a8b408 | ||
|
|
3156d94bca | ||
|
|
79be940130 | ||
|
|
0671a2a788 | ||
|
|
168f8a758e | ||
|
|
46030f5489 | ||
|
|
c0312748c4 | ||
|
|
a30ac3f8d7 | ||
|
|
6745fe8e70 | ||
|
|
2c0c9c92f4 | ||
|
|
2585249737 | ||
|
|
3d3cf96dc9 | ||
|
|
86ebceeb2e | ||
|
|
c4f14a39a5 | ||
|
|
9cdf8a1e2f | ||
|
|
e964987cd2 | ||
|
|
b79effefee | ||
|
|
d91ef6bb17 | ||
|
|
b3ec4f08d1 | ||
|
|
cd019cfa41 | ||
|
|
5c4c6a4207 | ||
|
|
b58572e283 | ||
|
|
4d47f9a4c0 | ||
|
|
90fd26b602 | ||
|
|
3844513431 | ||
|
|
6b52105b23 | ||
|
|
d786b4eb55 | ||
|
|
1fdeee380e | ||
|
|
2e389b6a46 | ||
|
|
f4dc9b1232 | ||
|
|
aa687a08cd | ||
|
|
e57fa51412 | ||
|
|
3c3ef6067e | ||
|
|
ec8e7003a6 | ||
|
|
6c7fe58468 | ||
|
|
7b9066120a | ||
|
|
6e9d47bd12 | ||
|
|
9e4eca00ff | ||
|
|
404fd6d9ab | ||
|
|
6e7bd551f2 | ||
|
|
ca0fe884ff | ||
|
|
d5cc0d53b7 | ||
|
|
1a7669bc63 | ||
|
|
447a3643c6 | ||
|
|
950e5c8c50 | ||
|
|
0af55f971d | ||
|
|
a13468320c | ||
|
|
c8a953af93 | ||
|
|
ac69776330 | ||
|
|
48a14e41e2 | ||
|
|
32fbb9ff01 | ||
|
|
9c00268914 | ||
|
|
5d775122c1 | ||
|
|
99c88629c3 | ||
|
|
9a6744baba | ||
|
|
befb0f3d39 | ||
|
|
d1470360c4 | ||
|
|
94ac563399 | ||
|
|
cbf72e5e26 | ||
|
|
9c5e8eb495 | ||
|
|
3c8050c44c | ||
|
|
45930457ca | ||
|
|
167e73cd5f | ||
|
|
110042d840 | ||
|
|
ea8f4ebb4d | ||
|
|
0c67dc7f82 | ||
|
|
e98760a1bf | ||
|
|
67c12e0368 | ||
|
|
989e53c20d | ||
|
|
bbcac0019b | ||
|
|
64b6cafcaa | ||
|
|
98f2e568b3 | ||
|
|
c289e3ea87 | ||
|
|
c0ac4564f7 | ||
|
|
07b28a6dd6 | ||
|
|
85ef8fb975 | ||
|
|
c885a1c243 | ||
|
|
dd772307a3 | ||
|
|
43b03b7621 | ||
|
|
9868f4cf29 | ||
|
|
d3cf65eb14 | ||
|
|
3aefd355c4 | ||
|
|
8d492637af | ||
|
|
5de8f8e8a9 | ||
|
|
338a0062c4 | ||
|
|
69e646f680 | ||
|
|
d41f595c75 | ||
|
|
a9669c0f9f | ||
|
|
2294c28355 | ||
|
|
499ccd1522 | ||
|
|
de195645f9 | ||
|
|
357e3ecc65 | ||
|
|
f359299df4 | ||
|
|
3d5be4c5a9 | ||
|
|
6db000630c | ||
|
|
fd05179d0a | ||
|
|
e067203b22 | ||
|
|
817ca4bf65 | ||
|
|
41175edd98 | ||
|
|
b6e04fa6a2 | ||
|
|
efe7393064 | ||
|
|
d7a90ebea6 | ||
|
|
e5cd050e51 | ||
|
|
aca22366f2 | ||
|
|
e8a90a03df | ||
|
|
c842f542cd | ||
|
|
415a338dc6 | ||
|
|
ceb7e04108 | ||
|
|
ee6c42945a | ||
|
|
abf70ac04e | ||
|
|
2ce12552bb | ||
|
|
289eea04d0 | ||
|
|
ac28341ebf | ||
|
|
ca4264202e | ||
|
|
af5e0b26ef | ||
|
|
f4cc4655ef | ||
|
|
5a82e4aa19 | ||
|
|
eff8b41fb0 | ||
|
|
d593f5b062 | ||
|
|
6af2fa4ec3 | ||
|
|
6db48f70e8 | ||
|
|
818aa36f7c | ||
|
|
7d0bb236f2 | ||
|
|
1882984380 | ||
|
|
9342deeae3 | ||
|
|
26352f5a13 | ||
|
|
ff50cdf396 | ||
|
|
7e0584579c | ||
|
|
5200e8a436 | ||
|
|
ac43f47820 | ||
|
|
448eb36f75 | ||
|
|
65030f3164 | ||
|
|
7811e313b3 | ||
|
|
ddf9fbed34 | ||
|
|
29f8715f05 | ||
|
|
5c39e0019d | ||
|
|
47eb4ca14f | ||
|
|
9eee202a69 | ||
|
|
a54c73687f | ||
|
|
a57ab2448f | ||
|
|
c982358753 | ||
|
|
18a514e39e | ||
|
|
33fc2375f8 | ||
|
|
ad925bd43b | ||
|
|
9108ae0114 | ||
|
|
2ab3a4e422 | ||
|
|
5d799c2d20 | ||
|
|
125f0c31dd | ||
|
|
e1c1c57242 | ||
|
|
0556ac0291 | ||
|
|
eb814b0216 | ||
|
|
a002c416c7 | ||
|
|
fd790e2977 | ||
|
|
6b82eaa2cd | ||
|
|
70e51b81cf | ||
|
|
0e2a06ae10 | ||
|
|
6048cd43a5 | ||
|
|
d7896ed4c9 | ||
|
|
f6de2b3885 | ||
|
|
2a01fbb56c | ||
|
|
7f8141ead9 | ||
|
|
ab7aa88ef2 | ||
|
|
4408e60c31 | ||
|
|
165cc581cd | ||
|
|
ff5354ee4f | ||
|
|
00da318350 | ||
|
|
eea71708ac | ||
|
|
79197b3196 | ||
|
|
d0bc520de8 | ||
|
|
e0d1a2a9b9 | ||
|
|
68c5a892d0 | ||
|
|
375afbad2d | ||
|
|
a00e7d3898 | ||
|
|
1bb0ebab0b | ||
|
|
97aa0c8c01 | ||
|
|
e61fe1c539 | ||
|
|
88d8d6af93 | ||
|
|
b9a2c11521 | ||
|
|
ecb6da9289 | ||
|
|
5c9a8f33b3 | ||
|
|
d7b23d5bca | ||
|
|
a059309a9f | ||
|
|
3bc728eaa9 | ||
|
|
c81271ee6e | ||
|
|
3d96111a5a | ||
|
|
f5f0b2c7c9 | ||
|
|
28beea9e88 | ||
|
|
edd7c8e4a1 | ||
|
|
9b97e1ef2f | ||
|
|
94d8391c03 | ||
|
|
e00cb664ad | ||
|
|
323c9760d3 | ||
|
|
edcf862da5 | ||
|
|
78d226bb3b | ||
|
|
6899eff155 | ||
|
|
9e9feb52f4 | ||
|
|
48acdd3d85 | ||
|
|
d0f7c8fa28 | ||
|
|
5d19beb547 | ||
|
|
13c97c5a8d | ||
|
|
b7ba7c3f2a | ||
|
|
f07c87405c | ||
|
|
03d774d6d8 | ||
|
|
4e60ad7212 | ||
|
|
d916f176e1 | ||
|
|
e2c8e7c8ae | ||
|
|
d7083bab4c | ||
|
|
5e0850fc54 | ||
|
|
1c1c75df72 | ||
|
|
aef93881af | ||
|
|
1d77170a30 | ||
|
|
6ee60fcfe2 | ||
|
|
b66e91ba77 | ||
|
|
896fd13b1c | ||
|
|
ddeaebfc68 | ||
|
|
04eac15f43 | ||
|
|
754b4234cb | ||
|
|
cf235b209f | ||
|
|
ac07701833 | ||
|
|
ff871e162a | ||
|
|
f0a86450b1 | ||
|
|
d60ab48511 | ||
|
|
b86435f0b5 | ||
|
|
6fcfeed5dc | ||
|
|
b2f9f197a5 | ||
|
|
6da73ac90f | ||
|
|
cc835b6d72 | ||
|
|
cbaf858227 | ||
|
|
1f794d2816 | ||
|
|
ba7ce3c6b9 | ||
|
|
57ec361682 | ||
|
|
d75e16a1b9 | ||
|
|
131577a4dc | ||
|
|
e996159738 | ||
|
|
8bd24ad6d4 | ||
|
|
3ee0342061 | ||
|
|
10b313d628 | ||
|
|
e9989f3a92 | ||
|
|
ff4bf0c367 | ||
|
|
a559ccc084 | ||
|
|
f169e0aafd | ||
|
|
6f7d9736e2 | ||
|
|
8eb0a1777f | ||
|
|
f526d96c98 | ||
|
|
19065e4a2f | ||
|
|
88585da2e8 | ||
|
|
eb6dd2c65d | ||
|
|
0b4fc26d4a | ||
|
|
48d9966aa1 | ||
|
|
d160342b55 | ||
|
|
1a242cd4f5 | ||
|
|
2c8f78e723 | ||
|
|
a9eaf0c993 | ||
|
|
6f18decb7a | ||
|
|
f1a55cbd52 | ||
|
|
9b517b50cb | ||
|
|
8aff1807fa | ||
|
|
b4fdd1470b | ||
|
|
1c3ff34d75 | ||
|
|
59defa3e71 | ||
|
|
ab398ae86d | ||
|
|
87aa319568 | ||
|
|
567fe2957d | ||
|
|
df8505b09d | ||
|
|
0903fa61d0 | ||
|
|
bcdfbb8b84 | ||
|
|
26bcc95665 | ||
|
|
583eb711ec | ||
|
|
46d53d3b59 | ||
|
|
b77444ee48 | ||
|
|
cde6d60c18 | ||
|
|
17eab1ed4d | ||
|
|
02f8fb7147 | ||
|
|
8477a67faf | ||
|
|
85a3d5312f | ||
|
|
d761b98adc | ||
|
|
98cc6df7ff | ||
|
|
83c225b243 | ||
|
|
c1579b7727 | ||
|
|
9968db65db | ||
|
|
d124c5aa20 | ||
|
|
721ad1587a | ||
|
|
b2c5ba6d4c | ||
|
|
424c6d0a5f | ||
|
|
583a60f8b5 | ||
|
|
94abfa76e2 | ||
|
|
c92ebd6a41 | ||
|
|
1fb09069c3 | ||
|
|
70f580041f | ||
|
|
9995e1b4d5 | ||
|
|
bf9329486b | ||
|
|
44c6ad7dce | ||
|
|
3e6f7494af | ||
|
|
78f3985c60 | ||
|
|
06a39015f2 | ||
|
|
0901801238 | ||
|
|
cb408bb06b | ||
|
|
fa814eb9ed | ||
|
|
a4f80f905d | ||
|
|
5702858553 | ||
|
|
3631af8107 | ||
|
|
57854219d0 | ||
|
|
324a95db8b | ||
|
|
f4e17a4b54 | ||
|
|
9cbe28d75e | ||
|
|
1fbb4e4e6a | ||
|
|
46c622aa3b | ||
|
|
3fb5b4bec9 | ||
|
|
890139f998 | ||
|
|
0802a10273 | ||
|
|
f199cec885 | ||
|
|
a433cef05f | ||
|
|
7cc4258dd5 | ||
|
|
e4fba78d81 | ||
|
|
9dc7bd4d05 | ||
|
|
38f11a0844 | ||
|
|
cf194419c3 | ||
|
|
9657b8e8ce | ||
|
|
fffb8c9e2c | ||
|
|
023e33cb07 | ||
|
|
98256b192b | ||
|
|
8c2a390fbc | ||
|
|
4af590a5f8 | ||
|
|
03c303d953 | ||
|
|
27c7e1e07b | ||
|
|
516356835d | ||
|
|
cce00498cd | ||
|
|
ae29d14abf | ||
|
|
13deea2a9d | ||
|
|
1912be8619 | ||
|
|
c49d909b60 | ||
|
|
f0b43bfd34 | ||
|
|
1b82c0e3d9 | ||
|
|
4f4d108639 | ||
|
|
5613f5fd05 | ||
|
|
35cd2af159 | ||
|
|
6a5a1353c7 | ||
|
|
220d3ec26f | ||
|
|
bf95f762b5 | ||
|
|
40a5942091 | ||
|
|
b823a5a266 | ||
|
|
29f39db857 | ||
|
|
651ec2027d | ||
|
|
4f5e817782 | ||
|
|
d204ec0cc9 | ||
|
|
4712931e71 | ||
|
|
ce039eb103 | ||
|
|
d43f2f73f7 | ||
|
|
db2858cec7 | ||
|
|
ae25afdb62 | ||
|
|
022a422755 | ||
|
|
f1f92b8656 | ||
|
|
5fb9c0c937 | ||
|
|
880b39f061 | ||
|
|
d29f77bece | ||
|
|
c32878d1b7 | ||
|
|
4b35003051 | ||
|
|
0ed24da686 | ||
|
|
e973aa278f | ||
|
|
2bb448908d | ||
|
|
125ebd0987 | ||
|
|
a7ab09fa4e | ||
|
|
384ddae86f | ||
|
|
508945965a | ||
|
|
67f8683ca3 | ||
|
|
2fa86c6a42 | ||
|
|
86885f31c1 | ||
|
|
70c326f2be | ||
|
|
61d583d59d | ||
|
|
2a0350b5b4 | ||
|
|
3132969c68 | ||
|
|
d1fa0f9628 | ||
|
|
edd97365f2 | ||
|
|
6e80294079 | ||
|
|
1f01ab3a30 | ||
|
|
a2d67959d7 | ||
|
|
83b525bc1f | ||
|
|
25aa72edbd | ||
|
|
1ba9f5ded3 | ||
|
|
3bf518e518 | ||
|
|
e3d802a10b | ||
|
|
9fa8b86891 | ||
|
|
fd8877b5fd | ||
|
|
81f20d8464 | ||
|
|
57c952f679 | ||
|
|
53d14d0561 | ||
|
|
ff47c51608 | ||
|
|
6940a01e74 | ||
|
|
55f4b66a52 | ||
|
|
c86b89a5fe | ||
|
|
22d98b4d52 | ||
|
|
172eadbb6f | ||
|
|
ade3a6a3ad | ||
|
|
3a2502de92 | ||
|
|
5c01418442 | ||
|
|
ea3749872a | ||
|
|
0c125be717 | ||
|
|
419eea2462 | ||
|
|
9536d66a35 | ||
|
|
76ce72cbe5 | ||
|
|
3a5627d911 | ||
|
|
253b24445e | ||
|
|
5e33bb6458 | ||
|
|
d831b8e7bd | ||
|
|
8e9d5c43d2 | ||
|
|
957d50ad49 | ||
|
|
e6e1696c28 | ||
|
|
6c6bc7fff5 | ||
|
|
018a6db132 | ||
|
|
8bfdffad32 | ||
|
|
f14a0ed6cf | ||
|
|
b4cf06b0d7 | ||
|
|
3015eeca94 | ||
|
|
53773ccee4 | ||
|
|
f85534fc52 | ||
|
|
0f4eccefd4 | ||
|
|
9eda9d1114 | ||
|
|
25a4a620d1 | ||
|
|
4e3e659a20 | ||
|
|
826eb7c552 | ||
|
|
08ecc518ec | ||
|
|
54f87184f0 | ||
|
|
6062f90d8b | ||
|
|
a792068d9d | ||
|
|
a51ee5b02d | ||
|
|
8725364cf0 | ||
|
|
3553aa3763 | ||
|
|
4b4f71a2cc | ||
|
|
1e5450f23e | ||
|
|
5a7d31108e | ||
|
|
491ce8b753 | ||
|
|
3a58621e72 | ||
|
|
ac1b48efbc | ||
|
|
46bad8676c | ||
|
|
bd69510662 | ||
|
|
4a1745281e | ||
|
|
856a1692ff | ||
|
|
b7735f88fa | ||
|
|
adc37670e8 | ||
|
|
3c36ea0dd7 | ||
|
|
4c613fbfe0 | ||
|
|
81b9058cc3 | ||
|
|
a9407d2f65 | ||
|
|
9e00234d2d | ||
|
|
3b5d30b5fd | ||
|
|
27adbf9a1f | ||
|
|
e0bb46b93a | ||
|
|
1ed4f747e9 | ||
|
|
e7933c9137 | ||
|
|
69aec10852 | ||
|
|
322f0bb7bc | ||
|
|
56024b7828 | ||
|
|
94c012b2ec | ||
|
|
fb70de8046 | ||
|
|
2696f2576d | ||
|
|
ad4a74c884 | ||
|
|
b6fd843288 | ||
|
|
b3fc9fe079 | ||
|
|
394037c174 | ||
|
|
e20de0f603 | ||
|
|
2976517bc7 | ||
|
|
00205cab08 | ||
|
|
db8de0db7a | ||
|
|
aec0c56386 | ||
|
|
b278098a7c | ||
|
|
29664863a5 | ||
|
|
61d9a6d750 | ||
|
|
ce62516251 | ||
|
|
5a7b861ea2 | ||
|
|
e96428b008 | ||
|
|
102e4f2c9d | ||
|
|
476bd35431 | ||
|
|
be17d55a5e | ||
|
|
51d44ab1fc | ||
|
|
e292d3976a | ||
|
|
f79d842029 | ||
|
|
74949eda2f | ||
|
|
9d4500f3ac | ||
|
|
3782294e92 | ||
|
|
3809ff4f2a | ||
|
|
c946ced9d5 | ||
|
|
dd4790130e | ||
|
|
532a6a7a89 | ||
|
|
c9b9fffc40 | ||
|
|
2bf5e5f20d | ||
|
|
20ec5cdc42 | ||
|
|
fc16df30dd | ||
|
|
47b8e56e3f | ||
|
|
57bc26893e | ||
|
|
eca402da79 | ||
|
|
e453a39d6b | ||
|
|
f7196e3b53 | ||
|
|
9d5db92cda | ||
|
|
939712fbbf | ||
|
|
55ca2df62a | ||
|
|
198f20fd20 | ||
|
|
e3d5518838 | ||
|
|
46f27b6e07 | ||
|
|
8f27b3e21f | ||
|
|
cd15ce35a0 | ||
|
|
395bd578d2 |
@@ -1,16 +1,17 @@
|
||||
---
|
||||
name: autoreview
|
||||
description: "Autoreview closeout: local dirty changes, PR branch vs main, parallel tests."
|
||||
description: "Auto Review closeout. Codex review is the default when no engine is set and is the recommended reviewer."
|
||||
---
|
||||
|
||||
# Autoreview
|
||||
# Auto Review
|
||||
|
||||
Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing.
|
||||
Run the bundled structured review helper as a closeout check. This is code review, not Guardian `auto_review` approval routing.
|
||||
|
||||
Codex native review mode performs best and is recommended. Non-Codex reviewers are fallback/second-opinion paths that receive a generated diff prompt, not the full Codex review-mode runtime.
|
||||
Codex review is the default when no engine is set. It usually delivers the best review results and should remain the normal final closeout engine.
|
||||
|
||||
Use when:
|
||||
- user asks for Codex review / autoreview / second-model review
|
||||
|
||||
- user asks for Codex review / Claude review / autoreview / second-model review
|
||||
- after non-trivial code edits, before final/commit/ship
|
||||
- reviewing a local branch or PR branch after fixes
|
||||
|
||||
@@ -21,12 +22,18 @@ Use when:
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- Keep going until the selected review path returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the review helper.
|
||||
- Default to Codex review. If Codex is unavailable or exits with an error, the helper falls back to the first configured CLI from `claude -p`, `pi -p`, `opencode run`, `droid exec`, or `copilot`. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
|
||||
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
|
||||
- Keep going until structured review returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
|
||||
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
|
||||
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
|
||||
- Tools are useful in review mode. The helper allows read-only inspection tools and web search by default so reviewers can check dependency contracts, upstream docs, and current behavior.
|
||||
- Security perspective is always included, but it should not cripple legitimate functionality. Report security findings only when the change creates a concrete, actionable risk or removes an important safety check.
|
||||
- Do not invoke built-in `codex review`, nested reviewers, or reviewer panels from inside the review. The helper builds one bundle, calls one selected engine, validates one structured result, and stops.
|
||||
- Stop as soon as the helper exits 0 with no accepted/actionable findings. Do not run an extra review just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
|
||||
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
|
||||
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
|
||||
- If `gh`/Gitcrawl reports `database disk image is malformed`, run `gitcrawl doctor --json` once to let the portable cache repair before retrying review; do not bypass the shim unless repair fails and freshness requires live GitHub.
|
||||
- If Gitcrawl reports a portable manifest mismatch, source/runtime DB health error, or stale portable-store checkout, run `gitcrawl doctor --json` and inspect `source_db_health`, `runtime_db_health`, and `portable_store_status` before falling back to live GitHub.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
|
||||
## Pick Target
|
||||
@@ -34,41 +41,44 @@ Use when:
|
||||
Dirty local work:
|
||||
|
||||
```bash
|
||||
codex review --uncommitted
|
||||
<autoreview-helper> --mode local
|
||||
```
|
||||
|
||||
Use this only when the patch is actually unstaged/staged/untracked in the
|
||||
current checkout. For committed, pushed, or PR work, point Codex at the commit
|
||||
current checkout. For committed, pushed, or PR work, point the helper at the commit
|
||||
or branch diff instead; do not force `--mode local` / `--uncommitted` just
|
||||
because the helper docs mention dirty work first. A clean `--uncommitted` review
|
||||
because the helper docs mention dirty work first. A clean local review
|
||||
only proves there is no local patch.
|
||||
|
||||
Branch/PR work:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
codex review --base origin/main
|
||||
<autoreview-helper> --mode branch --base origin/main
|
||||
```
|
||||
|
||||
Do not pass an inline prompt with `--base`; current CLI rejects `--base` + `[PROMPT]` even though help text is ambiguous. If custom instructions are needed, run the plain base review first, then do a local/manual follow-up pass.
|
||||
Optional review context is first-class:
|
||||
|
||||
```bash
|
||||
<autoreview-helper> --mode branch --base origin/main --prompt-file /tmp/review-notes.md --dataset /tmp/evidence.json
|
||||
```
|
||||
|
||||
If an open PR exists, use its actual base:
|
||||
|
||||
```bash
|
||||
base=$(gh pr view --json baseRefName --jq .baseRefName)
|
||||
codex review --base "origin/$base"
|
||||
<autoreview-helper> --mode branch --base "origin/$base"
|
||||
```
|
||||
|
||||
Committed single change:
|
||||
|
||||
```bash
|
||||
codex review --commit HEAD
|
||||
<autoreview-helper> --mode commit --commit HEAD
|
||||
```
|
||||
|
||||
or with the helper:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --mode commit --commit HEAD
|
||||
/Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode commit --commit HEAD
|
||||
```
|
||||
|
||||
Use commit review for already-landed or already-pushed work on `main`. Reviewing
|
||||
@@ -81,50 +91,63 @@ with `--base`.
|
||||
Format first if formatting can change line locations. Then it is OK to run tests and review in parallel:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --parallel-tests "<focused test command>"
|
||||
scripts/autoreview --parallel-tests "<focused test command>"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Context Efficiency
|
||||
|
||||
Codex review is usually noisy. Default to a subagent filter when subagents are available. Ask it to run the review and return only:
|
||||
- actionable findings it accepts
|
||||
- findings it rejects, with one-line reason
|
||||
- exact files/tests to rerun
|
||||
|
||||
Run inline only for tiny changes or when subagents are unavailable.
|
||||
Run the helper directly so target selection, engine choice, structured validation, and exit status all stay in one path. If output is noisy, summarize the completed helper output after it returns; do not ask another agent or reviewer to rerun the review.
|
||||
|
||||
## Helper
|
||||
|
||||
Bundled helper:
|
||||
OpenClaw repo-local helper:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --help
|
||||
```
|
||||
|
||||
`agent-scripts` checkout helper:
|
||||
|
||||
```bash
|
||||
skills/autoreview/scripts/autoreview --help
|
||||
```
|
||||
|
||||
Global helper from `agent-scripts`:
|
||||
|
||||
```bash
|
||||
~/.codex/skills/agent-scripts/autoreview/scripts/autoreview --help
|
||||
```
|
||||
|
||||
If installed from `agent-scripts`, path is:
|
||||
|
||||
```bash
|
||||
/Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --help
|
||||
```
|
||||
|
||||
The helper:
|
||||
- chooses dirty `--uncommitted` first
|
||||
|
||||
- chooses dirty local changes first
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- supports `--engine codex`, `claude`, `droid`, `copilot`, `pi`, and `opencode`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
|
||||
- `--engine pi` requires an explicit `--model` because the helper isolates Pi's config directory during review
|
||||
- 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
|
||||
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
|
||||
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is configured CLI fallback
|
||||
- falls back only when Codex is unavailable or exits nonzero, not when Codex reports findings
|
||||
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
|
||||
- supports `--dry-run`, `--parallel-tests`, and commit refs
|
||||
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
|
||||
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
|
||||
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
|
||||
- writes only to stdout unless `--output` or `--json-output` is set
|
||||
- supports `--dry-run`, `--parallel-tests`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
|
||||
- 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
|
||||
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
|
||||
- exits nonzero when accepted/actionable findings are present
|
||||
|
||||
## Final Report
|
||||
|
||||
Include:
|
||||
|
||||
- review command used
|
||||
- tests/proof run
|
||||
- findings accepted/rejected, briefly why
|
||||
- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected
|
||||
|
||||
Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
|
||||
Do not run another review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
203
.agents/skills/autoreview/scripts/test-review-harness
Executable file
203
.agents/skills/autoreview/scripts/test-review-harness
Executable file
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: test-review-harness [--fixture malicious|benign] [--engine codex|claude|droid|copilot|pi|opencode]...
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
mkdir -p uploads repos/sample
|
||||
: > uploads/.keep
|
||||
: > repos/sample/.keep
|
||||
|
||||
git add app.js uploads/.keep repos/sample/.keep
|
||||
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 fs from "node:fs";
|
||||
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");
|
||||
const resolved = safeChildPath(root, name);
|
||||
const realRoot = fs.realpathSync(root);
|
||||
const realResolved = fs.realpathSync(resolved);
|
||||
if (!realResolved.startsWith(realRoot + path.sep)) throw new Error("repo escaped root");
|
||||
return realResolved;
|
||||
}
|
||||
|
||||
export function uploadPath(name) {
|
||||
return safeChildPath(uploadsRoot, name);
|
||||
}
|
||||
|
||||
export async function repoStatus(repoName) {
|
||||
const { stdout } = await execFileAsync("git", [
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.hooksPath=/dev/null",
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"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
|
||||
fi
|
||||
|
||||
for engine in "${engines[@]}"; do
|
||||
echo "== $engine =="
|
||||
engine_args=()
|
||||
case "$engine" in
|
||||
pi|opencode)
|
||||
engine_args+=(--model "${AUTOREVIEW_HARNESS_MODEL:-openai/gpt-5.4-mini}")
|
||||
;;
|
||||
esac
|
||||
if [[ "$fixture" == malicious ]]; then
|
||||
"$script_dir/autoreview" \
|
||||
--mode local \
|
||||
--engine "$engine" \
|
||||
"${engine_args[@]}" \
|
||||
--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 "deleteUpload" \
|
||||
--require-any-finding "command,execSync,shell" \
|
||||
--expect-findings
|
||||
else
|
||||
"$script_dir/autoreview" \
|
||||
--mode local \
|
||||
--engine "$engine" \
|
||||
"${engine_args[@]}" \
|
||||
--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
|
||||
44
.agents/skills/channel-message-flows/SKILL.md
Normal file
44
.agents/skills/channel-message-flows/SKILL.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: channel-message-flows
|
||||
description: "Use when previewing local channel message flow fixtures."
|
||||
---
|
||||
|
||||
# Channel Message Flows
|
||||
|
||||
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
|
||||
|
||||
## Telegram
|
||||
|
||||
Native Telegram `sendMessageDraft` tool progress, then a final answer:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow working-final \
|
||||
--duration-ms 20000
|
||||
```
|
||||
|
||||
Thinking preview, then a final answer:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow thinking-final
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--account <accountId>`: Telegram account id when not using the default.
|
||||
- `--thread-id <id>`: Telegram forum topic/message thread id.
|
||||
- `--delay-ms <ms>`: Override preview update cadence.
|
||||
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
|
||||
- `--final-text <text>`: Override the durable final message.
|
||||
|
||||
## Notes
|
||||
|
||||
- `--target` is the numeric Telegram chat id.
|
||||
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
|
||||
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
|
||||
- Only `--channel telegram` is implemented for now.
|
||||
53
.agents/skills/control-ui-e2e/SKILL.md
Normal file
53
.agents/skills/control-ui-e2e/SKILL.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: control-ui-e2e
|
||||
description: Use when testing, fixing, or extending the OpenClaw Control UI GUI with Vitest + Playwright end-to-end checks, mocked Gateway WebSocket flows, or agent-verifiable browser proof.
|
||||
---
|
||||
|
||||
# Control UI E2E
|
||||
|
||||
Use this for Control UI changes that need a real browser flow with deterministic Gateway data.
|
||||
|
||||
## Test Shape
|
||||
|
||||
- Use `ui/src/**/*.e2e.test.ts` for full GUI flows.
|
||||
- Use `ui/src/test-helpers/control-ui-e2e.ts` to start the Vite Control UI and install a mocked Gateway WebSocket.
|
||||
- Keep scenarios deterministic. Do not use live provider keys, real channel credentials, or a real Gateway unless the user explicitly asks for live proof.
|
||||
- Prefer existing `.browser.test.ts` or unit tests for narrow rendering logic; use this E2E lane when the proof should cover routing, app boot, Gateway handshake, requests, and visible UI behavior together.
|
||||
|
||||
## Commands
|
||||
|
||||
- Target one E2E test in a Codex worktree:
|
||||
|
||||
```bash
|
||||
node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner ui/src/ui/e2e/chat-flow.e2e.test.ts
|
||||
```
|
||||
|
||||
- Run the whole local lane in a normal checkout:
|
||||
|
||||
```bash
|
||||
pnpm test:ui:e2e
|
||||
```
|
||||
|
||||
If dependencies are missing in a Codex worktree, install once with `pnpm install`; for broad GUI proof or dependency-heavy checks, use Testbox/Crabbox instead of running a wide local pnpm lane.
|
||||
|
||||
## Mock Pattern
|
||||
|
||||
Start the app server, install the mock before `page.goto`, then assert both Gateway traffic and visible UI:
|
||||
|
||||
```ts
|
||||
const server = await startControlUiE2eServer();
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
|
||||
});
|
||||
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.locator(".agent-chat__composer-combobox textarea").fill("hello");
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
const request = await gateway.waitForRequest("chat.send");
|
||||
await gateway.emitChatFinal({ runId: String(request.params.idempotencyKey), text: "Done." });
|
||||
await page.getByText("Done.").waitFor();
|
||||
```
|
||||
|
||||
Extend `installMockGateway` with typed scenario options or method responses when a new flow needs more Gateway surface.
|
||||
4
.agents/skills/control-ui-e2e/agents/openai.yaml
Normal file
4
.agents/skills/control-ui-e2e/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Control UI E2E"
|
||||
short_description: "Mocked browser E2E for Control UI"
|
||||
default_prompt: "Use $control-ui-e2e to verify a Control UI change with the mocked Vitest + Playwright browser lane."
|
||||
@@ -45,6 +45,10 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
|
||||
shim can be stale.
|
||||
- Check `.crabbox.yaml` for direct-provider defaults. Omitting `--provider`
|
||||
means brokered AWS today.
|
||||
- The brokered AWS default is a Linux developer image in `eu-west-1`; the repo
|
||||
config pins hot `eu-west-1a/b/c` placement so Fast Snapshot Restore can apply.
|
||||
If warmup drifts well past the minute-scale path, verify image promotion,
|
||||
region/AZ placement, and FSR state before blaming OpenClaw.
|
||||
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
|
||||
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
|
||||
Testbox policy applies.
|
||||
@@ -78,6 +82,25 @@ Use these only when the task needs an existing non-Linux host. OpenClaw broad
|
||||
Linux validation uses the repo Crabbox config unless a provider is explicitly
|
||||
requested.
|
||||
|
||||
Native brokered Windows is available for Windows-specific proof. Use the AWS
|
||||
developer image in `us-west-2` on demand; it has the expected OpenClaw developer
|
||||
toolchain and Docker image cache. Keep broad Linux gates on Linux/Testbox unless
|
||||
the bug is Windows-specific:
|
||||
|
||||
```sh
|
||||
../crabbox/bin/crabbox warmup \
|
||||
--provider aws \
|
||||
--target windows \
|
||||
--windows-mode normal \
|
||||
--region us-west-2 \
|
||||
--market on-demand \
|
||||
--timing-json
|
||||
```
|
||||
|
||||
The hydrate workflow assumes Docker should already be baked into Linux images
|
||||
and only installs it as a fallback. Do not add per-run Docker installs to proof
|
||||
commands unless the image probe shows Docker is actually missing.
|
||||
|
||||
When the user explicitly asks for brokered macOS runners, use Crabbox AWS
|
||||
macOS only after confirming the deployed coordinator supports EC2 Mac host
|
||||
lifecycle/image routes and the operator has AWS EC2 Mac Dedicated Host quota
|
||||
|
||||
64
.agents/skills/openclaw-docker-e2e-authoring/SKILL.md
Normal file
64
.agents/skills/openclaw-docker-e2e-authoring/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: openclaw-docker-e2e-authoring
|
||||
description: "Author OpenClaw Docker E2E and live provider Docker lanes."
|
||||
---
|
||||
|
||||
# OpenClaw Docker E2E Authoring
|
||||
|
||||
Use this when adding or changing Docker E2E lanes, release-path Docker tests,
|
||||
or live-provider Docker proof.
|
||||
|
||||
## Lane Choice
|
||||
|
||||
- Deterministic Docker: fake the dependency/server and assert the exact runtime
|
||||
contract crossing the boundary.
|
||||
- Live Docker: use real provider credentials/model only when user-visible
|
||||
behavior needs the real service.
|
||||
- Prefer both when they prove different risks: deterministic for byte/payload
|
||||
routing, live for actual provider behavior.
|
||||
|
||||
## Authoring Rules
|
||||
|
||||
- Test-only helpers live in `test/helpers` or `scripts/e2e/lib/<lane>/`, not
|
||||
`src/**`, unless production imports them.
|
||||
- Package-installed app runs from `/app`; mount only explicit harness/helper
|
||||
paths read-only.
|
||||
- Fake servers should log boundary requests as JSONL and clients should assert
|
||||
the real dependency payload, not just process success.
|
||||
- Add the package script and `scripts/lib/docker-e2e-scenarios.mjs` lane in the
|
||||
same change.
|
||||
- If a lane installs a plugin from npm, default the spec via env so published
|
||||
and local override paths are both testable.
|
||||
|
||||
## Media And Vision
|
||||
|
||||
- Expected answer must exist only in pixels or provider output being tested.
|
||||
- Use neutral filenames, neutral prompts, and no metadata leaks.
|
||||
- Random bitmap/OCR tokens reuse the repo OCR-safe alphabet `24567ACEF` unless
|
||||
the test owns a stronger glyph set.
|
||||
- Make the expected answer unique per run when proving real image
|
||||
understanding.
|
||||
|
||||
## `chat.send` E2E
|
||||
|
||||
- Require `chat.send` to return `status: "started"` and a string `runId`.
|
||||
- Wait for completion with `agent.wait`.
|
||||
- Assert final user-visible text via `chat.history` when event ordering is not
|
||||
the behavior under test.
|
||||
- Keep originating channel/account metadata only when the bug path needs queued
|
||||
inbound/channel context.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the smallest proof that covers the touched lane:
|
||||
|
||||
```bash
|
||||
pnpm exec oxfmt --write <changed files>
|
||||
node --check <new .mjs files>
|
||||
bash -n <new .sh files>
|
||||
node scripts/run-vitest.mjs test/scripts/docker-e2e-plan.test.ts
|
||||
OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:<lane>
|
||||
```
|
||||
|
||||
For real-provider lanes, run the matching live Docker script after deterministic
|
||||
Docker is green. Finish with `$autoreview` before commit/PR.
|
||||
151
.agents/skills/openclaw-landable-bug-sweep/SKILL.md
Normal file
151
.agents/skills/openclaw-landable-bug-sweep/SKILL.md
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: openclaw-landable-bug-sweep
|
||||
description: "Find or repair small high-confidence non-SDK-boundary OpenClaw bugfix PRs until five are landable."
|
||||
---
|
||||
|
||||
# OpenClaw Landable Bug Sweep
|
||||
|
||||
Autonomous maintainer workflow for producing five landable OpenClaw bugfix PR URLs.
|
||||
Use for broad issue/PR sweeps where the bar is high and the output is PRs, not notes.
|
||||
Do not use for plugin SDK/API boundary work; those need separate architecture review.
|
||||
|
||||
## Target
|
||||
|
||||
Return exactly five PR URLs, each with:
|
||||
|
||||
- bug summary
|
||||
- why the fix is low-risk
|
||||
- proof: local/CI/Testbox/live commands or run IDs
|
||||
- issue/duplicate cleanup done or still pending
|
||||
|
||||
The five URLs may be existing PRs that were reviewed/fixed, or new PRs created from issues/clusters.
|
||||
|
||||
## Companion Skills
|
||||
|
||||
Use `$gitcrawl` for discovery/clustering, `$openclaw-pr-maintainer` for live GitHub mutation rules, `$github-author-context` when contributor trust matters, `$openclaw-testing` for proof choice, `$autoreview` before publishing/landing, and `$crabbox` for broad/E2E/live proof.
|
||||
|
||||
## Candidate Bar
|
||||
|
||||
Accept only when all are true:
|
||||
|
||||
- bug or paper cut, not feature/product/support/docs-only
|
||||
- root cause is proven in current code
|
||||
- dependency behavior checked via upstream docs/source/types when relevant
|
||||
- production/runtime diff is small, ideally much smaller than 500 LOC and always below 500 LOC
|
||||
- tests may be larger, but focused
|
||||
- no new dependency
|
||||
- no new config option
|
||||
- no backward-incompatible behavior
|
||||
- no security/product/owner-boundary decision needed
|
||||
- no plugin SDK, public plugin API, or `src/plugin-sdk/**` boundary change
|
||||
- no broad refactor smell
|
||||
- focused proof is feasible
|
||||
|
||||
Good examples:
|
||||
|
||||
- provider parameter mismatch proven against dependency/API contract
|
||||
- CLI command diverges from adjacent command behavior
|
||||
- narrow runtime state/serialization bug with failing test
|
||||
- issue already fixed on current `main`, with proof and closeable duplicates
|
||||
|
||||
Reject:
|
||||
|
||||
- feature requests, new knobs, migrations, release work, workflow policy, support
|
||||
- plugin SDK/API boundary changes, including compatibility shims, new SDK methods, SDK exports, or plugin-facing channel/provider seams
|
||||
- auth/security boundary changes unless explicitly assigned
|
||||
- bugs needing live credentials that are unavailable
|
||||
- fixes whose clean shape is a larger architecture move
|
||||
- speculative reports without reproducible/provable cause
|
||||
- UI/UX changes requiring product judgment
|
||||
|
||||
## Sweep Loop
|
||||
|
||||
1. Start clean:
|
||||
- `git status -sb`
|
||||
- `git pull --ff-only`
|
||||
- verify branch is expected, usually `main`
|
||||
2. Build candidate clusters:
|
||||
- `gitcrawl` open issues/PRs, neighbors, and search
|
||||
- live `gh issue/pr view`
|
||||
- include PRs linked from issues and duplicates
|
||||
3. For each cluster:
|
||||
- read issue/PR body, comments, labels, linked refs, current source, adjacent tests
|
||||
- suppress maintainer-owned queue noise unless it is the best fix path
|
||||
- identify opener/author and preserve credit
|
||||
- decide: `repair-existing-pr`, `create-new-pr`, `close-fixed-on-main`, `close-duplicate`, or `reject`
|
||||
4. Prove before patching:
|
||||
- failing test, focused repro, log/source proof, or dependency contract proof
|
||||
- if already fixed on `main`, prove with current source/test/commit and close kindly
|
||||
5. Patch:
|
||||
- prefer existing PR when good and writable
|
||||
- if unwritable or wrong shape, create own PR and preserve useful contributor credit
|
||||
- if no PR exists, create one
|
||||
- add regression test when it fits
|
||||
- changelog for user-facing fixes; thank credited human reporter/contributor
|
||||
6. Review and publish:
|
||||
- run focused proof
|
||||
- run `$autoreview` until no accepted/actionable findings remain
|
||||
- create/update PR with real body and proof fields
|
||||
- push branch
|
||||
- get CI green or document exact external blocker; do not count blocked PRs in the five
|
||||
7. Hygiene:
|
||||
- close duplicates and fixed-on-main issues with proof
|
||||
- never mutate more than five associated items in one cluster without explicit confirmation
|
||||
- comments must be kind, concrete, and include proof/PR/commit links
|
||||
8. Repeat until five landable PR URLs are ready.
|
||||
|
||||
## PR Body Proof
|
||||
|
||||
Use the repo PR template. Include these exact labels:
|
||||
|
||||
```text
|
||||
Behavior addressed:
|
||||
Real environment tested:
|
||||
Exact steps or command run after this patch:
|
||||
Evidence after fix:
|
||||
Observed result after fix:
|
||||
What was not tested:
|
||||
```
|
||||
|
||||
## Existing PR Rules
|
||||
|
||||
- Review code path beyond the diff before trusting it.
|
||||
- If PR is good: fix small issues, run proof, push if writable, get CI green.
|
||||
- If PR is not good but has a useful idea: recreate locally, co-author when warranted, close original with thanks and explanation.
|
||||
- If PR is duplicate or fixed on `main`: comment proof, close.
|
||||
- If maintainer cannot push to contributor branch: create own branch/PR, preserve useful commits or credit.
|
||||
|
||||
## Output Ledger
|
||||
|
||||
Maintain a running ledger:
|
||||
|
||||
```text
|
||||
accepted:
|
||||
- PR URL:
|
||||
source refs:
|
||||
bug:
|
||||
root cause:
|
||||
fix:
|
||||
risk:
|
||||
proof:
|
||||
CI:
|
||||
credit/thanks:
|
||||
cleanup:
|
||||
|
||||
rejected:
|
||||
- ref:
|
||||
reason:
|
||||
|
||||
closed:
|
||||
- ref:
|
||||
reason:
|
||||
proof/comment:
|
||||
```
|
||||
|
||||
Final answer:
|
||||
|
||||
- exactly five accepted PR URLs
|
||||
- 2-4 sentence explainer per PR
|
||||
- proof/CI state per PR
|
||||
- closed duplicates/fixed-on-main refs
|
||||
- current branch/status
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "OpenClaw Landable Bug Sweep"
|
||||
short_description: "Find five small non-SDK landable bugfix PRs"
|
||||
default_prompt: "Use $openclaw-landable-bug-sweep to find or repair five small high-confidence non-SDK-boundary OpenClaw bugfix PRs and get them landable."
|
||||
95
.agents/skills/openclaw-mac-release/SKILL.md
Normal file
95
.agents/skills/openclaw-mac-release/SKILL.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: openclaw-mac-release
|
||||
description: "Run or recover OpenClaw macOS release signing, notarization, appcast, and asset promotion."
|
||||
---
|
||||
|
||||
# OpenClaw Mac Release
|
||||
|
||||
Use with `$openclaw-release-maintainer`, `$openclaw-release-ci`, and `$one-password` when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
|
||||
|
||||
## Credentials
|
||||
|
||||
- Canonical ASC item: vault `Molty`, title `API Key - App Store Connect - Personal - Release`.
|
||||
- Fields: `private_key_p8`, `key_id`, `issuer_id`.
|
||||
- Current known good key id: `AKVLXW849T`.
|
||||
- Legacy mirror: vault `Private`, title `API Key - App Store Connect - Personal`; keep it synced for older refs.
|
||||
- Stale/revoked key symptom: `xcrun notarytool submit` fails with `HTTP status code: 401. Unauthenticated`.
|
||||
- Validate candidate ASC credentials with `xcrun notarytool history` before setting GitHub secrets.
|
||||
|
||||
## 1Password
|
||||
|
||||
- Use `$one-password`: all `op` work inside one persistent tmux session, no secret output.
|
||||
- Prefer `OP_SERVICE_ACCOUNT_TOKEN` from `~/.profile` for Molty reads.
|
||||
- Do not assume `MOLTY_OP_SERVICE_ACCOUNT_TOKEN` is alive; it has previously pointed at a deleted service account.
|
||||
- If a service token fails, run status-only checks: token present/length and `op whoami`; never print token values.
|
||||
- If desktop app auth is needed but Touch ID is unavailable, set `OP_BIOMETRIC_UNLOCK_ENABLED=false` for the manual `op account add --signin` path.
|
||||
|
||||
## GitHub Secrets
|
||||
|
||||
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
|
||||
|
||||
Set only after local notary auth validation:
|
||||
|
||||
- `APP_STORE_CONNECT_API_KEY_P8`
|
||||
- `APP_STORE_CONNECT_KEY_ID`
|
||||
- `APP_STORE_CONNECT_ISSUER_ID`
|
||||
|
||||
Do not update these from mixed sources. All three ASC fields must come from the same 1Password item.
|
||||
|
||||
## Workflow Shape
|
||||
|
||||
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
|
||||
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
|
||||
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
|
||||
- Real mac publish must reuse:
|
||||
- a successful private mac preflight run for the same tag/source SHA
|
||||
- a successful private mac validation run for the same tag/source SHA
|
||||
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
|
||||
|
||||
## Notarization
|
||||
|
||||
- OpenClaw uses `scripts/notarize-mac-artifact.sh`.
|
||||
- `xcrun notarytool submit` should use `--no-s3-acceleration`; accelerated upload can surface misleading 401s even when `notarytool history` succeeds.
|
||||
- If signing succeeds but notarization fails immediately with 401, check ASC key freshness first.
|
||||
- If notarization stays in progress for several minutes after key-file write, that is normal Apple wait time; do not edit blindly.
|
||||
|
||||
## Dispatch
|
||||
|
||||
Private preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D \
|
||||
-f preflight_only=true \
|
||||
-f smoke_test_only=false \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
```
|
||||
|
||||
Private validation for a branch-variation preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D
|
||||
```
|
||||
|
||||
Real publish:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f preflight_only=false \
|
||||
-f smoke_test_only=false \
|
||||
-f preflight_run_id=<successful-preflight-run> \
|
||||
-f validate_run_id=<successful-validation-run> \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
|
||||
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
|
||||
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.
|
||||
@@ -28,7 +28,10 @@ git status --short --branch
|
||||
git rev-parse HEAD
|
||||
```
|
||||
|
||||
If env lacks keys, use `$one-password` to inject or set them, then rerun the script. The script prints only provider status and HTTP class, never tokens.
|
||||
1Password service-account values are the first source for release provider
|
||||
preflight. Inject those exact targeted keys first, then run the verifier; use
|
||||
ambient env only when it was already intentionally injected for this release.
|
||||
The script prints only provider status and HTTP class, never tokens.
|
||||
|
||||
## Dispatch
|
||||
|
||||
|
||||
@@ -170,6 +170,13 @@ live`; keep it clearly beta and avoid implying stable promotion.
|
||||
CI, validation, or internal release mechanics unless the release is explicitly
|
||||
about those. Peter prefers concrete user wins: features, integrations,
|
||||
workflow improvements, and practical reliability fixes.
|
||||
- Do not feature QA parity, test coverage, release gates, or validation lanes in
|
||||
user-facing launch tweets. Keep them for release notes or maintainer proof
|
||||
unless the operator explicitly asks for validation-focused copy.
|
||||
- Do not feature plugin-author or developer tooling such as SDK helpers,
|
||||
tool-plugin scaffolding, build/validate/init commands, or internal CLI
|
||||
plumbing in general user-facing launch tweets unless the operator explicitly
|
||||
asks for developer-focused copy.
|
||||
- Tone: high-signal, slightly cheeky, confident, not corporate. One joke is
|
||||
enough. Avoid punching down, insulting users, or promising what was not
|
||||
verified.
|
||||
|
||||
@@ -27,7 +27,7 @@ Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
use the Crabbox wrapper with the provider that matches the proof surface.
|
||||
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
|
||||
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
|
||||
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
|
||||
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
|
||||
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
|
||||
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
|
||||
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
|
||||
@@ -131,6 +131,8 @@ gh run view <run-id> --job <job-id> --log
|
||||
- Check exact SHA. Ignore newer unrelated `main` unless asked.
|
||||
- For cancelled same-branch runs, confirm whether a newer run superseded it.
|
||||
- Fetch full logs only for failed or relevant jobs.
|
||||
- Prefer `gh run view <run-id> --json jobs` over PR rollup while debugging; rollup can be stale/noisy.
|
||||
- For `prompt:snapshots:check` failures, treat Linux Node 24 as CI truth. If macOS passes but CI drifts, reproduce in a Linux Node 24 container or Testbox, commit that generated output, then rerun.
|
||||
|
||||
## GitHub Release Workflows
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ artifact bundle. The runner leases the shared burner account from Convex.
|
||||
Run from the OpenClaw repo and branch under test:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- start \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" start \
|
||||
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
|
||||
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
|
||||
```
|
||||
@@ -39,7 +40,8 @@ For deterministic visual repros, put the exact mock-model reply in a file and
|
||||
pass it to `start`:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- start \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" start \
|
||||
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
|
||||
--mock-response-file .artifacts/qa-e2e/telegram-user-crabbox/reply.txt \
|
||||
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
|
||||
@@ -55,15 +57,16 @@ For visual proof, first send or identify a bottom marker message, then open the
|
||||
group/topic directly by message id:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- view \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" view \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--message-id <message-id>
|
||||
```
|
||||
|
||||
This uses Telegram Desktop directly with `tg://privatepost`, not `xdg-open`.
|
||||
It also resizes Telegram to `650x1000` at the tested desktop position so
|
||||
Telegram switches to single-chat mode with no left chat list or right info
|
||||
pane. Do not press Escape after this; Escape can close the selected chat.
|
||||
the crop can isolate the chat pane even if Telegram keeps a split/sidebar
|
||||
layout. Do not press Escape after this; Escape can close the selected chat.
|
||||
|
||||
Bottom behavior matters:
|
||||
|
||||
@@ -71,13 +74,14 @@ Bottom behavior matters:
|
||||
later messages appear live in the recording
|
||||
- deep-linking to an older message does not auto-scroll to new arrivals; link
|
||||
again to the newest/final marker instead of clicking the down-arrow
|
||||
- `650px` is the largest tested clean width; `660px` switches Telegram back to
|
||||
split/sidebar layout
|
||||
- the cropped GIF intentionally uses the chat pane, not the whole desktop or
|
||||
whole Telegram window
|
||||
|
||||
Send as the real Telegram user:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- send \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" send \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--text /status
|
||||
```
|
||||
@@ -87,7 +91,8 @@ For slash commands, omit the bot username; the runner targets the SUT bot.
|
||||
Run arbitrary commands on the Crabbox:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- run \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" run \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
-- bash -lc 'source /tmp/openclaw-telegram-user-crabbox/env.sh && python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py transcript --limit 20 --json'
|
||||
```
|
||||
@@ -106,14 +111,16 @@ python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py probe --text '@{sut}
|
||||
Capture the current desktop without ending the session:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- screenshot \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" screenshot \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
|
||||
```
|
||||
|
||||
Check lease state and get the WebVNC command:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- status \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" status \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
|
||||
```
|
||||
|
||||
@@ -122,7 +129,8 @@ pnpm qa:telegram-user:crabbox -- status \
|
||||
Always finish or explicitly keep the box:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- finish \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" finish \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--preview-crop telegram-window
|
||||
```
|
||||
@@ -150,7 +158,8 @@ Attach only the useful visual artifact to the PR unless logs are needed. The
|
||||
runner is GIF-only by default:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- publish \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" publish \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--pr <pr-number> \
|
||||
--summary 'Telegram real-user Crabbox session motion GIF'
|
||||
@@ -189,7 +198,8 @@ experiments unless those artifacts are explicitly needed.
|
||||
For a fast one-shot check, use:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- --text /status
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" --text /status
|
||||
```
|
||||
|
||||
This is a start/send/finish shortcut. Prefer the held session for PR review,
|
||||
|
||||
@@ -6,6 +6,10 @@ capacity:
|
||||
strategy: most-available
|
||||
fallback: on-demand-after-120s
|
||||
hints: true
|
||||
availabilityZones:
|
||||
- eu-west-1a
|
||||
- eu-west-1b
|
||||
- eu-west-1c
|
||||
regions:
|
||||
- eu-west-1
|
||||
- eu-west-2
|
||||
|
||||
6
.github/CODEOWNERS
vendored
6
.github/CODEOWNERS
vendored
@@ -13,6 +13,12 @@
|
||||
/.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
|
||||
/package-lock.json @openclaw/openclaw-secops
|
||||
/npm-shrinkwrap.json @openclaw/openclaw-secops
|
||||
/extensions/*/package-lock.json @openclaw/openclaw-secops
|
||||
/extensions/*/npm-shrinkwrap.json @openclaw/openclaw-secops
|
||||
/pnpm-lock.yaml @openclaw/openclaw-secops
|
||||
/scripts/generate-npm-shrinkwrap.mjs @openclaw/openclaw-secops
|
||||
/src/security/ @openclaw/openclaw-secops
|
||||
/src/secrets/ @openclaw/openclaw-secops
|
||||
/src/config/*secret*.ts @openclaw/openclaw-secops
|
||||
|
||||
51
.github/actions/setup-node-env/action.yml
vendored
51
.github/actions/setup-node-env/action.yml
vendored
@@ -7,14 +7,6 @@ inputs:
|
||||
description: Node.js version to install.
|
||||
required: false
|
||||
default: "24.x"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the pnpm store cache key.
|
||||
required: false
|
||||
default: "node24-pnpm11"
|
||||
pnpm-version:
|
||||
description: pnpm version for corepack.
|
||||
required: false
|
||||
default: "11.0.8"
|
||||
install-bun:
|
||||
description: Whether to install Bun alongside Node.
|
||||
required: false
|
||||
@@ -27,6 +19,10 @@ inputs:
|
||||
description: Whether to use --frozen-lockfile for install.
|
||||
required: false
|
||||
default: "true"
|
||||
use-actions-cache:
|
||||
description: Whether to restore and save the pnpm store with actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -36,12 +32,11 @@ runs:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: false
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
id: pnpm-cache
|
||||
- name: Setup pnpm
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: ${{ inputs.pnpm-version }}
|
||||
cache-key-suffix: ${{ inputs.cache-key-suffix }}
|
||||
node-version: ${{ inputs.node-version }}
|
||||
use-actions-cache: ${{ inputs.use-actions-cache }}
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
@@ -58,14 +53,15 @@ runs:
|
||||
if command -v bun &>/dev/null; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
if: inputs.install-deps == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
node_bin="$(cygpath -u "$node_bin")"
|
||||
fi
|
||||
# zizmor: ignore[github-env] node_bin comes from trusted actions/setup-node output in this composite action.
|
||||
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
|
||||
echo "$node_bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.install-deps == 'true'
|
||||
@@ -99,12 +95,25 @@ runs:
|
||||
if [ -n "$LOCKFILE_FLAG" ]; then
|
||||
install_args+=("$LOCKFILE_FLAG")
|
||||
fi
|
||||
append_pnpm_option_arg() {
|
||||
local env_name="$1"
|
||||
local option_name="$2"
|
||||
local value="${!env_name-}"
|
||||
if [ -n "$value" ]; then
|
||||
install_args+=("--${option_name}=${value}")
|
||||
fi
|
||||
}
|
||||
append_pnpm_option_arg PNPM_CONFIG_CHILD_CONCURRENCY child-concurrency
|
||||
append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir
|
||||
append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency
|
||||
append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
fi
|
||||
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
|
||||
|
||||
- name: Save pnpm store cache
|
||||
if: inputs.install-deps == 'true' && steps.pnpm-cache.outputs.cache-enabled == 'true' && steps.pnpm-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.store-path }}
|
||||
key: ${{ steps.pnpm-cache.outputs.primary-key }}
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
rm -rf node_modules
|
||||
ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
fi
|
||||
|
||||
115
.github/actions/setup-pnpm-store-cache/action.yml
vendored
115
.github/actions/setup-pnpm-store-cache/action.yml
vendored
@@ -1,91 +1,62 @@
|
||||
name: Setup pnpm + store cache
|
||||
description: Prepare pnpm via corepack and restore pnpm store cache.
|
||||
name: Setup pnpm
|
||||
description: Prepare pnpm from the repository packageManager and restore its store cache.
|
||||
inputs:
|
||||
pnpm-version:
|
||||
description: pnpm version to activate via corepack.
|
||||
package-manager-file:
|
||||
description: package.json file that owns the packageManager pnpm pin.
|
||||
required: false
|
||||
default: "11.0.8"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the cache key.
|
||||
default: "package.json"
|
||||
lockfile-path:
|
||||
description: pnpm lockfile used to key the store cache.
|
||||
required: false
|
||||
default: "node24-pnpm11"
|
||||
use-restore-keys:
|
||||
description: Whether to use restore-keys fallback for actions/cache.
|
||||
default: "pnpm-lock.yaml"
|
||||
node-version:
|
||||
description: Expected Node.js version already installed by actions/setup-node.
|
||||
required: false
|
||||
default: "true"
|
||||
default: ""
|
||||
use-actions-cache:
|
||||
description: Whether to restore pnpm store with actions/cache.
|
||||
description: Whether pnpm/action-setup should cache the pnpm store.
|
||||
required: false
|
||||
default: "true"
|
||||
outputs:
|
||||
cache-enabled:
|
||||
description: Whether actions/cache restore was enabled.
|
||||
value: ${{ steps.pnpm-cache-config.outputs.enabled }}
|
||||
cache-hit:
|
||||
description: Whether the pnpm store cache had an exact key hit.
|
||||
value: ${{ steps.pnpm-cache-restore.outputs.cache-hit }}
|
||||
cache-matched-key:
|
||||
description: Cache key matched by restore, if any.
|
||||
value: ${{ steps.pnpm-cache-restore.outputs.cache-matched-key }}
|
||||
primary-key:
|
||||
description: Primary pnpm store cache key.
|
||||
value: ${{ steps.pnpm-cache-config.outputs.primary-key }}
|
||||
store-path:
|
||||
description: Resolved pnpm store path.
|
||||
value: ${{ steps.pnpm-store.outputs.path }}
|
||||
pnpm-version:
|
||||
description: Resolved pnpm version activated by the setup action.
|
||||
value: ${{ steps.pnpm-version.outputs.pnpm-version }}
|
||||
project-dir:
|
||||
description: Directory containing the packageManager file used for pnpm resolution.
|
||||
value: ${{ steps.setup-pnpm.outputs.project-dir }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm (corepack retry)
|
||||
- name: Validate pnpm setup inputs
|
||||
id: setup-pnpm
|
||||
shell: bash
|
||||
env:
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
||||
PNPM_VERSION: ${{ inputs.pnpm-version }}
|
||||
PACKAGE_MANAGER_FILE: ${{ inputs.package-manager-file }}
|
||||
REQUESTED_NODE_VERSION: ${{ inputs.node-version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'"
|
||||
exit 2
|
||||
project_dir="$(dirname "$PACKAGE_MANAGER_FILE")"
|
||||
if [[ ! -f "$PACKAGE_MANAGER_FILE" ]]; then
|
||||
echo "::error::package manager file not found: $PACKAGE_MANAGER_FILE"
|
||||
exit 1
|
||||
fi
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare "pnpm@$PNPM_VERSION" --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
echo "project-dir=$project_dir" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
requested_node="${REQUESTED_NODE_VERSION:-${NODE_VERSION:-}}"
|
||||
source "$GITHUB_ACTION_PATH/ensure-node.sh"
|
||||
openclaw_ensure_node "$requested_node"
|
||||
|
||||
- name: Resolve pnpm store cache keys
|
||||
id: pnpm-cache-config
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_KEY_SUFFIX: ${{ inputs.cache-key-suffix }}
|
||||
LOCKFILE_HASH: ${{ hashFiles('pnpm-lock.yaml') }}
|
||||
USE_ACTIONS_CACHE: ${{ inputs.use-actions-cache }}
|
||||
USE_RESTORE_KEYS: ${{ inputs.use-restore-keys }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "enabled=$USE_ACTIONS_CACHE" >> "$GITHUB_OUTPUT"
|
||||
echo "primary-key=${RUNNER_OS}-pnpm-store-${CACHE_KEY_SUFFIX}-${LOCKFILE_HASH}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$USE_RESTORE_KEYS" = "true" ]; then
|
||||
echo "restore-keys=${RUNNER_OS}-pnpm-store-${CACHE_KEY_SUFFIX}-" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "restore-keys=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
id: pnpm-cache-restore
|
||||
if: inputs.use-actions-cache == 'true'
|
||||
uses: actions/cache/restore@v5
|
||||
- name: Setup pnpm from packageManager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ steps.pnpm-cache-config.outputs.primary-key }}
|
||||
restore-keys: ${{ steps.pnpm-cache-config.outputs.restore-keys }}
|
||||
package_json_file: ${{ inputs.package-manager-file }}
|
||||
run_install: false
|
||||
cache: ${{ inputs.use-actions-cache }}
|
||||
cache_dependency_path: ${{ inputs.lockfile-path }}
|
||||
|
||||
- name: Record pnpm version
|
||||
id: pnpm-version
|
||||
shell: bash
|
||||
env:
|
||||
PROJECT_DIR: ${{ steps.setup-pnpm.outputs.project-dir }}
|
||||
run: echo "pnpm-version=$(cd "$PROJECT_DIR" && pnpm -v)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
96
.github/actions/setup-pnpm-store-cache/ensure-node.sh
vendored
Normal file
96
.github/actions/setup-pnpm-store-cache/ensure-node.sh
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
openclaw_node_version_matches() {
|
||||
local actual="$1"
|
||||
local requested="$2"
|
||||
if [[ -z "$requested" ]]; then
|
||||
return 0
|
||||
fi
|
||||
case "$requested" in
|
||||
*x)
|
||||
[[ "${actual%%.*}" == "${requested%%.*}" ]]
|
||||
;;
|
||||
*.*.*)
|
||||
[[ "$actual" == "$requested" ]]
|
||||
;;
|
||||
*.*)
|
||||
[[ "$actual" == "$requested".* ]]
|
||||
;;
|
||||
*)
|
||||
[[ "${actual%%.*}" == "$requested" ]]
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
openclaw_active_node_version() {
|
||||
node -p 'process.versions.node' 2>/dev/null || true
|
||||
}
|
||||
|
||||
openclaw_prepend_node_bin() {
|
||||
local node_bin_dir="$1"
|
||||
export PATH="$node_bin_dir:$PATH"
|
||||
if [[ -n "${GITHUB_PATH:-}" ]]; then
|
||||
echo "$node_bin_dir" >> "$GITHUB_PATH"
|
||||
fi
|
||||
hash -r
|
||||
}
|
||||
|
||||
openclaw_find_toolcache_node() {
|
||||
local requested_node="$1"
|
||||
local roots=()
|
||||
local root
|
||||
for root in \
|
||||
"${RUNNER_TOOL_CACHE:-}" \
|
||||
"${AGENT_TOOLSDIRECTORY:-}" \
|
||||
"${ACTIONS_RUNNER_TOOL_CACHE:-}" \
|
||||
"/opt/hostedtoolcache" \
|
||||
"/home/runner/_work/_tool" \
|
||||
"/Users/runner/hostedtoolcache" \
|
||||
"/c/hostedtoolcache/windows"
|
||||
do
|
||||
if [[ -d "$root/node" ]]; then
|
||||
roots+=("$root/node")
|
||||
elif [[ "$(basename "$root")" == "node" && -d "$root" ]]; then
|
||||
roots+=("$root")
|
||||
fi
|
||||
done
|
||||
|
||||
local node_root candidate candidate_version
|
||||
for node_root in "${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
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done < <(find "$node_root" \( -name node -o -name node.exe \) -type f 2>/dev/null | sort -r)
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
openclaw_ensure_node() {
|
||||
local requested_node="${1:-}"
|
||||
requested_node="${requested_node#v}"
|
||||
if [[ -z "$requested_node" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local active_node_version node_bin
|
||||
active_node_version="$(openclaw_active_node_version)"
|
||||
if openclaw_node_version_matches "$active_node_version" "$requested_node"; then
|
||||
echo "Using active Node ${active_node_version} at $(command -v node)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
node_bin="$(openclaw_find_toolcache_node "$requested_node" || true)"
|
||||
if [[ -n "$node_bin" ]]; then
|
||||
echo "Using Node $("$node_bin" -p 'process.versions.node') from $node_bin"
|
||||
openclaw_prepend_node_bin "$(dirname "$node_bin")"
|
||||
fi
|
||||
|
||||
active_node_version="$(openclaw_active_node_version)"
|
||||
if ! openclaw_node_version_matches "$active_node_version" "$requested_node"; then
|
||||
echo "::error::Expected Node '${requested_node}', but active node is '${active_node_version:-missing}' at $(command -v node || true)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -18,6 +18,10 @@ Hard limits:
|
||||
- Do not force GIFs for internal-only, workflow-only, test-only, docs-only, or
|
||||
otherwise non-visual PRs. A no-visual-proof manifest is a successful workflow
|
||||
outcome when GIFs would be misleading, but it is not proof that the PR passed.
|
||||
- Do not skip Telegram-visible PRs just because the proof needs a specific
|
||||
message, mock response, media attachment, command, button, reaction, stop
|
||||
timing, approval prompt, or progress/final delivery sequence. First write a
|
||||
concrete proof plan and try the standard harness path.
|
||||
- Keep public-facing manifest summaries short and user-domain. Do not mention
|
||||
harness internals, mock-provider limits, secret/trust boundaries, local paths,
|
||||
transcript seeding, or workflow implementation details in the summary.
|
||||
@@ -42,7 +46,15 @@ Required workflow:
|
||||
2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and
|
||||
`gh pr diff "$MANTIS_PR_NUMBER"`.
|
||||
3. Decide whether the PR has a visibly reproducible Telegram Desktop
|
||||
before/after. If it does not, write
|
||||
before/after. Treat these as visible until proven otherwise: message text
|
||||
formatting/content, progress drafts, native drafts, final delivery, media or
|
||||
document delivery, inline buttons, approval prompts, stop/abort behavior,
|
||||
reactions/status indicators, guest/inline responses, TTS/voice/audio
|
||||
delivery, and routing changes whose result is visible in the chat. For those
|
||||
PRs, define the exact Telegram stimulus and expected main/PR visual delta
|
||||
before deciding to skip.
|
||||
|
||||
If the PR does not have a Telegram-visible before/after, write
|
||||
`${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with `comparison.pass: true`, no
|
||||
artifacts, and a summary that starts with
|
||||
`Mantis did not generate before/after GIFs because`. Include a short
|
||||
@@ -78,8 +90,9 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
|
||||
```
|
||||
|
||||
If the PR appears visual but proof is blocked by Telegram Desktop session
|
||||
state, authorization, credentials, Crabbox, or another capture-infrastructure
|
||||
issue, do not describe it as a no-visual PR. Write a manifest with
|
||||
state, authorization, credentials, Crabbox, missing Telegram client support,
|
||||
unavailable media/provider setup, or another capture-infrastructure issue,
|
||||
do not describe it as a no-visual PR. Write a manifest with
|
||||
`comparison.pass: false`, skipped lanes, no artifacts, and a summary that
|
||||
starts with `Mantis could not capture Telegram Desktop proof because`. The
|
||||
publisher will keep that out of PR comments so the failure stays in the
|
||||
@@ -106,8 +119,10 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
|
||||
`$OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT`, the workflow-provided `crabbox`
|
||||
binary, and the workflow-provided local `ffmpeg`/`ffprobe`; do not generate,
|
||||
install, or patch replacement proof tooling during the run. Use the same
|
||||
proof idea for baseline and candidate. You may iterate and rerun if the
|
||||
visual result is not convincing.
|
||||
proof idea for baseline and candidate. Let `start` return or fail on its
|
||||
own; do not kill it while Crabbox is still waiting for bootstrap. Use a long
|
||||
command timeout for `start`, `send`, `view`, and `finish`. You may iterate
|
||||
and rerun if the visual result is not convincing.
|
||||
7. Open Telegram Desktop directly to the newest relevant message with the
|
||||
runner `view` command before finishing each recording. Keep the chat scrolled
|
||||
to the bottom so new proof messages appear in-frame.
|
||||
|
||||
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -286,6 +286,11 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/oc-path/**"
|
||||
"extensions: policy":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/policy/**"
|
||||
- "docs/cli/policy.md"
|
||||
"extensions: open-prose":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -5,10 +5,16 @@ Describe the problem and fix in 2–5 bullets:
|
||||
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
|
||||
|
||||
- Problem:
|
||||
- Why it matters:
|
||||
- Solution:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
## Motivation
|
||||
|
||||
Explain why this change should exist now. Link it to the user pain, failure mode, maintainer need, or product goal. If this is purely mechanical, write `N/A`.
|
||||
|
||||
-
|
||||
|
||||
## Change Type (select all)
|
||||
|
||||
- [ ] Bug fix
|
||||
|
||||
200
.github/workflows/ci.yml
vendored
200
.github/workflows/ci.yml
vendored
@@ -60,14 +60,11 @@ jobs:
|
||||
plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }}
|
||||
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
|
||||
run_checks: ${{ steps.manifest.outputs.run_checks }}
|
||||
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
|
||||
run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }}
|
||||
checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }}
|
||||
run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }}
|
||||
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
|
||||
run_check: ${{ steps.manifest.outputs.run_check }}
|
||||
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
|
||||
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
|
||||
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
|
||||
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
|
||||
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
|
||||
@@ -134,6 +131,7 @@ jobs:
|
||||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
|
||||
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
|
||||
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
@@ -175,6 +173,7 @@ jobs:
|
||||
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
|
||||
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
|
||||
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
|
||||
const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
|
||||
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
|
||||
const runNodeFastOnly =
|
||||
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
|
||||
@@ -199,7 +198,7 @@ jobs:
|
||||
const checksFastCoreTasks = [];
|
||||
if (runNodeFull) {
|
||||
checksFastCoreTasks.push(
|
||||
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
|
||||
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
|
||||
);
|
||||
} else {
|
||||
if (runNodeFastCiRouting) {
|
||||
@@ -248,21 +247,12 @@ jobs:
|
||||
runNodeFull ? createChannelContractTestShards() : [],
|
||||
),
|
||||
run_checks: runNodeFull,
|
||||
checks_matrix: createMatrix(
|
||||
runNodeFull
|
||||
? [
|
||||
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
|
||||
]
|
||||
: [],
|
||||
),
|
||||
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
|
||||
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
|
||||
run_checks_node_core_dist: nodeTestDistShards.length > 0,
|
||||
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
|
||||
run_check: runNodeFull,
|
||||
run_check_additional: runNodeFull,
|
||||
run_build_smoke: runNodeFull,
|
||||
run_check_docs: docsChanged,
|
||||
run_check_docs: docsChanged && eventName !== "push",
|
||||
run_control_ui_i18n: runControlUiI18n,
|
||||
run_skills_python_job: runSkillsPython,
|
||||
run_checks_windows: runWindows,
|
||||
@@ -297,9 +287,9 @@ jobs:
|
||||
}
|
||||
EOF
|
||||
|
||||
# Run the fast security/SCM checks in parallel with scope detection so the
|
||||
# Run dependency-free security checks in parallel with scope detection so the
|
||||
# main Node jobs do not have to wait for Python/pre-commit setup.
|
||||
security-scm-fast:
|
||||
security-fast:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
@@ -392,22 +382,6 @@ jobs:
|
||||
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
|
||||
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
|
||||
|
||||
security-dependency-audit:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.target_ref || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -417,35 +391,6 @@ jobs:
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
|
||||
security-fast:
|
||||
permissions: {}
|
||||
needs: [security-scm-fast, security-dependency-audit]
|
||||
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify fast security jobs
|
||||
env:
|
||||
DEPENDENCY_AUDIT_RESULT: ${{ needs.security-dependency-audit.result }}
|
||||
SCM_RESULT: ${{ needs.security-scm-fast.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
|
||||
for result in \
|
||||
"security-scm-fast=${SCM_RESULT}" \
|
||||
"security-dependency-audit=${DEPENDENCY_AUDIT_RESULT}"
|
||||
do
|
||||
job="${result%%=*}"
|
||||
status="${result#*=}"
|
||||
if [ "$status" != "success" ]; then
|
||||
echo "::error::${job} ended with ${status}"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$failed"
|
||||
|
||||
# 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.
|
||||
@@ -733,14 +678,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TASK" in
|
||||
bundled)
|
||||
bundled-protocol)
|
||||
pnpm test:bundled
|
||||
;;
|
||||
contracts-channels)
|
||||
pnpm test:contracts:channels
|
||||
;;
|
||||
contracts-plugins)
|
||||
pnpm test:contracts:plugins
|
||||
pnpm protocol:check
|
||||
;;
|
||||
contracts-plugins-ci-routing)
|
||||
pnpm test:contracts:plugins
|
||||
@@ -923,71 +863,6 @@ jobs:
|
||||
EOF
|
||||
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
|
||||
|
||||
checks-fast-protocol:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "checks-fast-protocol"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
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 -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
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"
|
||||
|
||||
- name: Run protocol check
|
||||
run: pnpm protocol:check
|
||||
|
||||
checks-node-compat:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1048,8 +923,7 @@ jobs:
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
cache-key-suffix: "node22-pnpm11"
|
||||
node-version: "22.19.0"
|
||||
install-bun: "false"
|
||||
|
||||
- name: Configure Node test resources
|
||||
@@ -1129,7 +1003,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: "${{ matrix.node_version || '24.x' }}"
|
||||
cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24-pnpm11' }}"
|
||||
install-bun: "false"
|
||||
|
||||
- name: Configure Node test resources
|
||||
@@ -1188,8 +1061,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-preflight-guards
|
||||
task: preflight-guards
|
||||
- check_name: check-guards
|
||||
task: guards
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-prod-types
|
||||
task: prod-types
|
||||
@@ -1200,15 +1073,9 @@ jobs:
|
||||
- check_name: check-dependencies
|
||||
task: dependencies
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-policy-guards
|
||||
task: policy-guards
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-test-types
|
||||
task: test-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-strict-smoke
|
||||
task: strict-smoke
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
@@ -1271,12 +1138,19 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TASK" in
|
||||
preflight-guards)
|
||||
guards)
|
||||
pnpm check:no-conflict-markers
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
pnpm deps:shrinkwrap:check
|
||||
pnpm deps:patches:check
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
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
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
@@ -1293,19 +1167,9 @@ jobs:
|
||||
pnpm deadcode:ci
|
||||
fi
|
||||
;;
|
||||
policy-guards)
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
pnpm lint:auth:pairing-account-scope
|
||||
pnpm check:import-cycles
|
||||
;;
|
||||
test-types)
|
||||
pnpm check:test-types
|
||||
;;
|
||||
strict-smoke)
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported check task: $TASK" >&2
|
||||
exit 1
|
||||
@@ -1335,15 +1199,9 @@ jobs:
|
||||
- check_name: check-additional-boundaries-a
|
||||
group: boundaries
|
||||
boundary_shard: 1/4
|
||||
- check_name: check-additional-boundaries-b
|
||||
- check_name: check-additional-boundaries-bcd
|
||||
group: boundaries
|
||||
boundary_shard: 2/4
|
||||
- check_name: check-additional-boundaries-c
|
||||
group: boundaries
|
||||
boundary_shard: 3/4
|
||||
- check_name: check-additional-boundaries-d
|
||||
group: boundaries
|
||||
boundary_shard: 4/4
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
- check_name: check-additional-extension-bundled
|
||||
@@ -1655,14 +1513,10 @@ jobs:
|
||||
node-version: 24.x
|
||||
check-latest: false
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
id: pnpm-cache
|
||||
- name: Setup pnpm
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "11.0.8"
|
||||
cache-key-suffix: "node24-pnpm11"
|
||||
use-restore-keys: "false"
|
||||
use-actions-cache: "true"
|
||||
node-version: 24.x
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
@@ -1690,14 +1544,6 @@ jobs:
|
||||
# caches can skip repeated rebuild/download work on later shards/runs.
|
||||
pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true
|
||||
|
||||
- name: Save pnpm store cache
|
||||
if: steps.pnpm-cache.outputs.cache-enabled == 'true' && steps.pnpm-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.store-path }}
|
||||
key: ${{ steps.pnpm-cache.outputs.primary-key }}
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
env:
|
||||
TASK: ${{ matrix.task }}
|
||||
|
||||
56
.github/workflows/crabbox-hydrate.yml
vendored
56
.github/workflows/crabbox-hydrate.yml
vendored
@@ -31,6 +31,12 @@ permissions:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_CHILD_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
|
||||
PNPM_CONFIG_NETWORK_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
|
||||
|
||||
jobs:
|
||||
hydrate:
|
||||
@@ -46,33 +52,42 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-actions-cache: "false"
|
||||
|
||||
- name: Prepare Crabbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
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"
|
||||
fi
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
pnpm_bin="$(command -v pnpm)"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Ensure Docker is available
|
||||
- name: Ensure Docker is running
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker not found; installing fallback engine"
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl start docker
|
||||
sudo systemctl start docker || true
|
||||
elif command -v service >/dev/null 2>&1; then
|
||||
sudo service docker start || true
|
||||
fi
|
||||
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
@@ -82,6 +97,35 @@ jobs:
|
||||
sudo chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
|
||||
if ! docker buildx version >/dev/null 2>&1; then
|
||||
arch="$(uname -m)"
|
||||
case "$arch" in
|
||||
aarch64|arm64) buildx_arch=arm64 ;;
|
||||
x86_64|amd64) buildx_arch=amd64 ;;
|
||||
*) echo "unsupported buildx arch: $arch" >&2; exit 2 ;;
|
||||
esac
|
||||
buildx_version="${DOCKER_BUILDX_VERSION:-v0.15.1}"
|
||||
mkdir -p "$HOME/.docker/cli-plugins"
|
||||
curl -fsSL \
|
||||
"https://github.com/docker/buildx/releases/download/${buildx_version}/buildx-${buildx_version}.linux-${buildx_arch}" \
|
||||
-o "$HOME/.docker/cli-plugins/docker-buildx"
|
||||
chmod 0755 "$HOME/.docker/cli-plugins/docker-buildx"
|
||||
fi
|
||||
|
||||
docker version
|
||||
docker buildx version
|
||||
docker compose version || true
|
||||
|
||||
- name: Ensure SSH is available
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl start ssh || sudo systemctl start sshd || true
|
||||
elif command -v service >/dev/null 2>&1; then
|
||||
sudo service ssh start || sudo service sshd start || true
|
||||
fi
|
||||
|
||||
- name: Hydrate provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
@@ -135,7 +179,7 @@ jobs:
|
||||
fi
|
||||
}
|
||||
{
|
||||
for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE; do
|
||||
for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE PNPM_CONFIG_CHILD_CONCURRENCY PNPM_CONFIG_MODULES_DIR PNPM_CONFIG_NETWORK_CONCURRENCY PNPM_CONFIG_STORE_DIR PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN PNPM_CONFIG_VIRTUAL_STORE_DIR; do
|
||||
write_export "$key"
|
||||
done
|
||||
} > "${env_file}.tmp"
|
||||
|
||||
@@ -34,11 +34,15 @@ jobs:
|
||||
|
||||
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) =>
|
||||
@@ -143,7 +147,8 @@ jobs:
|
||||
"",
|
||||
"Maintainer follow-up:",
|
||||
"- Review whether the dependency changes are intentional.",
|
||||
"- Inspect resolved package deltas when lockfile or workspace dependency policy changes are present.",
|
||||
"- 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");
|
||||
|
||||
|
||||
4
.github/workflows/docker-release.yml
vendored
4
.github/workflows/docker-release.yml
vendored
@@ -155,7 +155,7 @@ jobs:
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
@@ -253,7 +253,7 @@ jobs:
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
|
||||
2
.github/workflows/docs-sync-publish.yml
vendored
2
.github/workflows/docs-sync-publish.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Clone publish repo
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
|
||||
10
.github/workflows/docs.yml
vendored
10
.github/workflows/docs.yml
vendored
@@ -36,5 +36,15 @@ jobs:
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Checkout ClawHub docs source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: openclaw/clawhub
|
||||
path: clawhub-source
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check docs
|
||||
env:
|
||||
OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: ${{ github.workspace }}/clawhub-source
|
||||
run: pnpm check:docs
|
||||
|
||||
123
.github/workflows/full-release-validation.yml
vendored
123
.github/workflows/full-release-validation.yml
vendored
@@ -88,6 +88,11 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
npm_telegram_provider_mode:
|
||||
description: Provider mode for the package Telegram E2E lane
|
||||
required: false
|
||||
@@ -108,13 +113,12 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: full-release-validation-${{ inputs.ref }}-${{ inputs.rerun_group }}
|
||||
cancel-in-progress: ${{ inputs.ref == 'main' && inputs.rerun_group == 'all' }}
|
||||
cancel-in-progress: ${{ (inputs.ref == 'main' && inputs.rerun_group == 'all') || startsWith(inputs.ref, 'tideclaw/alpha/') }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
|
||||
jobs:
|
||||
resolve_target:
|
||||
@@ -151,6 +155,7 @@ jobs:
|
||||
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
|
||||
EVIDENCE_PACKAGE_SPEC: ${{ inputs.evidence_package_spec }}
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
|
||||
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
RELEASE_PROFILE: ${{ inputs.release_profile }}
|
||||
RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }}
|
||||
RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
@@ -208,14 +213,43 @@ jobs:
|
||||
else
|
||||
echo "- Package Acceptance package spec: SHA-built release artifact"
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
docker_runtime_assets_preflight:
|
||||
name: Verify Docker runtime-assets prune path
|
||||
needs: [resolve_target]
|
||||
if: inputs.rerun_group == 'all'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout target SHA
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify Docker runtime-assets prune path
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
timeout --foreground --kill-after=30s 35m docker build \
|
||||
--target runtime-assets \
|
||||
--build-arg OPENCLAW_EXTENSIONS="matrix" \
|
||||
.
|
||||
|
||||
normal_ci:
|
||||
name: Run normal full CI
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","ci"]'), inputs.rerun_group)
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -312,10 +346,10 @@ jobs:
|
||||
|
||||
plugin_prerelease:
|
||||
name: Run plugin prerelease validation
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group)
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || inputs.release_profile == 'stable' && 240 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -412,10 +446,10 @@ jobs:
|
||||
|
||||
release_checks:
|
||||
name: Run release/live/Docker/QA validation
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group)
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -437,6 +471,7 @@ jobs:
|
||||
CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
|
||||
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -532,6 +567,9 @@ jobs:
|
||||
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
|
||||
echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`"
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
child_rerun_group="$RERUN_GROUP"
|
||||
@@ -560,13 +598,16 @@ jobs:
|
||||
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
|
||||
args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC")
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC")
|
||||
fi
|
||||
|
||||
dispatch_and_wait openclaw-release-checks.yml "${args[@]}"
|
||||
|
||||
prepare_release_package:
|
||||
name: Prepare release package artifact
|
||||
needs: [resolve_target]
|
||||
if: ${{ inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' }}
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' && needs.docker_runtime_assets_preflight.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -593,7 +634,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "false"
|
||||
|
||||
@@ -735,7 +775,7 @@ jobs:
|
||||
|
||||
summary:
|
||||
name: Verify full validation
|
||||
needs: [resolve_target, normal_ci, plugin_prerelease, release_checks, npm_telegram]
|
||||
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
@@ -751,6 +791,8 @@ jobs:
|
||||
PLUGIN_PRERELEASE_RESULT: ${{ needs.plugin_prerelease.result }}
|
||||
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
|
||||
NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }}
|
||||
DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }}
|
||||
RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
|
||||
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
@@ -798,6 +840,7 @@ jobs:
|
||||
echo
|
||||
echo "| Child | Result | Minutes | Head SHA | Run |"
|
||||
echo "| --- | --- | ---: | --- | --- |"
|
||||
echo "| \`docker_runtime_assets_preflight\` | \`${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}\` | | current workflow | |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
append_child_row() {
|
||||
@@ -933,23 +976,46 @@ jobs:
|
||||
}
|
||||
|
||||
failed=0
|
||||
normal_ci_required=0
|
||||
plugin_prerelease_required=0
|
||||
release_checks_required=0
|
||||
if [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then
|
||||
echo "::error::Docker runtime-assets preflight ended with ${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}."
|
||||
failed=1
|
||||
elif [[ "$RERUN_GROUP" == "all" ]]; then
|
||||
normal_ci_required=1
|
||||
plugin_prerelease_required=1
|
||||
release_checks_required=1
|
||||
else
|
||||
case "$RERUN_GROUP" in
|
||||
ci)
|
||||
normal_ci_required=1
|
||||
;;
|
||||
plugin-prerelease)
|
||||
plugin_prerelease_required=1
|
||||
;;
|
||||
release-checks|install-smoke|cross-os|live-e2e|package|qa|qa-parity|qa-live)
|
||||
release_checks_required=1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
append_child_overview
|
||||
|
||||
if [[ "$NORMAL_CI_RESULT" == "skipped" && -z "${NORMAL_CI_RUN_ID// }" ]]; then
|
||||
check_child "normal_ci" "" 0 || failed=1
|
||||
check_child "normal_ci" "" "$normal_ci_required" || failed=1
|
||||
else
|
||||
check_child "normal_ci" "$NORMAL_CI_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
|
||||
if [[ "$PLUGIN_PRERELEASE_RESULT" == "skipped" && -z "${PLUGIN_PRERELEASE_RUN_ID// }" ]]; then
|
||||
check_child "plugin_prerelease" "" 0 || failed=1
|
||||
check_child "plugin_prerelease" "" "$plugin_prerelease_required" || failed=1
|
||||
else
|
||||
check_child "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && -z "${RELEASE_CHECKS_RUN_ID// }" ]]; then
|
||||
check_child "release_checks" "" 0 || failed=1
|
||||
check_child "release_checks" "" "$release_checks_required" || failed=1
|
||||
else
|
||||
check_child "release_checks" "$RELEASE_CHECKS_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
@@ -994,9 +1060,17 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
evidence_package_spec="$PACKAGE_SPEC"
|
||||
if [[ -z "${evidence_package_spec// }" ]]; then
|
||||
tag_ref="${TARGET_REF#refs/tags/}"
|
||||
if [[ "$tag_ref" =~ ^v([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?)$ ]]; then
|
||||
evidence_package_spec="openclaw@${BASH_REMATCH[1]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
release_id="${TARGET_REF#refs/tags/}"
|
||||
release_id="${release_id#v}"
|
||||
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
|
||||
if [[ "$evidence_package_spec" =~ ^openclaw@(.+)$ ]]; then
|
||||
release_id="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
|
||||
@@ -1010,7 +1084,7 @@ jobs:
|
||||
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
|
||||
--arg release_id "$release_id" \
|
||||
--arg release_ref "$TARGET_REF" \
|
||||
--arg package_spec "$PACKAGE_SPEC" \
|
||||
--arg package_spec "$evidence_package_spec" \
|
||||
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
|
||||
'{
|
||||
event_type: "openclaw_full_release_validation_completed",
|
||||
@@ -1032,6 +1106,15 @@ jobs:
|
||||
https://api.github.com/repos/openclaw/releases-private/dispatches \
|
||||
-d "$payload"; then
|
||||
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
|
||||
{
|
||||
echo "### Private release evidence dispatch failed"
|
||||
echo
|
||||
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases-private\`:"
|
||||
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 "\`\`\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Write release validation manifest
|
||||
|
||||
9
.github/workflows/labeler.yml
vendored
9
.github/workflows/labeler.yml
vendored
@@ -89,10 +89,10 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "bun.lockb"]);
|
||||
const totalChangedLines = files.reduce((total, file) => {
|
||||
const path = file.filename ?? "";
|
||||
if (path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||
if (path.startsWith("docs/") || excludedLockfiles.has(path) || path.endsWith("/package-lock.json") || path.endsWith("/npm-shrinkwrap.json")) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||
@@ -603,10 +603,10 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "bun.lockb"]);
|
||||
const totalChangedLines = files.reduce((total, file) => {
|
||||
const path = file.filename ?? "";
|
||||
if (path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||
if (path.startsWith("docs/") || excludedLockfiles.has(path) || path.endsWith("/package-lock.json") || path.endsWith("/npm-shrinkwrap.json")) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||
@@ -760,6 +760,7 @@ jobs:
|
||||
core.info(`Processed ${processed} pull requests.`);
|
||||
|
||||
label-issues:
|
||||
if: github.event_name == 'issues'
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
2
.github/workflows/macos-release.yml
vendored
2
.github/workflows/macos-release.yml
vendored
@@ -25,7 +25,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
|
||||
jobs:
|
||||
validate_macos_release_request:
|
||||
@@ -53,7 +52,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Ensure matching GitHub release exists
|
||||
|
||||
4
.github/workflows/mantis-discord-smoke.yml
vendored
4
.github/workflows/mantis-discord-smoke.yml
vendored
@@ -25,7 +25,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
|
||||
@@ -142,7 +141,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build private QA runtime
|
||||
@@ -168,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Upload Mantis artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/mantis/
|
||||
|
||||
@@ -32,7 +32,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
|
||||
@@ -255,7 +254,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build Mantis harness
|
||||
@@ -528,7 +526,7 @@ jobs:
|
||||
- name: Upload Mantis status reaction artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
|
||||
@@ -32,7 +32,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
|
||||
@@ -245,7 +244,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build Mantis harness
|
||||
@@ -536,7 +534,7 @@ jobs:
|
||||
- name: Upload Mantis thread attachment artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
|
||||
@@ -55,7 +55,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
CRABBOX_REF: main
|
||||
@@ -162,7 +161,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build Mantis harness
|
||||
@@ -359,7 +357,7 @@ jobs:
|
||||
- name: Upload Mantis Slack desktop artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
|
||||
@@ -45,7 +45,6 @@ permissions:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
CRABBOX_REF: main
|
||||
@@ -308,16 +307,36 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
|
||||
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
run_has_active_jobs() {
|
||||
local run_id="$1"
|
||||
local run_state="$2"
|
||||
if [[ "$run_state" != "in_progress" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local active_jobs
|
||||
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
|
||||
[[ "$active_jobs" != "0" ]]
|
||||
}
|
||||
while true; do
|
||||
blockers="$(
|
||||
candidates="$(
|
||||
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
--arg stale_before "$stale_before" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done
|
||||
done | sort -u
|
||||
)"
|
||||
blockers=""
|
||||
while IFS=$'\t' read -r created run_id run_state url; do
|
||||
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
|
||||
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
|
||||
fi
|
||||
done <<<"$candidates"
|
||||
if [[ -z "$blockers" ]]; then
|
||||
break
|
||||
fi
|
||||
@@ -336,7 +355,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Setup Go for Crabbox CLI
|
||||
@@ -406,7 +424,7 @@ jobs:
|
||||
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
|
||||
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
|
||||
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_CREDENTIAL_OWNER_ID OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
|
||||
} | sudo tee /etc/sudoers.d/mantis-codex-env >/dev/null
|
||||
sudo chmod 0440 /etc/sudoers.d/mantis-codex-env
|
||||
@@ -442,6 +460,7 @@ jobs:
|
||||
MANTIS_PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CREDENTIAL_OWNER_ID: mantis-telegram-desktop-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
|
||||
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
|
||||
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN: /usr/local/bin/crabbox
|
||||
@@ -460,6 +479,47 @@ jobs:
|
||||
codex-user: codex
|
||||
allow-bot-users: clawsweeper[bot]
|
||||
|
||||
- name: Release leaked Telegram proof leases
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! -d .artifacts/qa-e2e ]]; then
|
||||
exit 0
|
||||
fi
|
||||
status=0
|
||||
mapfile -d '' session_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/session.json' -type f -print0)
|
||||
for session_file in "${session_files[@]}"; do
|
||||
lease_file="${session_file%/session.json}/.session/lease.json"
|
||||
if [[ ! -f "$lease_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! sudo -u codex env \
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
|
||||
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
|
||||
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN=/usr/local/bin/crabbox \
|
||||
OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER="$CRABBOX_PROVIDER" \
|
||||
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-crabbox-proof.ts" \
|
||||
finish --session "$session_file" --preview-crop telegram-window; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
mapfile -d '' lease_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/.session/lease.json' -type f -print0)
|
||||
for lease_file in "${lease_files[@]}"; do
|
||||
if ! sudo -u codex env \
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
|
||||
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
|
||||
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-credential.ts" \
|
||||
release --lease-file "$lease_file"; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Inspect Mantis evidence manifest
|
||||
id: inspect
|
||||
if: ${{ always() }}
|
||||
@@ -479,7 +539,7 @@ jobs:
|
||||
- name: Upload Mantis Telegram desktop artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.inspect.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.inspect.outputs.output_dir }}
|
||||
@@ -556,7 +616,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download existing proof artifact
|
||||
|
||||
36
.github/workflows/mantis-telegram-live.yml
vendored
36
.github/workflows/mantis-telegram-live.yml
vendored
@@ -41,7 +41,6 @@ permissions:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
CRABBOX_REF: main
|
||||
@@ -272,16 +271,36 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
|
||||
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
run_has_active_jobs() {
|
||||
local run_id="$1"
|
||||
local run_state="$2"
|
||||
if [[ "$run_state" != "in_progress" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local active_jobs
|
||||
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
|
||||
[[ "$active_jobs" != "0" ]]
|
||||
}
|
||||
while true; do
|
||||
blockers="$(
|
||||
candidates="$(
|
||||
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
--arg stale_before "$stale_before" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done
|
||||
done | sort -u
|
||||
)"
|
||||
blockers=""
|
||||
while IFS=$'\t' read -r created run_id run_state url; do
|
||||
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
|
||||
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
|
||||
fi
|
||||
done <<<"$candidates"
|
||||
if [[ -z "$blockers" ]]; then
|
||||
break
|
||||
fi
|
||||
@@ -300,7 +319,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build Mantis harness
|
||||
@@ -479,7 +497,7 @@ jobs:
|
||||
- name: Upload Mantis Telegram artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
|
||||
4
.github/workflows/npm-telegram-beta-e2e.yml
vendored
4
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -104,7 +104,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
|
||||
jobs:
|
||||
run_package_telegram_e2e:
|
||||
@@ -147,7 +146,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate inputs and secrets
|
||||
@@ -270,7 +268,7 @@ jobs:
|
||||
|
||||
- name: Upload npm Telegram E2E artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/
|
||||
|
||||
@@ -193,7 +193,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_REPOSITORY: openclaw/openclaw
|
||||
TSX_VERSION: "4.21.0"
|
||||
OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.5' }}
|
||||
@@ -352,18 +351,18 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: ${{ inputs.candidate_artifact_name == '' && 'source/pnpm-lock.yaml' || 'workflow/pnpm-lock.yaml' }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: ./workflow/.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-file: ${{ inputs.candidate_artifact_name == '' && 'source/package.json' || 'workflow/package.json' }}
|
||||
lockfile-path: ${{ inputs.candidate_artifact_name == '' && 'source/pnpm-lock.yaml' || 'workflow/pnpm-lock.yaml' }}
|
||||
use-actions-cache: ${{ inputs.candidate_artifact_name == '' && 'true' || 'false' }}
|
||||
|
||||
- name: Ensure pnpm store cache directory exists
|
||||
run: mkdir -p "$(pnpm store path --silent)"
|
||||
@@ -540,17 +539,19 @@ jobs:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: ./workflow/.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-file: workflow/package.json
|
||||
lockfile-path: workflow/pnpm-lock.yaml
|
||||
use-actions-cache: "false"
|
||||
|
||||
- name: Download candidate artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
|
||||
@@ -68,6 +68,11 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
@@ -173,6 +178,11 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
@@ -298,7 +308,6 @@ permissions:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
|
||||
jobs:
|
||||
validate_selected_ref:
|
||||
@@ -321,9 +330,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
trusted_reason=""
|
||||
|
||||
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
git fetch --tags origin '+refs/tags/*:refs/tags/*'
|
||||
|
||||
# Resolve here instead of in actions/checkout so short SHAs work too.
|
||||
if ! selected_sha="$(git rev-parse --verify "${INPUT_REF}^{commit}")"; then
|
||||
echo "Ref '${INPUT_REF}' could not be resolved to a commit." >&2
|
||||
@@ -484,7 +490,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate live cache credentials
|
||||
@@ -532,7 +537,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build dist for repo E2E
|
||||
@@ -540,6 +544,9 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm --dir ui exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run repo E2E suite
|
||||
run: pnpm test:e2e
|
||||
|
||||
@@ -574,7 +581,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build dist for special E2E
|
||||
@@ -634,7 +640,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- chunk_id: package-update-openai
|
||||
label: package/update OpenAI install
|
||||
timeout_minutes: 20
|
||||
timeout_minutes: 45
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-anthropic
|
||||
label: package/update Anthropic install
|
||||
@@ -737,6 +743,7 @@ jobs:
|
||||
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
|
||||
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
OPENCLAW_DOCKER_ALL_RELEASE_PROFILE: ${{ inputs.release_test_profile }}
|
||||
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }}
|
||||
@@ -773,7 +780,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Hydrate live auth/profile inputs
|
||||
@@ -977,6 +983,7 @@ jobs:
|
||||
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
|
||||
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
|
||||
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ matrix.group.published_upgrade_survivor_baselines || inputs.published_upgrade_survivor_baselines }}
|
||||
@@ -1009,7 +1016,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Hydrate live auth/profile inputs
|
||||
@@ -1166,7 +1172,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate Open WebUI credentials
|
||||
@@ -1326,7 +1331,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download current-run OpenClaw Docker E2E package
|
||||
@@ -1670,7 +1674,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Hydrate live auth/profile inputs
|
||||
@@ -1789,7 +1792,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Normalize provider allowlist
|
||||
@@ -2159,7 +2161,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Hydrate live auth/profile inputs
|
||||
@@ -2377,7 +2378,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Hydrate live auth/profile inputs
|
||||
@@ -2596,7 +2596,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Hydrate live auth/profile inputs
|
||||
|
||||
65
.github/workflows/openclaw-npm-release.yml
vendored
65
.github/workflows/openclaw-npm-release.yml
vendored
@@ -20,6 +20,10 @@ on:
|
||||
description: Successful Full Release Validation run id for this tag/SHA, required for real publish
|
||||
required: false
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
npm_dist_tag:
|
||||
description: npm dist-tag to publish to
|
||||
required: true
|
||||
@@ -32,12 +36,11 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.tag, inputs.npm_dist_tag) || github.ref }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && inputs.npm_dist_tag == 'alpha' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
|
||||
jobs:
|
||||
# PLEASE DON'T ADD LONG-RUNNING OR FLAKY CHECKS TO THE npm RELEASE PATH.
|
||||
@@ -114,7 +117,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
@@ -367,6 +369,7 @@ jobs:
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require trusted workflow ref for publish
|
||||
@@ -389,6 +392,7 @@ jobs:
|
||||
env:
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
@@ -399,6 +403,32 @@ jobs:
|
||||
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" && "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Workflow-dispatched real publish requires release_publish_run_id from the approved OpenClaw Release Publish workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "OpenClaw npm publish dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct OpenClaw npm publish; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
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
|
||||
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}`);'
|
||||
|
||||
publish_openclaw_npm:
|
||||
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.
|
||||
@@ -463,7 +493,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
@@ -511,10 +540,25 @@ jobs:
|
||||
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
|
||||
rm -rf preflight-tarball
|
||||
mkdir -p preflight-tarball
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${preferred_name}" \
|
||||
--dir preflight-tarball; then
|
||||
|
||||
download_named_artifact() {
|
||||
local artifact_name="$1"
|
||||
for attempt in 1 2 3; do
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${artifact_name}" \
|
||||
--dir preflight-tarball; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$attempt" != "3" ]]; then
|
||||
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if download_named_artifact "${preferred_name}"; then
|
||||
echo "Downloaded ${preferred_name}."
|
||||
return 0
|
||||
fi
|
||||
@@ -530,10 +574,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
fallback_name="${matches[0]}"
|
||||
gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${fallback_name}" \
|
||||
--dir preflight-tarball
|
||||
download_named_artifact "${fallback_name}"
|
||||
echo "Downloaded fallback preflight artifact ${fallback_name}."
|
||||
}
|
||||
|
||||
|
||||
2
.github/workflows/openclaw-performance.yml
vendored
2
.github/workflows/openclaw-performance.yml
vendored
@@ -468,7 +468,7 @@ jobs:
|
||||
|
||||
- name: Upload Kova artifacts
|
||||
if: ${{ always() && steps.lane.outputs.run == 'true' }}
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-performance-${{ matrix.lane }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
|
||||
83
.github/workflows/openclaw-release-checks.yml
vendored
83
.github/workflows/openclaw-release-checks.yml
vendored
@@ -78,15 +78,19 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL || 'openai/gpt-5.5' }}
|
||||
|
||||
jobs:
|
||||
@@ -112,6 +116,7 @@ jobs:
|
||||
qa_live_slack_enabled: ${{ steps.inputs.outputs.qa_live_slack_enabled }}
|
||||
release_package_spec: ${{ steps.inputs.outputs.release_package_spec }}
|
||||
package_acceptance_package_spec: ${{ steps.inputs.outputs.package_acceptance_package_spec }}
|
||||
codex_plugin_spec: ${{ steps.inputs.outputs.codex_plugin_spec }}
|
||||
steps:
|
||||
- name: Require trusted workflow ref for release checks
|
||||
env:
|
||||
@@ -262,6 +267,7 @@ jobs:
|
||||
RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED || 'false' }}
|
||||
RELEASE_PACKAGE_SPEC_INPUT: ${{ inputs.release_package_spec }}
|
||||
RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }}
|
||||
RELEASE_CODEX_PLUGIN_SPEC_INPUT: ${{ inputs.codex_plugin_spec }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
qa_live_matrix_enabled=true
|
||||
@@ -307,6 +313,10 @@ jobs:
|
||||
if [[ "$release_profile" == "full" ]]; then
|
||||
run_release_soak=true
|
||||
fi
|
||||
codex_plugin_spec="$RELEASE_CODEX_PLUGIN_SPEC_INPUT"
|
||||
if [[ -z "${codex_plugin_spec// }" && "$RELEASE_PACKAGE_SPEC_INPUT" =~ ^openclaw@(.+)$ ]]; then
|
||||
codex_plugin_spec="npm:@openclaw/codex@${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
filter="$(printf '%s' "$RELEASE_LIVE_SUITE_FILTER_INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ -n "${filter// }" ]]; then
|
||||
@@ -387,6 +397,7 @@ jobs:
|
||||
printf 'qa_live_slack_enabled=%s\n' "$qa_live_slack_enabled"
|
||||
printf 'release_package_spec=%s\n' "$RELEASE_PACKAGE_SPEC_INPUT"
|
||||
printf 'package_acceptance_package_spec=%s\n' "$RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT"
|
||||
printf 'codex_plugin_spec=%s\n' "$codex_plugin_spec"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Summarize validated ref
|
||||
@@ -403,6 +414,7 @@ jobs:
|
||||
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
|
||||
CODEX_PLUGIN_SPEC: ${{ steps.inputs.outputs.codex_plugin_spec }}
|
||||
run: |
|
||||
{
|
||||
echo "## Release checks"
|
||||
@@ -432,6 +444,11 @@ jobs:
|
||||
else
|
||||
echo "- Package Acceptance package spec: prepared release artifact"
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
|
||||
else
|
||||
echo "- Codex plugin spec: packed from selected ref"
|
||||
fi
|
||||
if [[ "$RUN_RELEASE_SOAK" == "true" ]]; then
|
||||
echo "- This run will execute blocking release validation plus exhaustive live/Docker soak coverage."
|
||||
else
|
||||
@@ -469,7 +486,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "false"
|
||||
|
||||
@@ -640,6 +656,7 @@ jobs:
|
||||
include_live_suites: false
|
||||
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
|
||||
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }}
|
||||
secrets: *live_e2e_release_secrets
|
||||
|
||||
package_acceptance_release_checks:
|
||||
@@ -754,7 +771,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build private QA runtime
|
||||
@@ -794,7 +810,7 @@ jobs:
|
||||
|
||||
- name: Upload parity lane artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -826,11 +842,10 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download parity lane artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -853,7 +868,7 @@ jobs:
|
||||
|
||||
- name: Upload parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -892,7 +907,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build private QA runtime
|
||||
@@ -926,6 +940,20 @@ jobs:
|
||||
--runtime-pair pi,codex \
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
|
||||
|
||||
- name: Run soak runtime parity tier
|
||||
id: runtime_parity_soak_lane
|
||||
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--runtime-parity-tier soak \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "openai/gpt-5.5-alt" \
|
||||
--runtime-pair pi,codex \
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
|
||||
|
||||
- name: Generate runtime parity report
|
||||
if: always()
|
||||
run: |
|
||||
@@ -946,9 +974,24 @@ jobs:
|
||||
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
|
||||
|
||||
- name: Generate soak runtime parity report
|
||||
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_soak_lane.outcome != 'skipped' && steps.runtime_parity_soak_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
summary=".artifacts/qa-e2e/runtime-parity-soak/qa-suite-summary.json"
|
||||
if [[ ! -f "$summary" ]]; then
|
||||
echo "No soak runtime parity summary was produced."
|
||||
exit 0
|
||||
fi
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--runtime-axis \
|
||||
--summary "$summary" \
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-soak-report
|
||||
|
||||
- name: Upload runtime parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -979,11 +1022,10 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download runtime parity artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -999,7 +1041,7 @@ jobs:
|
||||
|
||||
- name: Upload runtime tool coverage artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/runtime-parity-standard-report/
|
||||
@@ -1032,7 +1074,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build private QA runtime
|
||||
@@ -1079,7 +1120,7 @@ jobs:
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1112,7 +1153,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -1175,7 +1215,7 @@ jobs:
|
||||
|
||||
- name: Upload Telegram QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1208,7 +1248,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -1271,7 +1310,7 @@ jobs:
|
||||
|
||||
- name: Upload Discord QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1307,7 +1346,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -1370,7 +1408,7 @@ jobs:
|
||||
|
||||
- name: Upload WhatsApp QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1403,7 +1441,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -1466,7 +1503,7 @@ jobs:
|
||||
|
||||
- name: Upload Slack QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
|
||||
214
.github/workflows/openclaw-release-publish.yml
vendored
214
.github/workflows/openclaw-release-publish.yml
vendored
@@ -71,7 +71,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
|
||||
jobs:
|
||||
resolve_release_target:
|
||||
@@ -153,10 +152,25 @@ jobs:
|
||||
preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest"
|
||||
rm -rf "${preflight_dir}"
|
||||
mkdir -p "${preflight_dir}"
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${preferred_name}" \
|
||||
--dir "${preflight_dir}"; then
|
||||
|
||||
download_named_artifact() {
|
||||
local artifact_name="$1"
|
||||
for attempt in 1 2 3; do
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${artifact_name}" \
|
||||
--dir "${preflight_dir}"; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$attempt" != "3" ]]; then
|
||||
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if download_named_artifact "${preferred_name}"; then
|
||||
echo "name=${preferred_name}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -172,10 +186,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
fallback_name="${matches[0]}"
|
||||
gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${fallback_name}" \
|
||||
--dir "${preflight_dir}"
|
||||
download_named_artifact "${fallback_name}"
|
||||
echo "name=${fallback_name}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download full release validation manifest
|
||||
@@ -336,6 +347,7 @@ jobs:
|
||||
needs: [resolve_release_target]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
environment: npm-release
|
||||
steps:
|
||||
- name: Checkout release SHA
|
||||
uses: actions/checkout@v6
|
||||
@@ -348,7 +360,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
cache-key-suffix: release-publish
|
||||
|
||||
- name: Dispatch publish workflows
|
||||
env:
|
||||
@@ -438,7 +449,7 @@ jobs:
|
||||
|
||||
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
|
||||
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
|
||||
return 0
|
||||
return 1
|
||||
fi
|
||||
|
||||
approved=0
|
||||
@@ -450,13 +461,15 @@ jobs:
|
||||
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
|
||||
-F "environment_ids[]=${env_id}" \
|
||||
-f state=approved \
|
||||
-f comment="Approve release gate from OpenClaw Release Publish wrapper" >/dev/null
|
||||
-f comment="Approve child release gate after parent release approval" >/dev/null
|
||||
approved=1
|
||||
done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv')
|
||||
|
||||
if [[ "${approved}" == "1" ]]; then
|
||||
echo "${workflow}: approved available pending environment gates"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
print_failed_run_summary() {
|
||||
@@ -499,7 +512,7 @@ jobs:
|
||||
if [[ "$state" != "$last_state" ]]; then
|
||||
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
|
||||
print_pending_deployments "${workflow}" "${run_id}"
|
||||
approve_pending_deployments "${workflow}" "${run_id}"
|
||||
approve_pending_deployments "${workflow}" "${run_id}" || true
|
||||
last_state="$state"
|
||||
fi
|
||||
sleep 30
|
||||
@@ -546,6 +559,85 @@ jobs:
|
||||
wait_run_pid="$!"
|
||||
}
|
||||
|
||||
wait_for_job_success() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local job_name="$3"
|
||||
local jobs_json job_json run_status run_conclusion status conclusion url deadline
|
||||
|
||||
deadline=$((SECONDS + 900))
|
||||
while true; do
|
||||
jobs_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,jobs)"
|
||||
run_status="$(printf '%s' "$jobs_json" | jq -r '.status')"
|
||||
run_conclusion="$(printf '%s' "$jobs_json" | jq -r '.conclusion // ""')"
|
||||
job_json="$(printf '%s' "$jobs_json" | jq -c --arg name "$job_name" '.jobs[]? | select(.name == $name) | {status, conclusion, url}' | head -n 1)"
|
||||
if [[ -n "$job_json" ]]; then
|
||||
status="$(printf '%s' "$job_json" | jq -r '.status')"
|
||||
conclusion="$(printf '%s' "$job_json" | jq -r '.conclusion // ""')"
|
||||
url="$(printf '%s' "$job_json" | jq -r '.url // ""')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" == "success" || "$conclusion" == "skipped" ]]; then
|
||||
echo "${workflow} ${job_name} ${conclusion}: ${url}"
|
||||
echo "- ${workflow} ${job_name}: ${conclusion} (${url})" >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
echo "${workflow} ${job_name} failed: ${conclusion} ${url}" >&2
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
echo "${workflow} ${job_name} still ${status}: ${url}"
|
||||
elif [[ "$run_status" == "completed" ]]; then
|
||||
if [[ "$run_conclusion" == "success" ]]; then
|
||||
echo "${workflow} completed before ${job_name} was needed."
|
||||
echo "- ${workflow} ${job_name}: not needed" >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
echo "${workflow} completed before ${job_name} with ${run_conclusion}." >&2
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
else
|
||||
echo "${workflow} waiting for ${job_name} to start: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "${workflow} ${job_name} did not complete within 15 minutes." >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
approve_child_publish_environment() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local run_json status conclusion deadline
|
||||
|
||||
deadline=$((SECONDS + 900))
|
||||
while true; do
|
||||
if approve_pending_deployments "${workflow}" "${run_id}"; then
|
||||
echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"
|
||||
status="$(printf '%s' "$run_json" | jq -r '.status')"
|
||||
conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion // ""')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" == "success" ]]; then
|
||||
echo "${workflow}: completed before child environment approval was needed"
|
||||
return 0
|
||||
fi
|
||||
echo "${workflow}: completed before child environment approval with ${conclusion}" >&2
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "${workflow}: child environment approval was not available within 15 minutes." >&2
|
||||
print_pending_deployments "${workflow}" "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
create_or_update_github_release() {
|
||||
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
@@ -645,10 +737,14 @@ jobs:
|
||||
--workflow-ref "${CHILD_WORKFLOW_REF}"
|
||||
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
|
||||
--plugin-npm-run "${plugin_npm_run_id}"
|
||||
--plugin-clawhub-run "${plugin_clawhub_run_id}"
|
||||
--openclaw-npm-run "${openclaw_npm_run_id}"
|
||||
--evidence-out "${evidence_path}"
|
||||
)
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
|
||||
else
|
||||
verify_args+=(--skip-clawhub)
|
||||
fi
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
verify_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
@@ -663,27 +759,97 @@ jobs:
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
body_file="${RUNNER_TEMP}/release-body.md"
|
||||
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
|
||||
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
|
||||
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
|
||||
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
|
||||
|
||||
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
telegram_line="- npm Telegram beta E2E: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${NPM_TELEGRAM_RUN_ID}"
|
||||
else
|
||||
telegram_line="- npm Telegram beta E2E: not supplied"
|
||||
fi
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
else
|
||||
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
fi
|
||||
|
||||
RELEASE_BODY_FILE="${body_file}" \
|
||||
RELEASE_NOTES_FILE="${notes_file}" \
|
||||
RELEASE_VERSION="${release_version}" \
|
||||
RELEASE_TAG="${RELEASE_TAG}" \
|
||||
RELEASE_REPO="${GITHUB_REPOSITORY}" \
|
||||
RELEASE_TARBALL="${tarball}" \
|
||||
RELEASE_INTEGRITY="${integrity}" \
|
||||
RELEASE_PUBLISH_RUN_ID="${GITHUB_RUN_ID}" \
|
||||
PREFLIGHT_RUN_ID="${PREFLIGHT_RUN_ID}" \
|
||||
FULL_RELEASE_VALIDATION_RUN_ID="${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
|
||||
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
|
||||
CLAWHUB_LINE="${clawhub_line}" \
|
||||
TELEGRAM_LINE="${telegram_line}" \
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const bodyFile = process.env.RELEASE_BODY_FILE;
|
||||
const notesFile = process.env.RELEASE_NOTES_FILE;
|
||||
if (!bodyFile || !notesFile) {
|
||||
throw new Error("Missing release notes file paths.");
|
||||
}
|
||||
|
||||
const body = readFileSync(bodyFile, "utf8").trimEnd();
|
||||
const section = [
|
||||
"### Release verification",
|
||||
"",
|
||||
`- 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`,
|
||||
`- 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}`,
|
||||
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
|
||||
process.env.CLAWHUB_LINE,
|
||||
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
|
||||
process.env.TELEGRAM_LINE,
|
||||
].join("\n");
|
||||
|
||||
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
|
||||
writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`);
|
||||
NODE
|
||||
|
||||
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}"
|
||||
echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
{
|
||||
echo "### Publish sequence"
|
||||
echo
|
||||
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
|
||||
echo "- Release tag: \`${RELEASE_TAG}\`"
|
||||
echo "- Release SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Release approval: this workflow job"
|
||||
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
echo "- OpenClaw npm publish: starts after plugin npm succeeds; final verification waits for ClawHub"
|
||||
echo "- OpenClaw npm publish: starts after plugin npm succeeds"
|
||||
else
|
||||
echo "- OpenClaw npm publish: skipped by input"
|
||||
fi
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
echo "- Workflow completion waits for ClawHub"
|
||||
else
|
||||
echo "- Workflow completion does not wait for ClawHub; monitor the dispatched ClawHub run separately"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
|
||||
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
if [[ -n "${PLUGINS}" ]]; then
|
||||
npm_args+=(-f plugins="${PLUGINS}")
|
||||
clawhub_args+=(-f plugins="${PLUGINS}")
|
||||
@@ -709,6 +875,7 @@ jobs:
|
||||
-f preflight_only=false \
|
||||
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
|
||||
-f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
-f release_publish_run_id="${GITHUB_RUN_ID}" \
|
||||
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
|
||||
echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
@@ -717,13 +884,19 @@ jobs:
|
||||
|
||||
clawhub_result=""
|
||||
clawhub_pid=""
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
openclaw_result=""
|
||||
@@ -760,6 +933,7 @@ jobs:
|
||||
|
||||
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
|
||||
verify_published_release
|
||||
append_release_proof_to_github_release
|
||||
fi
|
||||
if [[ "${failed}" != "0" ]]; then
|
||||
exit 1
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
- name: Upload SARIF as workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: opengrep-full-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
- name: Upload SARIF as workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: opengrep-pr-diff-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
|
||||
36
.github/workflows/package-acceptance.yml
vendored
36
.github/workflows/package-acceptance.yml
vendored
@@ -288,7 +288,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
PACKAGE_ARTIFACT_NAME: package-under-test
|
||||
|
||||
jobs:
|
||||
@@ -320,7 +319,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }}
|
||||
install-deps: "false"
|
||||
|
||||
@@ -514,9 +512,35 @@ jobs:
|
||||
echo "- Published upgrade survivor scenarios: \`${PUBLISHED_UPGRADE_SURVIVOR_SCENARIOS}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
package_integrity:
|
||||
name: Package integrity
|
||||
needs: resolve_package
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout package workflow ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.workflow_ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Download package-under-test artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ needs.resolve_package.outputs.package_artifact_name }}
|
||||
path: .artifacts/docker-e2e-package
|
||||
|
||||
- name: Enforce public package integrity
|
||||
env:
|
||||
OPENCLAW_PACKAGE_TARBALL_CHECK_TIMINGS: "0"
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/check-openclaw-package-tarball.mjs .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
|
||||
docker_acceptance:
|
||||
name: Docker product acceptance
|
||||
needs: resolve_package
|
||||
needs: [resolve_package, package_integrity]
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ inputs.advisory }}
|
||||
@@ -580,7 +604,7 @@ jobs:
|
||||
|
||||
package_telegram:
|
||||
name: Telegram package acceptance
|
||||
needs: resolve_package
|
||||
needs: [resolve_package, package_integrity]
|
||||
if: needs.resolve_package.outputs.telegram_enabled == 'true'
|
||||
uses: ./.github/workflows/npm-telegram-beta-e2e.yml
|
||||
with:
|
||||
@@ -598,7 +622,7 @@ jobs:
|
||||
|
||||
summary:
|
||||
name: Verify package acceptance
|
||||
needs: [resolve_package, docker_acceptance, package_telegram]
|
||||
needs: [resolve_package, package_integrity, docker_acceptance, package_telegram]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
@@ -606,6 +630,7 @@ jobs:
|
||||
- name: Verify package acceptance results
|
||||
env:
|
||||
DOCKER_RESULT: ${{ needs.docker_acceptance.result }}
|
||||
PACKAGE_INTEGRITY_RESULT: ${{ needs.package_integrity.result }}
|
||||
PACKAGE_TELEGRAM_RESULT: ${{ needs.package_telegram.result }}
|
||||
RESOLVE_RESULT: ${{ needs.resolve_package.result }}
|
||||
shell: bash
|
||||
@@ -615,6 +640,7 @@ jobs:
|
||||
failed=0
|
||||
for item in \
|
||||
"resolve_package=${RESOLVE_RESULT}" \
|
||||
"package_integrity=${PACKAGE_INTEGRITY_RESULT}" \
|
||||
"docker_acceptance=${DOCKER_RESULT}" \
|
||||
"package_telegram=${PACKAGE_TELEGRAM_RESULT}"
|
||||
do
|
||||
|
||||
42
.github/workflows/plugin-clawhub-release.yml
vendored
42
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -20,6 +20,10 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
@@ -28,7 +32,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_REPOSITORY: "openclaw/clawhub"
|
||||
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
|
||||
@@ -57,7 +60,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
@@ -196,6 +198,37 @@ jobs:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: preview_plugins_clawhub
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Plugin ClawHub publish dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct Plugin ClawHub Release dispatch; relying on this workflow's clawhub-plugin-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
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
|
||||
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}`);'
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_clawhub
|
||||
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
@@ -229,7 +262,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
@@ -289,11 +321,12 @@ jobs:
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack]
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
@@ -323,7 +356,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
|
||||
42
.github/workflows/plugin-npm-release.yml
vendored
42
.github/workflows/plugin-npm-release.yml
vendored
@@ -32,6 +32,10 @@ on:
|
||||
description: Comma-separated plugin package names to publish when publish_scope=selected
|
||||
required: false
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
@@ -40,7 +44,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
|
||||
jobs:
|
||||
preview_plugins_npm:
|
||||
@@ -64,7 +67,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
@@ -173,6 +175,37 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: preview_plugins_npm
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Plugin npm publish dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct Plugin NPM Release dispatch; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
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
|
||||
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}`);'
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_npm
|
||||
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
@@ -195,7 +228,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Preview publish command
|
||||
@@ -205,11 +237,12 @@ jobs:
|
||||
run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
publish_plugins_npm:
|
||||
needs: [preview_plugins_npm, preview_plugin_pack]
|
||||
needs: [preview_plugins_npm, preview_plugin_pack, validate_release_publish_approval]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
@@ -228,7 +261,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
|
||||
25
.github/workflows/qa-live-transports-convex.yml
vendored
25
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -51,7 +51,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL || 'openai/gpt-5.5' }}
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
@@ -182,7 +181,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build private QA runtime
|
||||
@@ -222,7 +220,7 @@ jobs:
|
||||
|
||||
- name: Upload parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -252,7 +250,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -312,7 +309,7 @@ jobs:
|
||||
|
||||
- name: Upload live runtime token-efficiency artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -338,7 +335,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -389,7 +385,7 @@ jobs:
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -424,7 +420,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -474,7 +469,7 @@ jobs:
|
||||
|
||||
- name: Upload Matrix QA shard artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -499,7 +494,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -569,7 +563,7 @@ jobs:
|
||||
|
||||
- name: Upload Telegram QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -594,7 +588,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -664,7 +657,7 @@ jobs:
|
||||
|
||||
- name: Upload Discord QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -692,7 +685,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -762,7 +754,7 @@ jobs:
|
||||
|
||||
- name: Upload WhatsApp QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -787,7 +779,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
@@ -857,7 +848,7 @@ jobs:
|
||||
|
||||
- name: Upload Slack QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
|
||||
21
.github/workflows/real-behavior-proof.yml
vendored
21
.github/workflows/real-behavior-proof.yml
vendored
@@ -18,6 +18,7 @@ jobs:
|
||||
name: Real behavior proof
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -25,5 +26,25 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
permission-issues: read
|
||||
permission-members: read
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
permission-issues: read
|
||||
permission-members: read
|
||||
- name: Check real behavior proof
|
||||
env:
|
||||
GH_APP_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/github/real-behavior-proof-check.mjs
|
||||
|
||||
4
.github/workflows/website-installer-sync.yml
vendored
4
.github/workflows/website-installer-sync.yml
vendored
@@ -76,11 +76,9 @@ jobs:
|
||||
- name: install.sh in Docker
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
|
||||
node:24-bookworm-slim \
|
||||
bash -lc 'bash /tmp/install.sh --no-prompt --no-onboard --version latest && openclaw --version'
|
||||
bash -lc 'bash /tmp/install.sh --version latest && openclaw --version'
|
||||
|
||||
- name: install-cli.sh in Docker
|
||||
run: |
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -109,6 +109,7 @@ USER.md
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
.crabbox/
|
||||
|
||||
# local QA evidence mirrors; CI publishes canonical Mantis files as Actions artifacts
|
||||
mantis/
|
||||
@@ -122,6 +123,8 @@ mantis/
|
||||
!.agents/skills/crabbox/**
|
||||
!.agents/skills/clawdtributor/
|
||||
!.agents/skills/clawdtributor/**
|
||||
!.agents/skills/control-ui-e2e/
|
||||
!.agents/skills/control-ui-e2e/**
|
||||
!.agents/skills/gitcrawl/
|
||||
!.agents/skills/gitcrawl/**
|
||||
!.agents/skills/openclaw-docs/**
|
||||
@@ -131,6 +134,8 @@ mantis/
|
||||
!.agents/skills/openclaw-debugging/**
|
||||
!.agents/skills/openclaw-ghsa-maintainer/
|
||||
!.agents/skills/openclaw-ghsa-maintainer/**
|
||||
!.agents/skills/openclaw-landable-bug-sweep/
|
||||
!.agents/skills/openclaw-landable-bug-sweep/**
|
||||
!.agents/skills/openclaw-parallels-smoke/
|
||||
!.agents/skills/openclaw-parallels-smoke/**
|
||||
!.agents/skills/openclaw-pr-maintainer/
|
||||
@@ -159,6 +164,8 @@ mantis/
|
||||
!.agents/skills/security-triage/**
|
||||
!.agents/skills/tag-duplicate-prs-issues/
|
||||
!.agents/skills/tag-duplicate-prs-issues/**
|
||||
!.agents/skills/autoreview/
|
||||
!.agents/skills/autoreview/**
|
||||
|
||||
# Agent credentials and memory (NEVER COMMIT)
|
||||
/memory/
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"rules": {
|
||||
"curly": "error",
|
||||
"eslint/no-underscore-dangle": "error",
|
||||
"eslint-plugin-unicorn/prefer-array-find": "error",
|
||||
"eslint/no-array-constructor": "error",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
|
||||
18
AGENTS.md
18
AGENTS.md
@@ -35,9 +35,11 @@ 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.
|
||||
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
|
||||
- Fix shape: prefer bounded owner-boundary refactors over local patches/shims when they remove stale abstractions, duplicate policy, or wrong ownership.
|
||||
- Compat default: no new internal shims, aliases, fallback APIs, or legacy names just to reduce diff. Migrate callers and delete old paths.
|
||||
- Public plugin API is the only compat exception: document/version breaks, aggressively deprecate unused SDK surface, and migrate ALL bundled/internal plugins to the modern API in the same change.
|
||||
- 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.
|
||||
- Lean code is a goal. No internal shims, aliases, legacy names, broad fallbacks, or defensive branches just to reduce diff or handle unrealistic edge cases.
|
||||
- Handle real production states, shipped upgrade paths, security boundaries, and dependency contracts. Public/hostile/observed malformed input gets care; hypothetical malformed input does not.
|
||||
- Public plugin SDK/API is the compat exception. New API first, old path only via named compat/deprecation metadata, docs, warnings when useful, tests for old+new, planned removal.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -50,7 +52,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
## Commands
|
||||
|
||||
- Runtime: Node 22+. Keep Node + Bun paths working.
|
||||
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
|
||||
- Package manager/runtime: repo defaults only. No swaps without approval.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
|
||||
- Sharp/Homebrew libvips source-build fail: `SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm install`.
|
||||
@@ -77,10 +79,12 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- If proof is blocked, say exactly what is missing and why.
|
||||
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
|
||||
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
|
||||
- Prompt snapshots: CI truth is Linux Node 24. If macOS local passes but CI drifts, reproduce/generate in Linux before rerun.
|
||||
|
||||
## GitHub / PRs
|
||||
|
||||
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
|
||||
- Pasted GitHub issue/PR: first `git status -sb`; if dirty, yell; then `git push` + `git pull --ff-only`.
|
||||
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
|
||||
- Bare issue/PR URL/number means review/report in chat. Suggest comment/close/merge when appropriate; mutate only when asked.
|
||||
- No unsolicited PR comments/reviews/labels/retitles/rebases/fixups/landing. Exception: close/duplicate action that needs a reason comment after explicit close/sweep/landing request.
|
||||
@@ -108,6 +112,10 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
|
||||
- External boundaries: prefer `zod` or existing schema helpers.
|
||||
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
|
||||
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
|
||||
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
|
||||
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
|
||||
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
|
||||
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
|
||||
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
|
||||
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
|
||||
@@ -153,6 +161,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Never commit real phone numbers, videos, credentials, live config.
|
||||
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
|
||||
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only.
|
||||
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
|
||||
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
|
||||
- Releases/publish/version bumps need explicit approval. Use `$openclaw-release-maintainer`.
|
||||
- GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`.
|
||||
@@ -164,6 +173,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- "restart iOS/Android apps" = rebuild/reinstall/relaunch, not kill/launch.
|
||||
- SwiftUI: Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
|
||||
- Mac gateway: dev watch = `pnpm gateway:watch`; managed installs = `openclaw gateway restart/status --deep`; logs = `./scripts/clawlog.sh`. No launchd/ad-hoc tmux.
|
||||
- Mac app permission testing: stable app path + real signing identity required. No `--no-sign`, `SIGN_IDENTITY=-`, or raw debug binary; TCC prompts/listing won't stick.
|
||||
- Version bump surfaces live in `$openclaw-release-maintainer`.
|
||||
- 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.
|
||||
|
||||
431
CHANGELOG.md
431
CHANGELOG.md
@@ -2,21 +2,314 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
## 2026.5.21
|
||||
|
||||
### Changes
|
||||
|
||||
- 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.
|
||||
- Packaging: exclude documentation images and assets from the npm tarball, reducing published package size without affecting runtime docs search or CLI behavior. Thanks @SebTardif.
|
||||
- Agents/subagents: limit default sub-agent bootstrap context to `AGENTS.md` and `TOOLS.md`, keeping persona, identity, user, memory, heartbeat, and setup files out of delegated workers by default. (#85283) Thanks @100yenadmin.
|
||||
- Maintainer skills: exclude plugin SDK/API boundary work from `openclaw-landable-bug-sweep` so bugbash sweeps stay focused on small paper-cut fixes.
|
||||
- Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.
|
||||
- Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as `docker` and `blacksmith`. (#85302) Thanks @hxy91819.
|
||||
- Maintainer skills: add `openclaw-landable-bug-sweep` for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.
|
||||
- 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.
|
||||
- Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.
|
||||
- xAI/Grok: reuse xAI OAuth auth profiles for Grok `web_search`, thread active-agent auth through web search, add Grok model aliases, and let media providers declare default operation timeouts. (#85182) Thanks @fuller-stack-dev.
|
||||
- Plugin SDK: add row-level session workflow helpers and deprecate `loadSessionStore` so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.
|
||||
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
|
||||
- Plugins/SDK: add a general `embeddingProviders` capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.
|
||||
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
|
||||
- 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.
|
||||
- QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.
|
||||
- QA-Lab: add a QA bus tool-trace visibility scenario for sanitized tool-call assertions.
|
||||
- QA-Lab: replace generic evidence framing in seeded scenario prompts with concrete observed QA behavior.
|
||||
- QA-Lab: list named scenario packs in the coverage report so personal-agent privacy coverage stays visible in audits.
|
||||
- QA-Lab: list live transport lane membership in the coverage report so real transport checks stay separate from seeded qa-channel scenarios.
|
||||
- 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.
|
||||
- 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.
|
||||
- QA-Lab: add a live-only long-context progress watchdog scenario for Codex app-server timeout and stalled-run sentinels. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: tag gateway restart recovery and streaming final-integrity scenarios as live-only runtime parity lanes. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: add a personal-agent failure recovery scenario that checks honest partial status, retry boundaries, and local recovery artifacts. (#83872) Thanks @iFiras-Max1.
|
||||
- QA-Lab: include an opt-in `update.run` package self-upgrade sentinel for destructive latest-package recovery checks.
|
||||
- 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.
|
||||
- Models/perf: pre-warm the provider auth-state map at gateway startup so `/models` 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.
|
||||
- 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.
|
||||
- Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so `doctor-core-checks` no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.
|
||||
|
||||
### Fixes
|
||||
|
||||
- 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.
|
||||
- Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.
|
||||
- CLI/update: repair managed npm plugin `openclaw` peer links during post-core convergence and reject stale or wrong-target peer links before restart. (#83794) Thanks @fuller-stack-dev.
|
||||
- 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.
|
||||
- Codex app-server: let authorized `/codex` control commands such as `/codex detach` escape plugin-owned conversation bindings while keeping unknown or unauthorized slash text routed to the bound plugin. Fixes #85157. (#85188) Thanks @TurboTheTurtle.
|
||||
- Auto-reply/models: keep `/models` browse replies fast by sharing the bounded read-only catalog path with Gateway model listing. (#84735) Thanks @safrano9999.
|
||||
- Codex app-server: disable native Code Mode when the effective exec host is `node` and keep OpenClaw `exec`/`process` available, so `/exec host=node` routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.
|
||||
- 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.
|
||||
- Gateway/agents: return phase-aware `agent.wait` timeout attribution and only cool auth profiles on provider-started timeouts. Refs #65504. Thanks @100yenadmin.
|
||||
- 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.
|
||||
- Agents/Codex: show the first plan update as a transient chat status notice without counting it as final assistant content.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Gateway/service: include the active `openclaw` command bin directory in managed service PATH generation and doctor audit expectations for npm-global macOS installs. Fixes #84201. (#84475) Thanks @jbetala7.
|
||||
- Control UI/chat: disable the thinking selector for known non-reasoning models instead of showing duplicate Off choices. Fixes #84069. Thanks @DrippingMellow.
|
||||
- Memory: expand `~` in configured extra memory paths before resolving them, so home-relative folders are not treated as workspace-relative. Fixes #58026. Thanks @stadman.
|
||||
- Skills: treat `openclaw.os: macos` as Darwin when checking skill requirements, so macOS-only skills no longer report as missing on macOS hosts. Fixes #61338. Thanks @Jessecq1995.
|
||||
- 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.
|
||||
- Docker: pre-create the workspace and auth-profile config mount points with `node` ownership so first-run named volumes do not start root-owned. Fixes #85076. Thanks @Noerr.
|
||||
- Telegram: pass configured markdown table mode through outbound markdown chunking so chunked sends render tables consistently. Fixes #85085. Thanks @ShuaiHui.
|
||||
- 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)
|
||||
- Agents/providers: honor per-model `api` and `baseUrl` overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.
|
||||
- Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.
|
||||
- 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.
|
||||
- Skills: watch each shared skill directory once across agent workspaces instead of once per agent, preventing file-descriptor exhaustion (`EMFILE`) that disposed bundle-mcp processes and stalled sessions on multi-agent gateways. Fixes #84968. (#85130) Thanks @openperf.
|
||||
- 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.
|
||||
- Cron: honor `cron.retry.retryOn: ["network"]` for common network error codes such as `EAI_AGAIN`, `EHOSTUNREACH`, and `ENETUNREACH`.
|
||||
- Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945.
|
||||
- Gateway chat display: preserve OpenAI-compatible `prompt_tokens`, `completion_tokens`, and `total_tokens` usage fields in sanitized chat history so llama.cpp sessions keep context counts. Fixes #77992. Thanks @MarTT79.
|
||||
- Dashboard/CLI: allow macOS browser launching through `open` even when SSH environment variables are present, while preserving Linux SSH no-display protection. Fixes #67088. Thanks @theglove44.
|
||||
- Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.
|
||||
- OpenCode Go: strip unsupported Kimi reasoning replay fields before provider requests so repeated `kimi-k2.6` turns do not fail schema validation. Fixes #83812. Thanks @Sleeck.
|
||||
- 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.
|
||||
- Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.
|
||||
- Doctor/Codex: point native Codex asset warnings at the canonical `openclaw migrate plan codex` preview command. Fixes #84948. Thanks @markoa.
|
||||
- CLI/models: make `capability model auth logout --agent` remove auth profiles from the selected non-default agent store. Fixes #85092. Thanks @islandpreneur007.
|
||||
- CLI/status: suppress systemd user-service setup hints when `openclaw status --deep` can already reach a running Gateway RPC service. Fixes #85094. Thanks @islandpreneur007.
|
||||
- CLI/devices: recover local approval when a same-device repair request replaces the request ID being approved.
|
||||
- CLI/agents: retry transient normal-close Gateway handshakes before falling back to embedded `openclaw agent` execution.
|
||||
- CLI/update: keep managed Gateway service stop/restart status lines out of `openclaw update --json` stdout so package-update automation can parse the JSON payload.
|
||||
- Plugins: resolve OpenClaw plugin SDK subpaths for native external plugin runtimes without mutating package installs or broadening process-wide module resolution.
|
||||
- Agents/OpenAI: preserve Responses and Chat Completions `reasoning_tokens` usage metadata without double-counting it in aggregate output tokens. (#85319)
|
||||
- Control UI/chat: convert pasted `data:image/...;base64,...` clipboard text into an image attachment instead of dumping the payload into the composer. Fixes #62604. Thanks @cpwilhelmi.
|
||||
- Providers/Gemini: strip fractional seconds from web-search time range filters so Gemini accepts freshness-bound search requests. (#85071) Thanks @Noerr.
|
||||
- OpenAI Codex: preserve image input support for sparse `openai-codex/gpt-5.5` catalog rows. (#85095) Thanks @sercada.
|
||||
- Plugins/discovery: strip `-plugin` package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu.
|
||||
- Tlon: stop advertising a non-existent agent tool contract in the plugin manifest.
|
||||
- Telegram: preserve fenced code block languages through Markdown rendering so Telegram receives `language-*` code classes. (#85209) Thanks @leno23.
|
||||
- Windows updates: roll back git-backed updates to the previous checkout when dependency install, build, UI build, or doctor repair fails.
|
||||
- Windows installer: persist user-local portable Git on PATH and activate the repo-pinned pnpm version for git-backed installs and updates.
|
||||
- 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.
|
||||
- Windows installer: extract the downloaded portable Node.js directory with native `tar` before falling back to .NET zip extraction, avoiding PowerShell 5.1 archive and path-length failures.
|
||||
- fix(integrations): enforce channel read target allowlists [AI]. (#84982) Thanks @pgondhi987.
|
||||
- Agents/heartbeat: route single-owner `session.dmScope=main` 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.
|
||||
- Agents/code-mode: expose outer code-mode `exec` source through the `command` hook alias with `toolKind`/`toolInputKind` discriminators so exec-shaped policies can distinguish code-mode cells. (#83483) Thanks @Kaspre.
|
||||
- Agents/code mode: return structured timeout and runtime-unavailable error codes for known worker failures. Fixes #83389. (#83444) Thanks @Kaspre.
|
||||
- QA-Lab: isolate multi-scenario suite workers when scenarios need startup config patches, preventing message-routing config from leaking into unrelated scenarios.
|
||||
- QA-Lab: make the commitments heartbeat-target-none scenario request an immediate heartbeat instead of waiting for the next scheduled heartbeat.
|
||||
- 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.
|
||||
- Gateway CLI: surface local post-challenge connect assembly failures immediately instead of waiting for the wrapper timeout. Fixes #68944. (#85253) Thanks @samzong.
|
||||
- Messages: strip unsupported web-search citation control markers from outbound replies before they reach WebChat or external channels. Fixes #85193. (#85204) Thanks @neeravmakwana.
|
||||
- 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.
|
||||
- CLI/agents: abort accepted Gateway-backed `openclaw agent` runs on SIGINT/SIGTERM so cron and supervisor timeouts do not leave remote agent work alive. Fixes #71710. (#84381) Thanks @Kaspre.
|
||||
- Codex app-server: retry replay-safe stdio client-close turns once using structured failure metadata, while surfacing idle `turn/completed` timeouts instead of blindly replaying active shared-server turns. Thanks @VACInc.
|
||||
- Codex app-server: reject command overrides that embed Node or package-manager arguments and point users to `appServer.args`, so Windows startup avoids shell parsing failures. (#84417) Thanks @TurboTheTurtle.
|
||||
- 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.
|
||||
- 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.
|
||||
- fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.
|
||||
- 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.
|
||||
- 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.
|
||||
- Agents/hooks: wait for local one-shot CLI and Codex `agent_end` plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)
|
||||
- Providers/Google: preserve Gemini 3 cron `thinkingDefault: "low"` when stale catalog metadata says `reasoning:false`, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.
|
||||
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
|
||||
- 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.
|
||||
- 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.
|
||||
- Channels: treat bare abort messages such as `stop`, `abort`, and `wait` as immediate control commands in inbound debounce paths so stop requests are not delayed behind pending message coalescing. (#83348) Thanks @IWhatsskill.
|
||||
- Channels/message tool: resolve configured external channel plugins during in-agent channel selection, so `openclaw agent --local` message-tool sends no longer report an available channel as unavailable. (#85022) Thanks @Kaspre.
|
||||
- Gateway/ACP: close child ACP sessions spawned via `sessions_spawn` when their parent session is reset or deleted, instead of leaving orphaned `claude-agent-acp` processes that accumulate and exhaust memory. Fixes #68916. (#85190) Thanks @openperf.
|
||||
- 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.
|
||||
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
|
||||
- Context engines: fail closed with a descriptive error when the selected agent runtime cannot satisfy declared context-engine host requirements.
|
||||
- Auth/Codex: silently auto-migrate legacy Codex OAuth profiles whose seed lives only in macOS Keychain on the first interactive `openclaw` invocation — no in-CLI prompt; the macOS Keychain dialog is the only consent surface. Deny or decryption failure writes a permanent decline marker (re-runnable via `openclaw doctor --fix`); headless paths still emit a one-shot warning naming the doctor command instead of silently failing with "No API key found". Fixes #85083. Thanks @romneyda.
|
||||
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
|
||||
- CLI/models: resolve `openclaw models set` aliases from the runtime config while keeping authored aliases ahead of runtime-only defaults. (#83262) Thanks @IWhatsskill.
|
||||
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
|
||||
- Install/update: reject OpenClaw GitHub source package targets early and point moving-main users at the dev/git install path instead of the broken npm source-install flow.
|
||||
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.
|
||||
- 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.
|
||||
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
|
||||
- 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.
|
||||
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
|
||||
- Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.
|
||||
- Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.
|
||||
- Gateway/status: surface inbound delivery telemetry counters and transport-liveness warnings in `openclaw status --all`. Fixes #49577. (#72724)
|
||||
- Docker: prune package-excluded plugin source workspaces and dependency closures so runtime images do not keep packages for plugins that were not opted in.
|
||||
- Providers/Ollama: treat Docker/OrbStack host aliases as local Ollama endpoints so `ollama-local` marker auth works when OpenClaw runs inside a VM/container and Ollama runs on the host. Fixes #84875.
|
||||
- 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.
|
||||
- Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt.
|
||||
- Installer: require a real controlling terminal before launching onboarding so headless `curl | bash` installs finish cleanly after installing the CLI.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Control UI/Web Push: use `https://openclaw.ai` as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when `OPENCLAW_VAPID_SUBJECT` is unset. Fixes #83134. (#83317) Thanks @IWhatsskill.
|
||||
- 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.
|
||||
- Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.
|
||||
- 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.
|
||||
- Memory/search: stop recall tracking from writing dreaming side-effect artifacts when `dreaming.enabled=false`, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.
|
||||
- Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.
|
||||
- QA: keep `pnpm qa:e2e` self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.
|
||||
- fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.
|
||||
- doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.
|
||||
- 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.
|
||||
- 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.
|
||||
- Discord: keep session recovery and `/stop` 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.
|
||||
- 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.
|
||||
- Codex app-server: give visible `message` 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.
|
||||
- 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.
|
||||
- Matrix: keep explicitly configured two-person rooms on the room route before stale `m.direct` or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant.
|
||||
- Agents/subagents: require explicit subagent allowlist targets to be configured agents so stale deleted-agent ids are omitted from `agents_list` and rejected by `sessions_spawn`. Fixes #84811. (#85154) Thanks @joshavant.
|
||||
- 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.
|
||||
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
|
||||
- Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.
|
||||
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
|
||||
- Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.
|
||||
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)
|
||||
- Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale `dreaming-narrative-*` sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.
|
||||
- Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.
|
||||
- TUI: coalesce repeated idle Esc abort notices into a single `no active run xN` system row instead of appending duplicate rows.
|
||||
- Telegram: honor `channels.telegram.pollingStallThresholdMs` in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.
|
||||
- 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.
|
||||
- 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.
|
||||
- Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.
|
||||
- Slack: keep native plugin approval prompts in the originating app conversation thread when the live Slack turn source is a `D...` conversation.
|
||||
- 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.
|
||||
- CLI/perf: keep `setup --help`, `onboard --help`, and `configure --help` out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.
|
||||
- CLI/perf: keep `agents --help` out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.
|
||||
- CLI/perf: keep `secrets --help` and `nodes --help` on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn.
|
||||
- CLI/perf: serve `doctor`, `gateway`, `models`, and `plugins` parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 `No API key found for provider "openai-codex"` until the user runs `openclaw doctor`. Thanks @Totalsolutionsync and @romneyda.
|
||||
- Codex/failover: classify `deactivated_workspace` as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.
|
||||
- Exec: keep configured `tools.exec.pathPrepend` entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.
|
||||
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
|
||||
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` 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.
|
||||
- TUI/streaming watchdog: dismiss the `This response is taking longer than expected` 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.
|
||||
- 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.
|
||||
|
||||
## 2026.5.20
|
||||
|
||||
### Changes
|
||||
|
||||
- Exec approvals: remove the old `cat SKILL.md && printf ... && <skill-wrapper>` allowlist compatibility path so skill files must be loaded with the read tool and only the real skill executable is auto-allowed.
|
||||
- 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.
|
||||
- Discord/voice: include bounded `IDENTITY.md`, `USER.md`, and `SOUL.md` profile context in realtime voice session instructions by default, with `voice.realtime.bootstrapContextFiles: []` available to disable it. (#84499) Thanks @fuller-stack-dev.
|
||||
- Dependencies: bump the bundled Codex harness to `@openai/codex` `0.132.0` and refresh the app-server model-list docs for the new catalog.
|
||||
- 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.
|
||||
- Agents/config: allow `agents.list[].experimental.localModelLean` so lean local-model mode can be enabled for one configured agent instead of globally. (#84073) Thanks @dutifulbob.
|
||||
- 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.
|
||||
- Providers/OpenRouter: honor provider-level `params.provider` routing policy for OpenRouter requests, with model and agent params overriding the defaults. Thanks @amknight.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/tasks: include stale-running task maintenance decisions in `openclaw tasks maintenance --json` so retained and reconcile candidates explain backing-session, cron, CLI, and wedged-subagent state. (#84691) Thanks @efpiva.
|
||||
- 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.
|
||||
- Providers/MiniMax music: stop advertising `durationSeconds` control and remove prompt-injected duration hints, so `music_generate` reports MiniMax duration as an unsupported override instead of suggesting MiniMax can enforce track length. Fixes #84508. Thanks @neeravmakwana.
|
||||
- Doctor: warn when sandbox tool policy hides configured MCP server tools before provider requests. (#84699) Thanks @nxmxbbd.
|
||||
- WhatsApp: update Baileys to `7.0.0-rc12`.
|
||||
- Build: suppress per-locale `rolldown-plugin-dts:fake-js` CommonJS dts warnings emitted while bundling the intentionally-inlined `zod/v4/locales/*.d.cts` files, so `pnpm build` output stays readable after the 0.25.1 plugin bump. Thanks @romneyda.
|
||||
- CLI/nodes: route lazy plugin-registration logs to stderr for JSON-mode `openclaw nodes` commands so stdout stays parseable. (#84684) Thanks @TurboTheTurtle.
|
||||
- Approvals: route manual `/approve` decisions through the trusted approval runtime so active exec and plugin approvals no longer look unknown or expired.
|
||||
- Mac app: update the About settings copyright year to 2026. (#84385) Thanks @pejmanjohn.
|
||||
- Dependencies: update `@openclaw/fs-safe` to `0.2.7` 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.
|
||||
- Infra/secrets: restore the fail-closed contract for `tryReadSecretFileSync` so credential loaders that pass `rejectSymlink: true` (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.
|
||||
- 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)
|
||||
- Doctor: remove unrecognized `models.providers.*.models[*].compat.thinkingFormat` values during `doctor --fix` so stale provider model config can validate after upgrade. Fixes #77803.
|
||||
- Doctor: warn when `openclaw.json` stores plaintext secret-bearing config fields, including model provider API keys and sensitive provider headers. (#84718) Thanks @lukaIvanic.
|
||||
- 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 `agents.defaults.model.primary`.
|
||||
- WebChat: clear stale typing indicators when session change events mark the active chat run complete.
|
||||
- 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.
|
||||
- macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.
|
||||
- Cron: deliver preferred final assistant output for successful scheduled runs when trailing plain tool warnings remain in diagnostics instead of marking the run failed.
|
||||
- fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987.
|
||||
- Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987.
|
||||
- CLI: keep the private QA subcommand out of exported command descriptors unless `OPENCLAW_ENABLE_PRIVATE_QA_CLI=1`, so root help and subcommand markers match runtime registration. (#84519)
|
||||
- CLI/cron: bound `openclaw cron show` job lookup pagination so non-advancing or unbounded `cron.list` responses fail instead of hanging the command. Fixes #83856. (#83989)
|
||||
- Agents/messages: stop message-tool-only turns after a successful source-channel `message` send while keeping transcript mirrors under the session write lock. (#84289)
|
||||
- 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.
|
||||
- Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving `strict=false` fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.
|
||||
- Agents/code mode: spell out the `exec` 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.
|
||||
- Codex: give `image_generate` 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.
|
||||
- Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.
|
||||
- CLI/message: include a stable top-level `messageId` in `openclaw message --json` output when channel sends return one. (#84191) Thanks @100menotu001.
|
||||
- Cron: preserve legacy top-level array `jobs.json` 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.
|
||||
- Gateway/agents: use an agent's `identity.name` in Gateway agent summaries when `agents.list[].name` is unset, so configured agent labels remain visible in clients. (#84355; refs #57835) Thanks @luoyanglang.
|
||||
- Channels/replies: keep normal `/verbose` failed-tool progress compact in message-tool replies and prevent late text-only tool output from appearing after the final answer. (#84303) Thanks @VACInc.
|
||||
- Plugins/hooks: apply a default 30-second timeout to `before_compaction` and `after_compaction` hooks so a hung plugin handler no longer blocks compaction completion. (#84153)
|
||||
- Discord: preserve reusable presentation buttons through portable conversion and Discord component registration. (#84187) Thanks @100menotu001.
|
||||
- Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Plugins/perf: thread explicit plugin discovery results through `loadBundledCapabilityRuntimeRegistry`, `resolveBundledPluginSources`, and `listChannelCatalogEntries` so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif.
|
||||
- harden update restart script creation [AI]. (#84088) Thanks @pgondhi987.
|
||||
- Android/Control UI Talk: split realtime voice transcript turns, queue PCM playback writes, and add opt-in OpenClaw consult routing for Gateway relay when a realtime provider skips `openclaw_agent_consult`. (#84181) Thanks @VACInc.
|
||||
- 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.
|
||||
- CLI/channels: preserve the first line of `openclaw channels logs` output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed `readLogSlice` behavior in `src/logging/log-tail.ts`.
|
||||
- Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057)
|
||||
- CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe.
|
||||
- Matrix/config: accept `messages.queue.byChannel.matrix` queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.
|
||||
- CLI: format `openclaw acp client` failures through the shared error formatter so object-shaped errors stay readable instead of printing `[object Object]`. Fixes #83904. (#84080)
|
||||
- Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob.
|
||||
- Codex app-server: disable native Code Mode, user MCP, and app-backed plugin execution while OpenClaw sandboxing is active, routing shell access through `sandbox_exec`/`sandbox_process` instead. (#84388) Thanks @joshavant.
|
||||
- Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering.
|
||||
- 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 `openclaw update` no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight.
|
||||
- CLI/gateway: include the running Gateway version in `gateway status` JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.
|
||||
- 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.
|
||||
- CLI/nodes: request pending node surface approval scopes before `openclaw nodes approve` so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with `missing scope: operator.admin`. (#84392) Thanks @joshavant.
|
||||
- Gateway: reject slow node event sends before outbound buffers grow unbounded and log the rejected payload diagnostic. (#84387) Thanks @samzong.
|
||||
- Agents: include bounded trajectory queued-writer diagnostics in `pi-trajectory-flush` timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev.
|
||||
- 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.
|
||||
- Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant.
|
||||
- Providers/Anthropic: route Anthropic model refs selected with Claude CLI auth through the Claude CLI runtime so shorthand refs such as `anthropic/opus-4.7` no longer fall back to embedded Anthropic billing. Fixes #84222. (#84374) Thanks @joshavant.
|
||||
- Agents: honor explicit `models.providers.<id>.timeoutSeconds` 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.
|
||||
- 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.
|
||||
- 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 `reason=no_active_run` queue noise. (#82964) Thanks @galiniliev.
|
||||
- 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.
|
||||
- Media: decode URL path basenames before using them as remote media fallback filenames, so files like `My%20Report.pdf` are surfaced as `My Report.pdf`. Fixes #84050. (#84052) Thanks @jbetala7.
|
||||
- WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to `channels.whatsapp.groups` without changing routing or sender authorization. (#83846) Thanks @neeravmakwana.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman.
|
||||
- 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.
|
||||
- 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.
|
||||
- Codex: respect explicit `models auth order set` and `config.auth.order` precedence over stale `lastGood` in `/codex account`, and show `no working credential` when every explicit-order profile is ineligible instead of marking a lower-ranked profile as active. Fixes #84386. (#84412) Thanks @openperf.
|
||||
- Agents: honor `messages.suppressToolErrors` for mutating tool failures so configured chat surfaces do not receive separate warning payloads. (#81561) Thanks @moeedahmed.
|
||||
- Agents/fallback: surface billing guidance for mixed rate-limit plus billing fallback exhaustion instead of generic failure copy. Fixes #79396. (#79489) Thanks @aayushprsingh.
|
||||
|
||||
## 2026.5.19
|
||||
|
||||
### Changes
|
||||
|
||||
- Agents: clarify that fixes should default to clean bounded refactors, lean internals, and explicit plugin SDK/API deprecation paths.
|
||||
- Agents/tools: normalize Swagger/OpenAPI refs and OpenAPI schema annotations when preparing tool parameter schemas.
|
||||
- Dependencies: update `@openclaw/proxyline` to 0.3.3.
|
||||
- Dependencies: update Pi packages to 0.75.1 and raise the minimum supported Node.js 22 line to 22.19.
|
||||
- Docker/Podman: add `OPENCLAW_IMAGE_APT_PACKAGES` as the runtime-neutral image build arg for extra apt packages while keeping `OPENCLAW_DOCKER_APT_PACKAGES` as a legacy fallback. (#62431) Thanks @urtabajev.
|
||||
- Gateway/ACPX: attribute startup probe, config, runtime, and resource-count costs in restart traces without changing readiness behavior. (#83300) Thanks @samzong.
|
||||
- Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving `/readyz` sidecar gating. (#83301) Thanks @samzong.
|
||||
- Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.
|
||||
- Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.
|
||||
- Mac app: refine Voice & Talk recognition-language and wake-phrase settings so they use the same compact card rows as the rest of Settings.
|
||||
- Skills: rename the repo-local Codex closeout review skill and helper to `autoreview` while preserving the Codex-first fallback behavior.
|
||||
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
|
||||
- Skills CLI: allow `openclaw skills install` and `openclaw skills update` to target shared managed skills with `--global`. (#74466) Thanks @Marvae.
|
||||
- Browser: surface pending and recently handled modal dialogs in snapshots, return `blockedByDialog` when an action opens a modal, and allow `browser dialog --dialog-id` to answer pending dialogs.
|
||||
- Browser CLI: add `openclaw browser evaluate --timeout-ms` so long-running page functions can extend both the evaluate action and request timeout budgets. (#83447) Thanks @eefreenyc.
|
||||
- Codex app-server: scope OpenClaw prompt guidance by runtime surface so native Codex keeps Codex-owned base/personality instructions while OpenClaw contributes only runtime context, delivery guidance, and explicitly scoped command hints. (#83454) Thanks @100yenadmin.
|
||||
- Docker/Podman: add `OPENCLAW_IMAGE_PIP_PACKAGES` for opt-in Python package installation in local image builds. (#83771) Thanks @stephenredmond-straiteis.
|
||||
- Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.
|
||||
- Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.
|
||||
- CLI/plugins: add `defineToolPlugin` plus `openclaw plugins build`, `validate`, and `init` for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.
|
||||
- Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.
|
||||
- Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`.
|
||||
- Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.
|
||||
- Codex: add `/codex plugins list`, `enable`, and `disable` for managing configured native Codex plugins from chat without editing config by hand.
|
||||
- Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated.
|
||||
- Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection.
|
||||
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
|
||||
- QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.
|
||||
- QA-Lab: add `openclaw qa suite --runtime-parity-tier` and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.
|
||||
@@ -27,29 +320,136 @@ Docs: https://docs.openclaw.ai
|
||||
- QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.
|
||||
- QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.
|
||||
- QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.
|
||||
- QA-Lab: extend the personal-agent benchmark pack with a local task followthrough scenario for proof-backed pending, blocked, and done status reporting. Thanks @iFiras-Max1.
|
||||
- QA-Lab: add a report-only dreaming shadow-trial scenario so candidate memory promotion can be evaluated without mutating `MEMORY.md`. Thanks @iFiras-Max1.
|
||||
- Gateway/performance: add `pnpm test:restart:gateway` benchmark tooling for repeated restart readiness, downtime, trace, and resource-slope evidence. (#83299) Thanks @samzong.
|
||||
- Android: switch Talk Mode to realtime Gateway relay voice sessions with streaming mic input, realtime audio playback, tool-result bridging, and on-screen transcripts. (#83130) Thanks @sliekens.
|
||||
- Gateway/config: expose config lookup reload metadata so tools can distinguish restart-required, hot-reloadable, and no-op fields before applying config edits. Fixes #81409. (#81612) Thanks @LLagoon3.
|
||||
- Telegram: add allowlisted native DM draft previews for transient tool progress while keeping final answers on the normal persistent delivery path. (#83622) Thanks @akrimm702.
|
||||
- QA-Lab: add a personal-agent share-safe diagnostics artifact scenario so support handoffs keep useful status while omitting raw personal content. Thanks @iFiras-Max1.
|
||||
- QA-Lab: add a personal-agent no-fake-progress scenario so completion claims stay tied to local evidence instead of unsupported external progress. (#83824) Thanks @iFiras-Max1.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/exec approvals: return approved WebChat gateway exec output inline after native approval instead of leaving the model waiting for an async follow-up. (#82019) Thanks @Zac-W.
|
||||
- CLI/node: reject invalid explicit `node run --port` values instead of silently falling back to the configured or default port. Fixes #83923. Thanks @davinci282828.
|
||||
- CLI: reject explicit port numbers above 65535 before they reach Gateway or Node bind paths. Fixes #83900. (#84008) Thanks @hclsys.
|
||||
- Codex app-server: preserve plugin tool auth profiles when Codex owns model transport so OpenClaw dynamic tools can resolve their provider credentials. (#83603) Thanks @rubencu.
|
||||
- Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.
|
||||
- Memory Wiki: preserve fs-safe diagnostics when bridge source page writes fail for non-symlink filesystem safety reasons, so directory collisions are reported with the underlying error code. (#83776) Thanks @TurboTheTurtle.
|
||||
- Telegram: keep forum topics from blocking sibling topic traffic by routing inbound serialization, media/text buffers, and account API queues on topic-aware lanes. (#83829)
|
||||
- Telegram: keep queued forum-topic follow-up messages from inheriting superseded source abort signals, so later same-topic user turns can still run and reply after an active turn is replaced. (#83827) Thanks @VACInc.
|
||||
- CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.
|
||||
- CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.
|
||||
- Twitch: register refreshing chat tokens with Twurple's chat intent so automatic token refresh keeps chat access available. (#83750) Thanks @TurboTheTurtle.
|
||||
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
|
||||
- CLI/config: preserve numeric-looking record keys such as Discord guild IDs when creating missing config containers with `config set`. (#83769) Thanks @TurboTheTurtle.
|
||||
- Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.
|
||||
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
|
||||
- Providers/Anthropic: preserve Claude 4 image capability when configured model refs resolve through a stale local catalog row. (#83756) Thanks @TurboTheTurtle.
|
||||
- Providers/DeepSeek: normalize MCP tool schemas with `anyOf`/`oneOf` unions before normal and compaction requests reach DeepSeek, preventing union-shaped parameters from being rejected. (#83766) Thanks @TurboTheTurtle.
|
||||
- Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.
|
||||
- Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.
|
||||
- Discord: preserve streamed reply previews when recovered tool-warning finals are delivered before or after the assistant's final reply. (#84169) Thanks @neeravmakwana.
|
||||
- Control UI: keep the chat delete confirmation popover clamped inside the visible viewport on small screens. (#83804) Thanks @ThiagoCAltoe.
|
||||
- Browser: enforce current-tab URL allowlist checks for `/act` evaluate/batch actions and `/highlight` routes while leaving tab-management actions unblocked. (#78523)
|
||||
- CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)
|
||||
- Models: show the effective OpenAI/Codex auth profile in `/models` provider headers instead of falling back to the OpenAI env-key label. (#83697) Thanks @yu-xin-c.
|
||||
- CLI: include active bundled loopback MCP tools in CLI system prompts and reset provider-side CLI sessions when that prompt-visible tool surface changes. (#83785) Thanks @TurboTheTurtle.
|
||||
- Browser: keep a profile `cdpPort` when its `cdpUrl` omits a port, while still letting explicitly written URL ports win. (#82166) Thanks @Marvae.
|
||||
- Agents/image generation: allow distinct `image_generate` prompts to start separate session-backed background tasks while same-prompt retries still return the active task status. (#83614) Thanks @Elarwei001.
|
||||
- Gateway/WebChat: honor configured `channels.webchat.textChunkLimit` and `chunkMode` overrides when chunking WebChat replies. (#83713)
|
||||
- Control UI: stop the chat reading indicator from sticking after an assistant response finishes. (#83515) Thanks @njuboy11.
|
||||
- Skills: reject empty or whitespace-only skill names and descriptions during quick validation. (#27061)
|
||||
- Sessions: skip trailing custom transcript entries when checking tail assistant replies so embedded CLI gap-fill does not duplicate canonical assistant output. (#83635) Thanks @yaoyi1222.
|
||||
- Memory Wiki: keep `wiki_lint` tool output path-safe by reporting vault-internal lint reports as relative paths in tool text and details while preserving absolute report paths for CLI/file callers. (#83439) Thanks @LLagoon3.
|
||||
- Telegram: keep verbose tool progress visible without mirroring non-final progress into active session transcripts, preventing embedded provider replies from aborting mid-run. (#83631) Thanks @kurplunkin.
|
||||
- Telegram: log successful outbound text and media deliveries with account, chat, message, operation, thread, reply, silent, and chunk metadata while keeping message bodies out of logs. Fixes #83196. (#83247) Thanks @jrwrest.
|
||||
- Cron: link isolated scheduled task runs to their stable cron session so task status and cleanup can follow the backing agent run. (#83606) Thanks @jai.
|
||||
- Codex app-server: mark Codex-native subagent task mirrors terminal when blocked or failed spawn-agent calls arrive with stale initializing child state, preventing task registry entries from staying running. Fixes #83852. (#83945) Thanks @joshavant.
|
||||
- CLI: enforce the documented Node.js 22.19 runtime floor in the source launcher.
|
||||
- Release stability: repair broad-gate regressions in requester-agent completion handoff, QA-Lab mock spawn attribution, Slack monitor test isolation, plugin uninstall peer fixtures, and Node-floor launcher contract coverage.
|
||||
- Agents/replies: persist queued follow-up user messages and assistant error stubs only once across model-fallback retries, preventing repeated provider rejections from corrupted same-role session transcripts. Fixes #83404. (#83417) Thanks @yetval.
|
||||
- Telegram: preserve reply-target context for bare mention replies on runtime-only turns so the model sees the replied-to message body. Fixes #83767. (#83953) Thanks @joshavant.
|
||||
- ClawHub: preserve configured base URL path prefixes when building API request URLs, so self-hosted ClawHub instances mounted under a subpath keep routing correctly. (#83982) Thanks @ThiagoCAltoe.
|
||||
- Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430.
|
||||
- Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.
|
||||
- Doctor/Codex: warn when Linux host policy blocks the Codex bwrap user or network namespace path used by sandboxed app-server turns, with Ubuntu/AppArmor repair guidance. Refs #83018.
|
||||
- Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.
|
||||
- Gateway/sessions: clear stored CLI provider resume bindings on non-subagent `/reset` so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.
|
||||
- Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.
|
||||
- Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.
|
||||
- LM Studio: resolve env-template API keys like `${LMSTUDIO_API_KEY}` through the standard SecretInput path instead of sending the raw template as the bearer token, and preserve header-auth and discovery-key precedence when the template is unset. Fixes #80495. (#80568) Thanks @MonkeyLeeT.
|
||||
- Discord/subagents: route the initial reply from thread-bound delegated sessions into the bound Discord thread instead of the parent channel. Fixes #83170. (#83172) Thanks @100menotu001.
|
||||
- Gateway/sessions: rotate failed agent sessions when their transcript file is missing instead of wedging per-channel lanes. Fixes #83488. (#83553) Thanks @LLagoon3.
|
||||
- Agents: refresh final-delivery routing from fresh session state before declaring a no-send failure, keeping recovered runs on the normal durable delivery path. (#83835) Thanks @joshavant.
|
||||
- Agents: guard final-delivery fresh session routing against mismatched logical sessions before reusing recovered delivery context. (#83928) Thanks @joshavant.
|
||||
- Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.
|
||||
- Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.
|
||||
- Channels/bundled: append `openclaw doctor --fix` guidance to the bundled-channel load warnings emitted on `ERR_MODULE_NOT_FOUND` / `MODULE_NOT_FOUND` (including those wrapped on `.cause` by the native-require loader), so users hitting unstaged plugin runtime deps (e.g. `nostr-tools`) see an actionable repair hint instead of a bare module-not-found warning. (#76974) Thanks @BSG2000.
|
||||
- Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.
|
||||
- Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.
|
||||
- Telegram: keep `/btw` and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.
|
||||
- Agents: log strict-agentic execution contract diagnostics only when the planning-only retry path actually triggers.
|
||||
- Agents: stop embedded session takeover and session write-lock errors from consuming model fallbacks while preserving provider fallback metadata. Fixes #83510. Thanks @luyao618.
|
||||
- Agents/video: hide `video_generate` reference-audio parameters unless a registered video provider supports audio inputs.
|
||||
- Plugins: fall back to npm for official ClawHub updates when artifact downloads are unavailable, including beta-to-default fallback and dry-run version reporting.
|
||||
- Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.
|
||||
- Codex app-server: hydrate current inbound image attachments before queued runs so Responses-backed agents receive Discord and other channel images as native vision input. Fixes #83466. Thanks @iannwu.
|
||||
- Codex app-server: keep native code mode available without forcing code-mode-only so OpenClaw dynamic tool turns complete through the app-server tool bridge. Fixes #83109. Thanks @daswass.
|
||||
- Codex app-server: expose OpenClaw's sandbox-routed shell as `sandbox_exec`/`sandbox_process` for non-Docker sandbox backends so SSH sandbox agents keep a correctly routed shell path without shadowing Codex native shell. Fixes #80322. Thanks @keramblock.
|
||||
- Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.
|
||||
- Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.
|
||||
- Messages/Codex: keep Codex direct/source chats on message-tool visible delivery by default while documenting and testing `messages.visibleReplies: "automatic"` as the old-mode opt-out; channel wildcard model overrides now apply to direct chats before harness delivery defaults.
|
||||
- Memory/QMD: keep archived session transcript hits visible after QMD export while preserving normal `.md` session ids that only resemble archive names. (#83518; fixes #83506) Thanks @tanshanshan.
|
||||
- Codex app-server: preserve network access for sandboxed Codex code-mode turns when the OpenClaw sandbox allows outbound egress. Fixes #83347. Thanks @YusukeIt0.
|
||||
- Codex app-server: honor writable Docker bind mounts for sandboxed workspace-write turns while disabling native Code Mode when container-path aliases or read-only bind shadows cannot be represented safely host-side. Fixes #83737. (#83849) Thanks @joshavant.
|
||||
- QA-Lab: keep the OTLP smoke decoder independent of removed OpenTelemetry generated-root internals.
|
||||
- Messages: default group/channel visible replies to automatic final delivery again, keeping `message_tool` opt-in for ambient/shared rooms and tool-reliable models.
|
||||
- CLI/TUI: force standalone `/exit` runs to terminate after `runTui` returns so onboarding-launched TUI children do not stay alive invisibly. (#83501) Thanks @fuller-stack-dev.
|
||||
- Agents/code mode: honor per-agent code-mode config in schema, runtime catalog activation, and model payload filtering. Fixes #83388. Thanks @Kaspre.
|
||||
- Agents/code mode: preserve agent, session, run, and channel context in `before_tool_call` hooks for top-level `exec`/`wait` dispatches. Fixes #83387.
|
||||
- QQBot: shorten C2C typing indicators to a 10-second window renewed every 5 seconds, capped to keep a final passive-reply slot available. (#83469)
|
||||
- Replies: keep final payload delivery after live preview updates so channels can finalize or send the completed answer instead of losing preview-only drafts. (#83468)
|
||||
- Discord: deliver final replies in progress-mode preview streams instead of deduplicating the final visible message. (#83443) Thanks @compoodment.
|
||||
- Providers/Xiaomi: replay MiMo Anthropic-compatible `reasoning_content` as provider-required thinking blocks even when OpenClaw thinking is disabled, fixing follow-up tool turns for `mimo-v2-flash`. Fixes #83407. Thanks @Xgenious7.
|
||||
- Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi.
|
||||
- Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow `system.which` timeout warnings.
|
||||
- CLI/config: keep broken discovered plugins that are not referenced by active config from failing `openclaw config validate`, while preserving fatal errors for explicitly configured plugin entries.
|
||||
- GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with `invalid_request_body`. Fixes #83220. Thanks @galiniliev.
|
||||
- Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.
|
||||
- QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.
|
||||
- Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.
|
||||
- UI: show reasoning choices as plain labels instead of leaking internal override wording in session and chat pickers.
|
||||
- Mac app: avoid repeating the Configuration heading inside channel quick settings.
|
||||
- Mac app: keep the Settings sidebar always visible and remove the redundant titlebar hide/show control.
|
||||
- Mac app: normalize Settings pane content margins so pages share the same left and right rail.
|
||||
- Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.
|
||||
- Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.
|
||||
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected, and keep the Settings sidebar toggle in the leading titlebar area.
|
||||
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected.
|
||||
- Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.
|
||||
- Mac app: tighten remote Gateway fields in Settings so the Connection pane keeps readable labels and full action button text.
|
||||
- Mac app: keep custom Settings card rows left-aligned and full-width so Discovery and status sections no longer appear centered or detached.
|
||||
- Mac app: align Location permission controls to the same trailing column as the rest of Settings.
|
||||
- Mac app: add Dashboard, Chat, Canvas, and Settings shortcuts to the Dock icon menu.
|
||||
- Mac app: replace the Settings window's native split-view sidebar with an explicit layout so page content keeps its leading gutter when the sidebar is shown or hidden.
|
||||
- Mac app: render channel quick config as aligned Settings rows and hide schema-only variants that cannot be edited safely from the quick pane.
|
||||
- Gateway/webchat: hide internal runtime-context and other `display: false` transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.
|
||||
- CLI/help: keep `gateway`, `doctor`, `status`, and `health` help registration out of action/runtime imports so subcommand `--help` stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.
|
||||
- CLI/help: show plugin-owned command help based on the active memory slot so LanceDB memory users see `ltm` instead of unavailable `memory` commands. Fixes #83745. (#83841) Thanks @joshavant.
|
||||
- Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.
|
||||
- Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.
|
||||
- Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.
|
||||
- Telegram: fail topic sends closed when Telegram reports `message thread not found` instead of retrying without `message_thread_id` into the base chat. Refs #83302.
|
||||
- Config/subagents: remove ignored agent-model `timeoutMs` keys, keep subagent model config to primary/fallback selection, and clean shipped stale config through doctor. Fixes #83291. Thanks @giodl73-repo.
|
||||
- Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.
|
||||
- OpenAI/Codex: stop rejecting available `openai-codex` GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.
|
||||
- Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.
|
||||
- Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.
|
||||
- Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.
|
||||
- Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.
|
||||
- Codex app-server: keep OpenClaw session spawning searchable while steering Codex-native delegation through native subagents, avoiding duplicate direct subagent surfaces. (#83329) Thanks @fuller-stack-dev.
|
||||
- Codex app-server: recover stale childless Codex-native subagent task mirrors during maintenance and allow their registry rows to be cancelled without an OpenClaw child session. (#82836) Thanks @yshimadahrs-ship-it and @joshavant.
|
||||
- Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.
|
||||
- CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.
|
||||
- Plugins: apply a default 15-second timeout to legacy `before_agent_start` hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.
|
||||
@@ -66,10 +466,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.
|
||||
- Agents/OpenAI streams: yield via `setTimeout(0)` instead of `setImmediate` between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.
|
||||
- Agents/Codex: keep legacy `oauthRef`-backed OAuth profiles usable while `openclaw doctor --fix` migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.
|
||||
- Agents/Codex: load the selected provider owner alongside the Codex harness runtime so `openai-codex` models resolve when plugin allowlists scope runtime loading. Fixes #83380. (#83519) Thanks @joshavant.
|
||||
- Telegram: fail stalled isolated-ingress handlers into tombstones and abort same-lane reply work before restarting, so later same-chat updates drain after a hung turn. Fixes #83272. (#83505) Thanks @joshavant.
|
||||
- CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.
|
||||
- CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like `0.0.0.0`. Fixes #83286. Thanks @giodl73-repo.
|
||||
- CLI/plugins: ship the bundled memory CLI as a package entry so package-installed `openclaw memory` commands register correctly.
|
||||
- CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.
|
||||
- CLI/update: preserve old-parent-readable config metadata during legacy package handoffs, fall back only to official `@openclaw/*` npm plugin packages when ClawHub plugin artifacts are unavailable, and keep managed service package roots authoritative during updates.
|
||||
- Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.
|
||||
- Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.
|
||||
- CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.
|
||||
@@ -80,6 +483,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded `memory index` warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.
|
||||
- Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.
|
||||
- Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.
|
||||
- Gateway/plugins: bind admin HTTP RPC dispatch to the accepting gateway instance so multi-gateway processes cannot execute plugin HTTP control-plane calls against another live gateway. Fixes #83486. (#83487) Thanks @coygeek.
|
||||
- Telegram: keep hot-reload restarts from marking polling accounts manually stopped and restart isolated ingress cleanly after worker shutdown, preserving Telegram replies across config reloads. Fixes #83008. (#83410) Thanks @joshavant.
|
||||
- Telegram/Ollama: pass current Telegram image attachments into native PI/Ollama vision turns so live photo prompts reach Ollama as native images. Fixes #83023. (#83516) Thanks @joshavant.
|
||||
- Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.
|
||||
- Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.
|
||||
- Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.
|
||||
@@ -93,6 +499,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.
|
||||
- Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.
|
||||
- Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.
|
||||
- Browser/CDP: keep loopback proxy bypass active across both `NO_PROXY` casings and redact home-relative Chrome MCP profile paths in attach-failure diagnostics.
|
||||
- Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward `music_generate` audio creation instead of lyric-only replies, and reserve `lyrics` for exact sung words.
|
||||
- Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.
|
||||
- Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)
|
||||
@@ -108,9 +515,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/OpenAI: preserve deterministic tool payload ordering for prompt-cache reuse across OpenAI Responses and chat completions calls. (#82940) Thanks @galiniliev.
|
||||
- ACP/Codex: honor terminal ACP turn results so failed Codex/acpx runs are not recorded as successful after only progress text. Fixes #79522. Thanks @dudaefj.
|
||||
- Telegram: warn when a media group drops photos that fail to download, including albums where every photo is skipped. Fixes #55216. (#82987) Thanks @eldar702.
|
||||
- Agents/diagnostics: treat repeated same-handle embedded-run cleanup as idempotent while preserving true replacement-handle mismatch diagnostics. Fixes #82959. (#82960) Thanks @galiniliev.
|
||||
- Agents/subagents: preserve high-priority `AGENTS.md` policy in bootstrap context when oversized files are trimmed, and warn agents to read the full policy file before relying on scoped rules. Fixes #82920. (#82921) Thanks @galiniliev.
|
||||
- Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)
|
||||
- Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.
|
||||
- Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving `default` instead of silently dropping it.
|
||||
- Agents/CLI: reject empty successful CLI subprocess replies as `empty_response` and keep them out of shared auth-profile health, so blank Claude CLI results no longer become green no-payload turns. Fixes #83231. (#83421) Thanks @joshavant.
|
||||
- Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram `/verbose` stays visible when command events arrive only at completion.
|
||||
- Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.
|
||||
- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.
|
||||
@@ -123,6 +533,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.
|
||||
- Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.
|
||||
- Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.
|
||||
- Telegram: redact nested raw-update identifiers and user metadata before verbose raw update logging, preserving useful update/message ids without exposing chat, user, command, or profile details. (#82945) Thanks @galiniliev and @joshavant.
|
||||
- Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)
|
||||
- Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style `reasoning.enabled`/`max_tokens` controls for reasoning-capable OpenAI-completions models.
|
||||
- Agents/diagnostics: split slow embedded-run `attempt-dispatch` startup summaries into workspace, prompt, runtime-plan, and final dispatch subspans so traces identify the delayed setup phase. Fixes #82782. (#82783) Thanks @galiniliev.
|
||||
@@ -142,6 +553,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.
|
||||
- Agents/tools: keep the `message` tool available in embedded runs when it is explicitly allowed through `tools.alsoAllow` or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113.
|
||||
- WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.
|
||||
- WhatsApp: reject symlinked Web credential files across auth checks and socket startup so unsafe `creds.json` paths cannot be read through. Thanks @mcaxtr.
|
||||
- WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as `file.pdf` and `file.csv` instead of an extensionless `file`. Thanks @mcaxtr.
|
||||
- Process/diagnostics: report active lane blockers in lane wait warnings so `queueAhead=0` no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev.
|
||||
- Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings.
|
||||
@@ -152,6 +564,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.
|
||||
- Plugin SDK: bundle `openclaw/plugin-sdk/zod` into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local `zod` symlink. Fixes #78398. (#78515) Thanks @ggzeng.
|
||||
- Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.
|
||||
- Gateway/mobile: allow paired iOS and Android clients to refresh same-family OS metadata on authenticated reconnect instead of requiring a new approval. (#83490) Thanks @ngutman.
|
||||
- WhatsApp: treat `upload-file` as a supported media send intent by lowering path/URL uploads through the channel's normal send-media transport. (#81883) Thanks @ngutman.
|
||||
- iOS: end Live Activities when OpenClaw is connected, idle, or disconnected, and show compact attention states for approval-required reconnects. (#83597) Thanks @ngutman.
|
||||
- Control UI: hide child nav items when collapsing the active sidebar group. Fixes #42167. (#42223) Thanks @Aroool.
|
||||
- CI/proof: skip the real-behavior-proof gate for private org maintainers by minting a least-privilege (`members: read`) GitHub App token and checking active membership in the `maintainer` team, instead of treating `author_association=CONTRIBUTOR` as definitively external. (#83418) Thanks @romneyda.
|
||||
|
||||
## 2026.5.17
|
||||
|
||||
@@ -516,6 +933,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Require canonical node platform IDs [AI]. (#81880) Thanks @pgondhi987.
|
||||
- Agents/Azure OpenAI Responses: default unset Azure OpenAI API versions to `preview` so `/openai/v1/responses` calls use Azure's current Responses API route. (#82026) Thanks @leoge007.
|
||||
- Control UI/WebChat: compact the desktop chat header controls into a single aligned row so the session, model, thinking, and action controls no longer waste vertical space. Thanks @BunsDev.
|
||||
- Control UI/settings: widen the Personal quick-settings card to a 3/1 desktop split and keep Appearance/Automations below it on narrower layouts. Thanks @BunsDev.
|
||||
- Agents/model catalog: reuse manifest model-id normalization metadata while loading persisted read-only catalog rows, avoiding repeated metadata scans.
|
||||
- Agents: retry empty final turns for generic `anthropic-messages` providers instead of limiting non-visible recovery to Kimi, so custom/proxied Anthropic-compatible routes can recover with a visible answer. Addresses #46080. Thanks @wmgx, @w1tv, and @iFwu.
|
||||
- Agents/replies: strip workflow `<function_response>` scaffolding from user-visible sanitizer paths so raw tool output does not leak into chat history, transcript mirrors, or channel replies. Fixes #47444. Thanks @5toCode.
|
||||
@@ -650,7 +1068,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/plugins: route lazy plugin command-registration chatter to stderr only during JSON-output command registration, keeping plugin-backed `--json` stdout parseable without changing parse-only or pass-through `--json` behavior. Fixes #81535. (#81536) Thanks @ScientificProgrammer and @vincentkoc.
|
||||
- Plugins: treat git plugin install refs as refs instead of checkout flags, so option-like selectors fail checkout instead of silently installing the default branch. Fixes #79898. (#79901) Thanks @afurm and @vincentkoc.
|
||||
- Doctor/memory: stop warning that no memory plugin is active when an enabled alternate memory plugin explicitly owns the memory slot, while preserving the warning for missing or disabled slot entries. Fixes #78540. (#78557) Thanks @carladams1299-lab and @vincentkoc.
|
||||
- Plugins: keep process-local plugin metadata snapshot memo freshness tied to the cached registry snapshot so policy-stale derived plugin metadata edits invalidate the memo instead of returning stale owners or command aliases. (#81064) Thanks @Kaspre.
|
||||
- Plugins: keep derived plugin metadata snapshots uncached when the persisted registry is missing, disabled, or stale, so newly added plugins are discovered without restarting. (#81064) Thanks @Kaspre.
|
||||
- Plugins: discover provider plugins from `setup.providers[].envVars` credentials during provider discovery while keeping the deprecated `providerAuthEnvVars` fallback. (#81542) Thanks @JARVIS-Glasses.
|
||||
- Docs/Codex harness: clarify that per-agent `CODEX_HOME` isolates `~/.codex` while inherited `HOME` intentionally keeps `.agents` discovery and subprocess user-home state available.
|
||||
- CLI/plugins: keep bare plugin and parent-command help on the lightweight path, avoiding plugin registry discovery before rendering help.
|
||||
@@ -1559,6 +1977,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Dependencies: bump transitive `basic-ftp` to 5.3.1 so the runtime lockfile no longer includes the vulnerable 5.3.0 build flagged by the production dependency audit. (#78637) Thanks @sallyom.
|
||||
- Hooks/cron: log returned `/hooks/agent` isolated-run errors and failed cron jobs with cron diagnostic summaries, so rejected `payload.model` values are visible instead of looking like accepted-but-missing runs. Fixes #78597. (#78655) Thanks @kevinslin.
|
||||
- Managed proxy/security: classify raw socket callsites and proxy runtime mutations in boundary checks so new direct egress or unmanaged proxy-state changes cannot land without explicit review. (#77126) Thanks @jesse-merhi.
|
||||
- Memory indexing: propagate memory directory creation failures immediately instead of reporting an unusable directory as ready. Thanks @he-yufeng.
|
||||
- Channels/iMessage: surface the silent group-allowlist drop at default log level by emitting a one-time `warn` per account at monitor startup when `channels.imessage.groupPolicy: "allowlist"` is set without a `channels.imessage.groups` block, plus a one-time `warn` per `chat_id` when the runtime gate drops a specific group, naming the exact `channels.imessage.groups[...]` key to add to allow it. Fixes #78749. (#79190) Thanks @omarshahine.
|
||||
- WhatsApp: stop Gateway-originated outbound echoes from advancing inbound activity in `openclaw channels status`, so outbound self-sends no longer look like handled inbound messages. Fixes #79056. (#79057) Thanks @ai-hpc and @bittoby.
|
||||
- Gateway/nodes: preserve the live node registry session and invoke ownership when an older same-node WebSocket closes after reconnecting. (#78351) Thanks @samzong.
|
||||
@@ -2372,6 +2791,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- CLI/message: skip eager model context warmup and preserve channel-declared gateway execution for Discord and Telegram message actions, avoiding Codex app-server/model discovery during simple send/read commands. Thanks @fuller-stack-dev.
|
||||
- Agents/exec approvals: parse exec approval result metadata with balanced parentheses so nested-paren denial and finished payloads such as `Exec denied (gateway id=req-1, approval-timeout (allowlist-miss)): ...` are matched and routed to the denied followup branch instead of falling through to the generic followup path. (#72268) Thanks @amittell.
|
||||
- Codex/app-server: resolve managed binaries from bundled `dist` chunks and from the `@openai/codex` package bin when installs do not provide a nearby `.bin/codex` shim, avoiding false missing-binary startup failures.
|
||||
- Plugins/ClawHub: use the ClawHub artifact resolver response as the install decision before downloading, keeping legacy ZIP fallback and future ClawPack npm-pack installs on the same explicit resolver path. Thanks @vincentkoc.
|
||||
- Plugins/ClawHub: keep bare plugin package specs on npm for the launch cutover and reserve ClawHub resolution for explicit `clawhub:` specs until ClawHub pack readiness is deployed. Thanks @vincentkoc.
|
||||
@@ -3457,6 +3877,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as `tsserver` do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby.
|
||||
- Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830.
|
||||
- Logging: write validated diagnostic trace context as top-level `traceId`, `spanId`, `parentSpanId`, and `traceFlags` fields in file-log JSONL records so traced requests and model calls are easier to correlate in log processors. Refs #40353. Thanks @liangruochong44-ui.
|
||||
- Nextcloud-Talk: wire the existing reaction sender into the channel `actions` adapter so agents can react to messages via the shared `message` tool, instead of advertising the `reactions` capability without a dispatch path. Fixes #70110. Thanks @powerpaul17.
|
||||
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
|
||||
- Providers/Ollama: honor `/api/show` capabilities when registering local models so non-tool Ollama models no longer receive the agent tool surface, and keep native Ollama thinking opt-in instead of enabling it by default. Fixes #64710 and duplicate #65343. Thanks @yuan-b, @netherby, @xilopaint, and @Diyforfun2026.
|
||||
- Control UI/Agents: remount the Overview model controls when switching agents so the primary-model picker cannot retain stale per-agent selection. Fixes #39392; carries forward #39401, notes the duplicate #39495 approach, and keeps #46275/#54724 broader stabilization out of scope. Thanks @daijunyi002, @SergioChan, @aworki, and @wsyjh8.
|
||||
@@ -4607,7 +5028,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/SDK retry: cap long `Retry-After` sleeps in Stainless-based Anthropic/OpenAI model SDKs so 60s+ retry windows surface immediately for OpenClaw failover instead of blocking the run. (#68474) Thanks @jetd1.
|
||||
- Agents/TTS: preserve spoken text in TTS tool results while defusing reply directives in transcript content, so future turns remember voice replies without treating spoken `MEDIA:` or voice tags as delivery metadata. (#68869) Thanks @zqchris.
|
||||
- Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT.
|
||||
- Agents/Pi embedded runs: suppress the "âš ï¸ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (`stopReason=stop`). Real failure modes (tool errors, provider `stopReason=error`, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana.
|
||||
- Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (`stopReason=stop`). Real failure modes (tool errors, provider `stopReason=error`, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana.
|
||||
- Auto-reply/WebChat: preserve the active session mapping when context-overflow recovery or auto-compaction fails, and return retry, `/compact`, and `/new` guidance instead of silently rotating to a fresh session. Fixes #70472. (#70479) Thanks @fuller-stack-dev.
|
||||
- Gateway/Linux: wrap gateway-managed supervisor, PTY, MCP stdio, and browser child processes in a tiny `/bin/sh` shim that raises the child's own `oom_score_adj` on Linux, so under cgroup memory pressure the kernel prefers transient workers over the long-lived gateway. Opt out with `OPENCLAW_CHILD_OOM_SCORE_ADJ=0`. Fixes #70404. (#70419) Thanks @neeravmakwana.
|
||||
- Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.<name>:<index>`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.
|
||||
- Dependencies/security: override transitive `uuid` to `14.0.0`, clearing the runtime advisory across dependencies.
|
||||
@@ -5099,6 +5521,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/skills: require unique case-insensitive fallback matches in `openclaw skills info` so case-only collisions return not-found instead of showing guidance for the wrong skill. (#38713)
|
||||
- Agents/Ollama: forward the configured embedded-run timeout into the global undici stream timeout tuning so slow local Ollama runs no longer inherit the default stream cutoff instead of the operator-set run timeout. (#63175) Thanks @mindcraftreader and @vincentkoc.
|
||||
- Models/Codex: include `apiKey` in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in `models.json`. (#66180) Thanks @hoyyeva.
|
||||
- Tools/image+pdf: normalize configured provider/model refs before media-tool registry lookup so image and PDF tool runs stop rejecting valid Ollama vision models as unknown just because the tool path skipped the usual model-ref normalization step. (#59943) Thanks @yqli2420 and @vincentkoc.
|
||||
|
||||
44
Dockerfile
44
Dockerfile
@@ -116,19 +116,22 @@ ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
|
||||
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
# Prune dev dependencies, omitted plugin runtime packages, and build-only
|
||||
# metadata before copying runtime assets into the final image.
|
||||
FROM build AS runtime-assets
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
# BuildKit cache mounts are not part of cached layers; seed tarballs for the
|
||||
# installed prod graph in the same step that runs offline prune.
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm list --prod --depth Infinity --json | node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
|
||||
CI=true pnpm prune --prod \
|
||||
--config.offline=true \
|
||||
--config.supportedArchitectures.os=linux \
|
||||
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
|
||||
--config.supportedArchitectures.libc=glibc && \
|
||||
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
|
||||
node scripts/postinstall-bundled-plugins.mjs && \
|
||||
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
|
||||
node scripts/check-package-dist-imports.mjs /app
|
||||
|
||||
@@ -198,13 +201,29 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \
|
||||
chmod -R a+rX "$COREPACK_HOME"
|
||||
|
||||
# Install additional system packages needed by your skills or extensions.
|
||||
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
|
||||
# Example: docker build --build-arg OPENCLAW_IMAGE_APT_PACKAGES="python3 wget" .
|
||||
# Legacy alias: OPENCLAW_DOCKER_APT_PACKAGES is still accepted as a fallback.
|
||||
ARG OPENCLAW_IMAGE_APT_PACKAGES
|
||||
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
packages="${OPENCLAW_IMAGE_APT_PACKAGES-$OPENCLAW_DOCKER_APT_PACKAGES}"; \
|
||||
if [ -n "$packages" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $packages; \
|
||||
fi
|
||||
|
||||
# Install additional Python packages needed by your plugins or skills.
|
||||
# Example: docker build --build-arg OPENCLAW_IMAGE_PIP_PACKAGES="requests humanize" .
|
||||
ARG OPENCLAW_IMAGE_PIP_PACKAGES=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_IMAGE_PIP_PACKAGES" ]; then \
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-pip; \
|
||||
fi && \
|
||||
python3 -m pip install --no-cache-dir --break-system-packages $OPENCLAW_IMAGE_PIP_PACKAGES; \
|
||||
fi
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
@@ -266,10 +285,15 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
|
||||
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
|
||||
&& chmod 755 /app/openclaw.mjs
|
||||
|
||||
# Pre-create the default state dir so first-run Docker named volumes mounted
|
||||
# here inherit node ownership instead of root-owned state.
|
||||
RUN install -d -m 0700 -o node -g node /home/node/.openclaw && \
|
||||
stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700'
|
||||
# Pre-create default named-volume mount points so first-run Docker volumes copy
|
||||
# node ownership from the image instead of starting as root-owned directories.
|
||||
RUN install -d -m 0700 -o node -g node \
|
||||
/home/node/.openclaw \
|
||||
/home/node/.openclaw/workspace \
|
||||
/home/node/.config/openclaw && \
|
||||
stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700' && \
|
||||
stat -c '%U:%G %a' /home/node/.openclaw/workspace | grep -qx 'node:node 700' && \
|
||||
stat -c '%U:%G %a' /home/node/.config/openclaw | grep -qx 'node:node 700'
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Peter Steinberger
|
||||
Copyright (c) 2026 OpenClaw Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -96,7 +96,7 @@ Model note: while many providers and models are supported, prefer a current flag
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.19+**.
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
@@ -109,7 +109,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.19+**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
|
||||
|
||||
### Node.js Version
|
||||
|
||||
OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes important security patches:
|
||||
OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended default runtime for new installs. The minimum version includes important security patches:
|
||||
|
||||
- CVE-2025-59466: async_hooks DoS vulnerability
|
||||
- CVE-2026-21636: Permission model bypass vulnerability
|
||||
@@ -320,7 +320,7 @@ OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes impo
|
||||
Verify your Node.js version:
|
||||
|
||||
```bash
|
||||
node --version # Should be v22.16.0 or later
|
||||
node --version # Should be v22.19.0 or later
|
||||
```
|
||||
|
||||
### Docker Security
|
||||
|
||||
1481
appcast.xml
1481
appcast.xml
File diff suppressed because it is too large
Load Diff
@@ -209,15 +209,16 @@ Why these matter:
|
||||
|
||||
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
|
||||
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
|
||||
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
|
||||
- The Play build removes these behind the `play` flavor.
|
||||
- Photo library access is also removed from the Play build. Use third-party builds for `photos.latest`.
|
||||
|
||||
Current OpenClaw Android implication:
|
||||
|
||||
- APK / sideload build can keep SMS and Call Log features.
|
||||
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
|
||||
- APK / sideload build can keep SMS, Call Log, and recent-photo features.
|
||||
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
|
||||
- The repo now ships this split as Android product flavors:
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
|
||||
|
||||
Policy links:
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026051700
|
||||
versionName = "2026.5.17"
|
||||
versionCode = 2026052100
|
||||
versionName = "2026.5.21"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -5,6 +5,7 @@ import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
|
||||
import ai.openclaw.app.node.CameraCaptureManager
|
||||
import ai.openclaw.app.node.CanvasController
|
||||
import ai.openclaw.app.node.SmsManager
|
||||
@@ -81,6 +82,40 @@ class MainViewModel(
|
||||
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
|
||||
val gatewayUpdateAvailable: StateFlow<GatewayUpdateAvailableSummary?> = runtimeState(initial = null) { it.gatewayUpdateAvailable }
|
||||
val modelCatalog: StateFlow<List<GatewayModelSummary>> = runtimeState(initial = emptyList()) { it.modelCatalog }
|
||||
val modelAuthProviders: StateFlow<List<GatewayModelProviderSummary>> = runtimeState(initial = emptyList()) { it.modelAuthProviders }
|
||||
val modelCatalogRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.modelCatalogRefreshing }
|
||||
val modelCatalogErrorText: StateFlow<String?> = runtimeState(initial = null) { it.modelCatalogErrorText }
|
||||
val gatewayDefaultAgentId: StateFlow<String?> = runtimeState(initial = null) { it.gatewayDefaultAgentId }
|
||||
val gatewayAgents: StateFlow<List<GatewayAgentSummary>> = runtimeState(initial = emptyList()) { it.gatewayAgents }
|
||||
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
|
||||
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
|
||||
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
|
||||
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
|
||||
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
|
||||
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
|
||||
val usageErrorText: StateFlow<String?> = runtimeState(initial = null) { it.usageErrorText }
|
||||
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
|
||||
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
|
||||
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
|
||||
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> =
|
||||
runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary }
|
||||
val nodesDevicesRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.nodesDevicesRefreshing }
|
||||
val nodesDevicesErrorText: StateFlow<String?> = runtimeState(initial = null) { it.nodesDevicesErrorText }
|
||||
val channelsSummary: StateFlow<GatewayChannelsSummary> =
|
||||
runtimeState(initial = GatewayChannelsSummary(channels = emptyList())) { it.channelsSummary }
|
||||
val channelsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.channelsRefreshing }
|
||||
val channelsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.channelsErrorText }
|
||||
val dreamingSummary: StateFlow<GatewayDreamingSummary> =
|
||||
runtimeState(initial = GatewayDreamingSummary()) { it.dreamingSummary }
|
||||
val dreamingRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.dreamingRefreshing }
|
||||
val dreamingErrorText: StateFlow<String?> = runtimeState(initial = null) { it.dreamingErrorText }
|
||||
val healthLogsSummary: StateFlow<GatewayHealthLogsSummary> =
|
||||
runtimeState(initial = GatewayHealthLogsSummary()) { it.healthLogsSummary }
|
||||
val healthLogsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.healthLogsRefreshing }
|
||||
val healthLogsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.healthLogsErrorText }
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
|
||||
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
|
||||
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
|
||||
@@ -118,6 +153,8 @@ class MainViewModel(
|
||||
val talkModeListening: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeListening }
|
||||
val talkModeSpeaking: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeSpeaking }
|
||||
val talkModeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.talkModeStatusText }
|
||||
val talkModeConversation: StateFlow<List<VoiceConversationEntry>> =
|
||||
runtimeState(initial = emptyList()) { it.talkModeConversation }
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
|
||||
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
|
||||
@@ -291,6 +328,10 @@ class MainViewModel(
|
||||
ensureRuntime().setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun cancelMicCapture() {
|
||||
ensureRuntime().cancelMicCapture()
|
||||
}
|
||||
|
||||
fun setTalkModeEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setTalkModeEnabled(enabled)
|
||||
}
|
||||
@@ -353,6 +394,42 @@ class MainViewModel(
|
||||
ensureRuntime().refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
|
||||
fun refreshModelCatalog() {
|
||||
ensureRuntime().refreshModelCatalog()
|
||||
}
|
||||
|
||||
fun refreshAgents() {
|
||||
ensureRuntime().refreshAgents()
|
||||
}
|
||||
|
||||
fun refreshCronJobs() {
|
||||
ensureRuntime().refreshCronJobs()
|
||||
}
|
||||
|
||||
fun refreshUsage() {
|
||||
ensureRuntime().refreshUsage()
|
||||
}
|
||||
|
||||
fun refreshSkills() {
|
||||
ensureRuntime().refreshSkills()
|
||||
}
|
||||
|
||||
fun refreshNodesDevices() {
|
||||
ensureRuntime().refreshNodesDevices()
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
ensureRuntime().refreshChannels()
|
||||
}
|
||||
|
||||
fun refreshDreaming() {
|
||||
ensureRuntime().refreshDreaming()
|
||||
}
|
||||
|
||||
fun refreshHealthLogs() {
|
||||
ensureRuntime().refreshHealthLogs()
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
ensureRuntime().loadChat(sessionKey)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,80 +17,148 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PermissionRequester(
|
||||
class PermissionRequester internal constructor(
|
||||
private val activity: ComponentActivity,
|
||||
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private data class PendingPermissionRequest(
|
||||
val deferred: CompletableDeferred<Map<String, Boolean>>,
|
||||
var timedOut: Boolean = false,
|
||||
)
|
||||
|
||||
private val launcher: ActivityResultLauncher<Array<String>> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
val p = pending
|
||||
pending = null
|
||||
p?.complete(result)
|
||||
}
|
||||
private class PermissionRequestSlot(
|
||||
val launcher: ActivityResultLauncher<Array<String>>,
|
||||
var request: PendingPermissionRequest? = null,
|
||||
)
|
||||
|
||||
constructor(activity: ComponentActivity) : this(
|
||||
activity = activity,
|
||||
launcherFactory = { callback ->
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), callback)
|
||||
},
|
||||
)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val requestSlotsLock = Any()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
|
||||
|
||||
suspend fun requestIfMissing(
|
||||
permissions: List<String>,
|
||||
timeoutMs: Long = 20_000,
|
||||
): Map<String, Boolean> =
|
||||
mutex.withLock {
|
||||
val missing =
|
||||
permissions.filter { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||
): Map<String, Boolean> {
|
||||
return mutex.withLock {
|
||||
while (true) {
|
||||
val missing =
|
||||
permissions.filter { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return permissions.associateWith { true }
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return permissions.associateWith { true }
|
||||
}
|
||||
|
||||
val needsRationale =
|
||||
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||
if (needsRationale) {
|
||||
val proceed = showRationaleDialog(missing)
|
||||
if (!proceed) {
|
||||
return permissions.associateWith { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
val needsRationale =
|
||||
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||
if (needsRationale) {
|
||||
val proceed = showRationaleDialog(missing)
|
||||
if (!proceed) {
|
||||
return permissions.associateWith { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||
pending = deferred
|
||||
withContext(Dispatchers.Main) {
|
||||
launcher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
val result =
|
||||
withContext(Dispatchers.Default) {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||
val request = PendingPermissionRequest(deferred)
|
||||
val slot = reservePermissionRequestSlot(request)
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
slot.launcher.launch(missing.toTypedArray())
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clearPermissionRequestSlot(slot, request)
|
||||
throw err
|
||||
}
|
||||
|
||||
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
||||
val merged =
|
||||
permissions.associateWith { perm ->
|
||||
val nowGranted =
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
result[perm] == true || nowGranted
|
||||
val result =
|
||||
try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
request.timedOut = true
|
||||
throw err
|
||||
}
|
||||
|
||||
val merged =
|
||||
permissions.associateWith { perm ->
|
||||
val nowGranted =
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
result[perm] == true || nowGranted
|
||||
}
|
||||
|
||||
val denied =
|
||||
merged.filterValues { !it }.keys.filter {
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||
}
|
||||
if (denied.isNotEmpty()) {
|
||||
showSettingsDialog(denied)
|
||||
}
|
||||
|
||||
val denied =
|
||||
merged.filterValues { !it }.keys.filter {
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||
}
|
||||
if (denied.isNotEmpty()) {
|
||||
showSettingsDialog(denied)
|
||||
return merged
|
||||
}
|
||||
|
||||
return merged
|
||||
error("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPermissionRequestSlot(
|
||||
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
|
||||
): PermissionRequestSlot {
|
||||
var slot: PermissionRequestSlot? = null
|
||||
val launcher = launcherFactory { result -> completePermissionRequest(checkNotNull(slot), result) }
|
||||
val created = PermissionRequestSlot(launcher)
|
||||
slot = created
|
||||
return created
|
||||
}
|
||||
|
||||
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
|
||||
synchronized(requestSlotsLock) {
|
||||
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
|
||||
slot.request = request
|
||||
slot
|
||||
}
|
||||
|
||||
private fun completePermissionRequest(
|
||||
slot: PermissionRequestSlot,
|
||||
result: Map<String, Boolean>,
|
||||
) {
|
||||
val request =
|
||||
synchronized(requestSlotsLock) {
|
||||
slot.request.also {
|
||||
slot.request = null
|
||||
}
|
||||
} ?: return
|
||||
if (request.timedOut) return
|
||||
request.deferred.complete(result)
|
||||
}
|
||||
|
||||
private fun clearPermissionRequestSlot(
|
||||
slot: PermissionRequestSlot,
|
||||
request: PendingPermissionRequest,
|
||||
) {
|
||||
synchronized(requestSlotsLock) {
|
||||
if (slot.request === request) {
|
||||
slot.request = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@@ -58,6 +58,7 @@ class ChatController(
|
||||
|
||||
private val pendingRuns = mutableSetOf<String>()
|
||||
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
|
||||
private val pendingRunTimeoutMs = 120_000L
|
||||
|
||||
private var lastHealthPollAtMs: Long? = null
|
||||
@@ -76,6 +77,7 @@ class ChatController(
|
||||
fun load(sessionKey: String) {
|
||||
val key = normalizeRequestedSessionKey(sessionKey)
|
||||
_sessionKey.value = key
|
||||
optimisticMessagesByRunId.clear()
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||
}
|
||||
|
||||
@@ -113,6 +115,7 @@ class ChatController(
|
||||
if (key.isEmpty()) return
|
||||
if (key == _sessionKey.value) return
|
||||
_sessionKey.value = key
|
||||
optimisticMessagesByRunId.clear()
|
||||
// Keep the thread switch path lean: history + health are needed immediately,
|
||||
// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
|
||||
@@ -171,14 +174,15 @@ class ChatController(
|
||||
)
|
||||
}
|
||||
}
|
||||
_messages.value =
|
||||
_messages.value +
|
||||
val optimisticMessage =
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = "user",
|
||||
content = userContent,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
)
|
||||
optimisticMessagesByRunId[runId] = optimisticMessage
|
||||
_messages.value = _messages.value + optimisticMessage
|
||||
|
||||
armPendingRunTimeout(runId)
|
||||
synchronized(pendingRuns) {
|
||||
@@ -218,6 +222,7 @@ class ChatController(
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
|
||||
clearPendingRun(runId)
|
||||
armPendingRunTimeout(actualRunId)
|
||||
synchronized(pendingRuns) {
|
||||
@@ -228,6 +233,7 @@ class ChatController(
|
||||
true
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
_errorText.value = err.message
|
||||
false
|
||||
}
|
||||
@@ -302,7 +308,7 @@ class ChatController(
|
||||
|
||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
|
||||
_messages.value = history.messages
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
@@ -369,7 +375,13 @@ class ChatController(
|
||||
if (state == "error") {
|
||||
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||
}
|
||||
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
|
||||
if (runId != null) {
|
||||
clearPendingRun(runId)
|
||||
optimisticMessagesByRunId.remove(runId)
|
||||
} else {
|
||||
clearPendingRuns()
|
||||
optimisticMessagesByRunId.clear()
|
||||
}
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
@@ -378,7 +390,7 @@ class ChatController(
|
||||
val historyJson =
|
||||
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
|
||||
_messages.value = history.messages
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
@@ -471,6 +483,7 @@ class ChatController(
|
||||
}
|
||||
if (!stillPending) return@launch
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
_errorText.value = "Timed out waiting for a reply; try again or refresh."
|
||||
}
|
||||
}
|
||||
@@ -488,12 +501,18 @@ class ChatController(
|
||||
job.cancel()
|
||||
}
|
||||
pendingRunTimeoutJobs.clear()
|
||||
optimisticMessagesByRunId.clear()
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.clear()
|
||||
_pendingRunCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeOptimisticMessage(runId: String) {
|
||||
val message = optimisticMessagesByRunId.remove(runId) ?: return
|
||||
_messages.value = _messages.value.filterNot { it.id == message.id }
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
@@ -620,11 +639,54 @@ internal fun reconcileMessageIds(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun mergeOptimisticMessages(
|
||||
incoming: List<ChatMessage>,
|
||||
optimistic: Collection<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
if (optimistic.isEmpty()) return incoming
|
||||
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
val missingOptimistic =
|
||||
optimistic.filter { message ->
|
||||
val matchIndex =
|
||||
unmatchedIncoming.indexOfFirst { incomingMessage ->
|
||||
incomingMessageConsumesOptimistic(incomingMessage, message)
|
||||
}
|
||||
if (matchIndex >= 0) {
|
||||
unmatchedIncoming.removeAt(matchIndex)
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
if (missingOptimistic.isEmpty()) return incoming
|
||||
|
||||
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
|
||||
}
|
||||
|
||||
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
val contentKey = messageContentIdentityKey(message) ?: return null
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
|
||||
return listOf(contentKey, timestamp).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun optimisticMessageIdentityKey(message: ChatMessage): String? = messageContentIdentityKey(message)
|
||||
|
||||
private fun incomingMessageConsumesOptimistic(
|
||||
incoming: ChatMessage,
|
||||
optimistic: ChatMessage,
|
||||
): Boolean {
|
||||
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
|
||||
val incomingTimestamp = incoming.timestampMs ?: return false
|
||||
val optimisticTimestamp = optimistic.timestampMs ?: return true
|
||||
return incomingTimestamp >= optimisticTimestamp
|
||||
}
|
||||
|
||||
private fun messageContentIdentityKey(message: ChatMessage): String? {
|
||||
val role = message.role.trim().lowercase()
|
||||
if (role.isEmpty()) return null
|
||||
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
val contentFingerprint =
|
||||
message.content.joinToString(separator = "\u001E") { part ->
|
||||
listOf(
|
||||
@@ -642,8 +704,7 @@ internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
|
||||
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
|
||||
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
|
||||
return listOf(role, contentFingerprint).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -68,6 +70,20 @@ data class GatewayConnectErrorDetails(
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
data class GatewayHelloSummary(
|
||||
val serverName: String?,
|
||||
val remoteAddress: String?,
|
||||
val serverVersion: String?,
|
||||
val mainSessionKey: String?,
|
||||
val updateAvailable: GatewayUpdateAvailableSummary?,
|
||||
)
|
||||
|
||||
data class GatewayUpdateAvailableSummary(
|
||||
val currentVersion: String?,
|
||||
val latestVersion: String?,
|
||||
val channel: String?,
|
||||
)
|
||||
|
||||
private data class SelectedConnectAuth(
|
||||
val authToken: String?,
|
||||
val authBootstrapToken: String?,
|
||||
@@ -86,7 +102,7 @@ class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onConnected: (GatewayHelloSummary) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
|
||||
@@ -149,7 +165,10 @@ class GatewaySession(
|
||||
val tls: GatewayTlsParams?,
|
||||
)
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private val lifecycleLock = Any()
|
||||
|
||||
@Volatile private var desired: DesiredConnection? = null
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
@Volatile private var currentConnection: Connection? = null
|
||||
@@ -168,26 +187,39 @@ class GatewaySession(
|
||||
options: GatewayConnectOptions,
|
||||
tls: GatewayTlsParams? = null,
|
||||
) {
|
||||
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
val connectionToClose: Connection?
|
||||
synchronized(lifecycleLock) {
|
||||
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
connectionToClose = currentConnection
|
||||
if (job?.isActive != true) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
}
|
||||
connectionToClose?.closeQuietly()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
desired = null
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
currentConnection?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
val jobToCancel: Job?
|
||||
val connectionToClose: Connection?
|
||||
synchronized(lifecycleLock) {
|
||||
desired = null
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
connectionToClose = currentConnection
|
||||
jobToCancel = job
|
||||
job = null
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
}
|
||||
connectionToClose?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
jobToCancel?.cancelAndJoin()
|
||||
if (desired == null) {
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
}
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
@@ -316,6 +348,22 @@ class GatewaySession(
|
||||
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
|
||||
}
|
||||
|
||||
suspend fun sendRequestFrame(
|
||||
method: String,
|
||||
paramsJson: String?,
|
||||
timeoutMs: Long = 15_000,
|
||||
onError: (ErrorShape) -> Unit = {},
|
||||
) {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val params =
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
json.parseToJsonElement(paramsJson)
|
||||
}
|
||||
conn.sendRequestFrame(method = method, params = params, timeoutMs = timeoutMs, onError = onError)
|
||||
}
|
||||
|
||||
private data class RpcResponse(
|
||||
val id: String,
|
||||
val ok: Boolean,
|
||||
@@ -338,6 +386,22 @@ class GatewaySession(
|
||||
private val client: OkHttpClient = buildClient()
|
||||
private var socket: WebSocket? = null
|
||||
private val loggerTag = "OpenClawGateway"
|
||||
private val incomingMessages = Channel<String>(Channel.UNLIMITED)
|
||||
private val messagePumpJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
for (text in incomingMessages) {
|
||||
try {
|
||||
handleMessage(text)
|
||||
} catch (err: CancellationException) {
|
||||
throw err
|
||||
} catch (err: Throwable) {
|
||||
Log.w(
|
||||
loggerTag,
|
||||
"gateway message handling failed: ${err.message ?: err::class.java.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val remoteAddress: String = formatGatewayAuthority(endpoint.host, endpoint.port)
|
||||
|
||||
@@ -360,14 +424,12 @@ class GatewaySession(
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
val frame =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (params != null) put("params", params)
|
||||
}
|
||||
sendJson(frame)
|
||||
try {
|
||||
sendJson(buildRequestFrame(id = id, method = method, params = params))
|
||||
} catch (err: Throwable) {
|
||||
pending.remove(id)
|
||||
throw err
|
||||
}
|
||||
return try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
@@ -376,17 +438,66 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendRequestFrame(
|
||||
method: String,
|
||||
params: JsonElement?,
|
||||
timeoutMs: Long,
|
||||
onError: (ErrorShape) -> Unit,
|
||||
) {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
try {
|
||||
sendJson(buildRequestFrame(id = id, method = method, params = params))
|
||||
} catch (err: Throwable) {
|
||||
pending.remove(id)
|
||||
throw err
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val response =
|
||||
try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pending.remove(id)
|
||||
onError(ErrorShape("UNAVAILABLE", "request timeout"))
|
||||
return@launch
|
||||
}
|
||||
if (!response.ok) {
|
||||
onError(response.error ?: ErrorShape("UNAVAILABLE", "request failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendJson(obj: JsonObject) {
|
||||
val jsonString = obj.toString()
|
||||
writeLock.withLock {
|
||||
socket?.send(jsonString)
|
||||
if (socket?.send(jsonString) != true) {
|
||||
throw IllegalStateException("gateway send failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRequestFrame(
|
||||
id: String,
|
||||
method: String,
|
||||
params: JsonElement?,
|
||||
): JsonObject =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (params != null) put("params", params)
|
||||
}
|
||||
|
||||
suspend fun awaitClose() = closedDeferred.await()
|
||||
|
||||
fun closeQuietly() {
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
incomingMessages.close()
|
||||
messagePumpJob.cancel()
|
||||
if (!connectDeferred.isCompleted) {
|
||||
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed"))
|
||||
}
|
||||
socket?.close(1000, "bye")
|
||||
socket = null
|
||||
closedDeferred.complete(Unit)
|
||||
@@ -431,7 +542,7 @@ class GatewaySession(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
scope.launch { handleMessage(text) }
|
||||
incomingMessages.trySend(text)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
@@ -443,6 +554,7 @@ class GatewaySession(
|
||||
connectDeferred.completeExceptionally(t)
|
||||
}
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
incomingMessages.close()
|
||||
failPending()
|
||||
closedDeferred.complete(Unit)
|
||||
onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}")
|
||||
@@ -458,6 +570,7 @@ class GatewaySession(
|
||||
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason"))
|
||||
}
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
incomingMessages.close()
|
||||
failPending()
|
||||
closedDeferred.complete(Unit)
|
||||
onDisconnected("Gateway closed: $reason")
|
||||
@@ -530,8 +643,8 @@ class GatewaySession(
|
||||
val allowedOperatorScopes =
|
||||
setOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
)
|
||||
scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted()
|
||||
@@ -574,7 +687,9 @@ class GatewaySession(
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
||||
val server = obj["server"].asObjectOrNull()
|
||||
val serverName = server?.get("host").asStringOrNull()
|
||||
val serverVersion = server?.get("version").asStringOrNull()
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
@@ -612,13 +727,33 @@ class GatewaySession(
|
||||
?.let { normalized -> surface to normalized }
|
||||
} ?: emptyList()
|
||||
pluginSurfaceUrls = normalizedPluginSurfaceUrls.toMap()
|
||||
val snapshot = obj["snapshot"].asObjectOrNull()
|
||||
val sessionDefaults =
|
||||
obj["snapshot"]
|
||||
.asObjectOrNull()
|
||||
snapshot
|
||||
?.get("sessionDefaults")
|
||||
.asObjectOrNull()
|
||||
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
|
||||
onConnected(serverName, remoteAddress, mainSessionKey)
|
||||
onConnected(
|
||||
GatewayHelloSummary(
|
||||
serverName = serverName,
|
||||
remoteAddress = remoteAddress,
|
||||
serverVersion = serverVersion,
|
||||
mainSessionKey = mainSessionKey,
|
||||
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseUpdateAvailable(value: JsonObject?): GatewayUpdateAvailableSummary? {
|
||||
if (value == null) return null
|
||||
val latestVersion = value["latestVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val currentVersion = value["currentVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val channel = value["channel"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return GatewayUpdateAvailableSummary(
|
||||
currentVersion = currentVersion,
|
||||
latestVersion = latestVersion,
|
||||
channel = channel,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildConnectParams(
|
||||
@@ -905,9 +1040,11 @@ class GatewaySession(
|
||||
conn.connect()
|
||||
conn.awaitClose()
|
||||
} finally {
|
||||
currentConnection = null
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
if (currentConnection === conn) {
|
||||
currentConnection = null
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,8 +1227,10 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
|
||||
role?.trim() == "node" &&
|
||||
scopes.isEmpty() &&
|
||||
error.details.reason == "not-paired" &&
|
||||
(error.details.pauseReconnect == false ||
|
||||
error.details.recommendedNextStep == "wait_then_retry")
|
||||
(
|
||||
error.details.pauseReconnect == false ||
|
||||
error.details.recommendedNextStep == "wait_then_retry"
|
||||
)
|
||||
)
|
||||
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
|
||||
else -> false
|
||||
|
||||
@@ -22,6 +22,7 @@ class ConnectionManager(
|
||||
private val readSmsAvailable: () -> Boolean,
|
||||
private val smsSearchPossible: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
@@ -96,6 +97,7 @@ class ConnectionManager(
|
||||
readSmsAvailable = readSmsAvailable(),
|
||||
smsSearchPossible = smsSearchPossible(),
|
||||
callLogAvailable = callLogAvailable(),
|
||||
photosAvailable = photosAvailable(),
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
@@ -160,7 +162,15 @@ class ConnectionManager(
|
||||
fun buildOperatorConnectOptions(): GatewayConnectOptions =
|
||||
GatewayConnectOptions(
|
||||
role = "operator",
|
||||
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
|
||||
// QR bootstrap hands Android a bounded operator token that includes approvals; keep the
|
||||
// default operator reconnect request aligned so the post-bootstrap loop can approve work.
|
||||
scopes =
|
||||
listOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
|
||||
@@ -28,6 +28,7 @@ class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
) {
|
||||
companion object {
|
||||
internal fun hasAnySmsCapability(
|
||||
@@ -150,7 +151,9 @@ class DeviceHandler(
|
||||
val smsReadGranted = hasPermission(Manifest.permission.READ_SMS)
|
||||
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
|
||||
val photosGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
if (!photosEnabled) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= 33) {
|
||||
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
} else {
|
||||
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
@@ -248,7 +251,7 @@ class DeviceHandler(
|
||||
"photos",
|
||||
permissionStateJson(
|
||||
granted = photosGranted,
|
||||
promptableWhenDenied = true,
|
||||
promptableWhenDenied = photosEnabled,
|
||||
),
|
||||
)
|
||||
put(
|
||||
|
||||
@@ -23,6 +23,7 @@ data class NodeRuntimeFlags(
|
||||
val readSmsAvailable: Boolean,
|
||||
val smsSearchPossible: Boolean,
|
||||
val callLogAvailable: Boolean,
|
||||
val photosAvailable: Boolean,
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
@@ -37,6 +38,7 @@ enum class InvokeCommandAvailability {
|
||||
ReadSmsAvailable,
|
||||
RequestableSmsSearchAvailable,
|
||||
CallLogAvailable,
|
||||
PhotosAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
DebugBuild,
|
||||
@@ -48,6 +50,7 @@ enum class NodeCapabilityAvailability {
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
CallLogAvailable,
|
||||
PhotosAvailable,
|
||||
VoiceWakeEnabled,
|
||||
MotionAvailable,
|
||||
}
|
||||
@@ -87,7 +90,10 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawCapability.Location.rawValue,
|
||||
availability = NodeCapabilityAvailability.LocationEnabled,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Photos.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Photos.rawValue,
|
||||
availability = NodeCapabilityAvailability.PhotosAvailable,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Contacts.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Calendar.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
@@ -188,6 +194,7 @@ object InvokeCommandRegistry {
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawPhotosCommand.Latest.rawValue,
|
||||
availability = InvokeCommandAvailability.PhotosAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawContactsCommand.Search.rawValue,
|
||||
@@ -244,6 +251,7 @@ object InvokeCommandRegistry {
|
||||
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
|
||||
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
NodeCapabilityAvailability.PhotosAvailable -> flags.photosAvailable
|
||||
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
||||
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
||||
}
|
||||
@@ -260,6 +268,7 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
|
||||
InvokeCommandAvailability.RequestableSmsSearchAvailable -> flags.smsSearchPossible
|
||||
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
|
||||
@@ -77,6 +77,7 @@ class InvokeDispatcher(
|
||||
private val smsFeatureEnabled: () -> Boolean,
|
||||
private val smsTelephonyAvailable: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
@@ -325,6 +326,15 @@ class InvokeDispatcher(
|
||||
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.PhotosAvailable ->
|
||||
if (photosAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "PHOTOS_UNAVAILABLE",
|
||||
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
|
||||
@@ -27,28 +27,23 @@ internal object JpegSizeLimiter {
|
||||
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
|
||||
require(maxBytes > 0) { "Invalid maxBytes" }
|
||||
|
||||
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
|
||||
var width = initialWidth
|
||||
var height = initialHeight
|
||||
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
|
||||
var best =
|
||||
JpegSizeLimiterResult(
|
||||
bytes = encode(width, height, clampedStartQuality),
|
||||
width = width,
|
||||
height = height,
|
||||
quality = clampedStartQuality,
|
||||
)
|
||||
if (best.bytes.size <= maxBytes) return best
|
||||
var best: JpegSizeLimiterResult? = null
|
||||
|
||||
repeat(maxScaleAttempts) {
|
||||
repeat(maxScaleAttempts + 1) { scaleAttempt ->
|
||||
var quality = clampedStartQuality
|
||||
repeat(maxQualityAttempts) {
|
||||
val bytes = encode(width, height, quality)
|
||||
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
|
||||
val attempt = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
|
||||
best = attempt
|
||||
if (bytes.size <= maxBytes) return best
|
||||
if (quality <= minQuality) return@repeat
|
||||
quality = max(minQuality, (quality * 0.75).roundToInt())
|
||||
}
|
||||
|
||||
if (scaleAttempt == maxScaleAttempts) return@repeat
|
||||
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
|
||||
val nextScale = max(scaleStep, minScale)
|
||||
val nextWidth = max(minSize, (width * nextScale).roundToInt())
|
||||
@@ -58,10 +53,11 @@ internal object JpegSizeLimiter {
|
||||
height = min(nextHeight, height)
|
||||
}
|
||||
|
||||
if (best.bytes.size > maxBytes) {
|
||||
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
|
||||
val failed = checkNotNull(best)
|
||||
if (failed.bytes.size > maxBytes) {
|
||||
throw IllegalStateException("CAMERA_TOO_LARGE: ${failed.bytes.size} bytes > $maxBytes bytes")
|
||||
}
|
||||
|
||||
return best
|
||||
return failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun CanvasSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val currentUrl by viewModel.canvasCurrentUrl.collectAsState()
|
||||
val hydrated by viewModel.canvasA2uiHydrated.collectAsState()
|
||||
val rehydratePending by viewModel.canvasRehydratePending.collectAsState()
|
||||
val rehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
|
||||
val hasLivePage = currentUrl?.isNotBlank() == true
|
||||
val showCanvasSurface = isConnected
|
||||
val canvasLabel = if (hasLivePage) "Live page" else "Home canvas"
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Canvas",
|
||||
subtitle = "Current screen output and interactive app surface.",
|
||||
icon = Icons.AutoMirrored.Filled.ScreenShare,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Connection", if (isConnected) "Online" else "Offline"),
|
||||
SettingsMetric("Surface", canvasLabel),
|
||||
SettingsMetric("Bridge", if (hasLivePage && hydrated) "Ready" else "Standby"),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawPrimaryButton(
|
||||
text = if (rehydratePending) "Refreshing" else "Refresh Screen",
|
||||
onClick = { viewModel.requestCanvasRehydrate(source = "settings_canvas") },
|
||||
enabled = isConnected && !rehydratePending,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ClawSecondaryButton(
|
||||
text = "Reconnect",
|
||||
onClick = viewModel::refreshGatewayConnection,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
rehydrateErrorText?.let {
|
||||
ClawPanel {
|
||||
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = canvasLabel, style = ClawTheme.type.section, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(520.dp).clip(RoundedCornerShape(ClawTheme.radii.panel)),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box {
|
||||
if (showCanvasSurface) {
|
||||
CanvasScreen(viewModel = viewModel, visible = true, modifier = Modifier.fillMaxWidth().height(520.dp))
|
||||
} else {
|
||||
CanvasStandbyPanel(isConnected = isConnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CanvasStandbyPanel(isConnected: Boolean) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().height(520.dp).padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(54.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ScreenShare, contentDescription = null, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (isConnected) "Screen surface ready" else "Connect the gateway",
|
||||
style = ClawTheme.type.title,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.padding(top = 18.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (isConnected) "Canvas output appears here when OpenClaw opens an app surface." else "Canvas output needs an active gateway connection.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawListPanel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun ChannelsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.channelsSummary.collectAsState()
|
||||
val refreshing by viewModel.channelsRefreshing.collectAsState()
|
||||
val errorText by viewModel.channelsErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val channels = summary.channels
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Channels",
|
||||
subtitle = "Messaging surfaces connected to this gateway.",
|
||||
icon = Icons.Default.Notifications,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Channels", channels.size.toString()),
|
||||
SettingsMetric("Connected", channels.count { it.connected }.toString()),
|
||||
SettingsMetric("Configured", channels.count { it.configured }.toString()),
|
||||
SettingsMetric("Issues", channels.count { it.error != null }.toString()),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshChannels,
|
||||
enabled = isConnected && !refreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
errorText?.let { error ->
|
||||
ClawPanel {
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
if (summary.partial || summary.warnings.isNotEmpty()) {
|
||||
ClawPanel {
|
||||
Text(text = channelsWarningText(summary), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load channels.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
channels.isEmpty() ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No channels found.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Telegram, WhatsApp, email, and other channels appear here after setup.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> ChannelsPanel(channels = channels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelsPanel(channels: List<GatewayChannelSummary>) {
|
||||
ClawListPanel(items = channels) { channel ->
|
||||
ChannelRow(channel = channel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(channel: GatewayChannelSummary) {
|
||||
ClawDetailRow(
|
||||
title = channel.label,
|
||||
subtitle = channelSubtitle(channel),
|
||||
leading = { ClawTextBadge(text = channelBadge(channel.label)) },
|
||||
trailing = { ClawStatusPill(text = channelStatusText(channel), status = channelStatus(channel)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun channelSubtitle(channel: GatewayChannelSummary): String {
|
||||
val accounts =
|
||||
when (channel.accountCount) {
|
||||
0 -> null
|
||||
1 -> "1 account"
|
||||
else -> "${channel.accountCount} accounts"
|
||||
}
|
||||
val lifecycle =
|
||||
when {
|
||||
channel.connected -> "Connected"
|
||||
channel.running -> "Running"
|
||||
channel.linked -> "Linked"
|
||||
channel.configured -> "Configured"
|
||||
channel.enabled -> "Enabled"
|
||||
else -> "Off"
|
||||
}
|
||||
return listOfNotNull(accounts, lifecycle, channel.error).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun channelStatusText(channel: GatewayChannelSummary): String =
|
||||
when {
|
||||
channel.error != null -> "Issue"
|
||||
channel.connected -> "Connected"
|
||||
channel.running -> "Running"
|
||||
channel.linked || channel.configured -> "Ready"
|
||||
channel.enabled -> "Setup"
|
||||
else -> "Off"
|
||||
}
|
||||
|
||||
private fun channelStatus(channel: GatewayChannelSummary): ClawStatus =
|
||||
when {
|
||||
channel.error != null -> ClawStatus.Danger
|
||||
channel.connected || channel.running -> ClawStatus.Success
|
||||
channel.linked || channel.configured -> ClawStatus.Neutral
|
||||
channel.enabled -> ClawStatus.Warning
|
||||
else -> ClawStatus.Neutral
|
||||
}
|
||||
|
||||
private fun channelBadge(label: String): String =
|
||||
label
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "C" }
|
||||
|
||||
private fun channelsWarningText(summary: GatewayChannelsSummary): String = summary.warnings.firstOrNull()?.takeIf { it.isNotBlank() } ?: "Some channel status checks did not complete."
|
||||
@@ -0,0 +1,320 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSeparatedColumn
|
||||
import ai.openclaw.app.ui.design.ClawTextField
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.outlined.AccessTime
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.Inventory2
|
||||
import androidx.compose.material.icons.outlined.MicNone
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun CommandPalette(
|
||||
viewModel: MainViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenChat: () -> Unit,
|
||||
onOpenVoice: () -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
onOpenProviders: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSession: (String) -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val normalizedQuery = query.trim().lowercase()
|
||||
val quickActions =
|
||||
listOf(
|
||||
CommandItem("Open Chat", "Start or continue a conversation", Icons.Outlined.ChatBubbleOutline, onOpenChat),
|
||||
CommandItem("Start Voice", "Talk or dictate with OpenClaw", Icons.Outlined.MicNone, onOpenVoice),
|
||||
CommandItem("Browse Sessions", "Find previous conversations", Icons.Outlined.AccessTime, onOpenSessions),
|
||||
CommandItem("Providers & Models", providerCommandSubtitle(isConnected, providers, models), Icons.Outlined.Inventory2, onOpenProviders),
|
||||
CommandItem("Settings", "Gateway, voice, notifications, privacy", Icons.Outlined.Settings, onOpenSettings),
|
||||
)
|
||||
val actionRows = quickActions.filter { it.matches(normalizedQuery) }
|
||||
val sessionRows =
|
||||
sessions
|
||||
.filter { session ->
|
||||
val title = commandSessionTitle(session.displayName)
|
||||
normalizedQuery.isEmpty() || title.lowercase().contains(normalizedQuery)
|
||||
}.take(5)
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
|
||||
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
|
||||
CommandAvatar(text = "OC")
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ClawTextField(value = query, onValueChange = { query = it }, placeholder = "Search OpenClaw")
|
||||
}
|
||||
|
||||
item {
|
||||
CommandSectionLabel(title = "Quick actions")
|
||||
}
|
||||
|
||||
if (actionRows.isEmpty()) {
|
||||
item {
|
||||
ClawEmptyState(title = "No actions found", body = "Try Chat, Voice, Sessions, Providers, or Settings.")
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
CommandActionList(rows = actionRows)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
CommandSectionLabel(title = "Sessions")
|
||||
}
|
||||
|
||||
if (sessionRows.isEmpty()) {
|
||||
item {
|
||||
ClawPanel {
|
||||
Text(
|
||||
text = if (isConnected) "No matching sessions yet." else "Connect the Gateway to search sessions.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
CommandSessionList(
|
||||
rows =
|
||||
sessionRows.map { session ->
|
||||
CommandSessionRow(
|
||||
key = session.key,
|
||||
title = commandSessionTitle(session.displayName),
|
||||
subtitle = if (pendingRunCount > 0) "Assistant working" else "OpenClaw session",
|
||||
metadata = session.updatedAtMs?.let(::commandRelativeTime) ?: "now",
|
||||
)
|
||||
},
|
||||
onOpen = onOpenSession,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CommandItem(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val icon: ImageVector,
|
||||
val onClick: () -> Unit,
|
||||
) {
|
||||
fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query)
|
||||
}
|
||||
|
||||
private data class CommandSessionRow(
|
||||
val key: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val metadata: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun CommandActionList(rows: List<CommandItem>) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
|
||||
ClawSeparatedColumn(items = rows) { row ->
|
||||
CommandActionRow(row = row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandActionRow(row: CommandItem) {
|
||||
Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.clickable(onClick = row.onClick)
|
||||
.padding(horizontal = 2.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = ClawTheme.colors.text)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Open ${row.title}",
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandSessionList(
|
||||
rows: List<CommandSessionRow>,
|
||||
onOpen: (String) -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
|
||||
ClawSeparatedColumn(items = rows) { row ->
|
||||
CommandSessionListRow(row = row, onClick = { onOpen(row.key) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandSessionListRow(
|
||||
row: CommandSessionRow,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 58.dp)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 2.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(15.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1)
|
||||
}
|
||||
Text(text = row.metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Open session",
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandAvatar(text: String) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandSectionLabel(title: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerCommandSubtitle(
|
||||
isConnected: Boolean,
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): String {
|
||||
if (!isConnected) return "Connect Gateway to load models"
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
|
||||
if (models.isNotEmpty()) return "${models.size} models available"
|
||||
return "Configure model access"
|
||||
}
|
||||
|
||||
private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
|
||||
|
||||
private fun commandRelativeTime(updatedAtMs: Long): String {
|
||||
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
|
||||
val minutes = deltaMs / 60_000L
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return "${minutes}m"
|
||||
val hours = minutes / 60
|
||||
if (hours < 24) return "${hours}h"
|
||||
return "${hours / 24}d"
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayDreamDiaryEntry
|
||||
import ai.openclaw.app.GatewayDreamingSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun DreamingSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.dreamingSummary.collectAsState()
|
||||
val refreshing by viewModel.dreamingRefreshing.collectAsState()
|
||||
val errorText by viewModel.dreamingErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshDreaming()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Dreaming",
|
||||
subtitle = "Memory consolidation and dream diary.",
|
||||
icon = Icons.Default.Storage,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Status", if (summary.enabled) "On" else "Off"),
|
||||
SettingsMetric("Waiting", summary.shortTermCount.toString()),
|
||||
SettingsMetric("Signals", summary.totalSignalCount.toString()),
|
||||
SettingsMetric("Next Cycle", formatDreamingNextRun(summary.nextRunAtMs)),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshDreaming,
|
||||
enabled = isConnected && !refreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
errorText?.let { error ->
|
||||
ClawPanel {
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load dreaming.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
else -> DreamingPanel(summary = summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamingPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
DreamingHealthRow(
|
||||
title = "Memory Store",
|
||||
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.storeHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
title = "Signal Index",
|
||||
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.phaseSignalHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
title = "Promoted",
|
||||
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
DreamDiaryPanel(summary = summary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamingHealthRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(7.dp))
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamDiaryPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "DIARY", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
if (!summary.diaryFound) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No dream diary yet.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Entries appear after a dreaming cycle writes a narrative summary.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (summary.diaryEntries.isEmpty()) {
|
||||
ClawPanel {
|
||||
Text(text = "The diary is waiting for its first entry.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
return
|
||||
}
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
summary.diaryEntries.forEachIndexed { index, entry ->
|
||||
DreamDiaryRow(entry = entry)
|
||||
if (index != summary.diaryEntries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamDiaryRow(entry: GatewayDreamDiaryEntry) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = "D", style = ClawTheme.type.label, color = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = entry.date, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = entry.text, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDreamingNextRun(nextRunAtMs: Long?): String {
|
||||
val next = nextRunAtMs ?: return "Not scheduled"
|
||||
val deltaMinutes = ((next - System.currentTimeMillis()) / 60_000L).coerceAtLeast(0L)
|
||||
val hours = deltaMinutes / 60L
|
||||
return when {
|
||||
hours >= 24L -> "In ${hours / 24L}d"
|
||||
hours >= 1L -> "In ${hours}h"
|
||||
deltaMinutes >= 1L -> "In ${deltaMinutes}m"
|
||||
else -> "Soon"
|
||||
}
|
||||
}
|
||||
@@ -143,27 +143,15 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
|
||||
?.trim()
|
||||
?.lowercase(Locale.US)
|
||||
.orEmpty()
|
||||
val tls =
|
||||
when (scheme) {
|
||||
"ws", "http" -> false
|
||||
"wss", "https" -> true
|
||||
else -> true
|
||||
}
|
||||
if (scheme !in setOf("ws", "wss", "http", "https")) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
}
|
||||
val tls = scheme == "wss" || scheme == "https"
|
||||
if (!tls && !isLoopbackGatewayHost(host)) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
|
||||
}
|
||||
val defaultPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 18789
|
||||
else -> 443
|
||||
}
|
||||
val displayPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 80
|
||||
else -> 443
|
||||
}
|
||||
val defaultPort = if (tls) 443 else 18789
|
||||
val displayPort = if (tls) 443 else 80
|
||||
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
|
||||
val displayHost = if (host.contains(":")) "[$host]" else host
|
||||
val displayUrl =
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayHealthLogsSummary
|
||||
import ai.openclaw.app.GatewayLogEntry
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun HealthLogsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val chatHealthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val modelCount by viewModel.modelCatalog.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val talkStatus by viewModel.talkModeStatusText.collectAsState()
|
||||
val logsSummary by viewModel.healthLogsSummary.collectAsState()
|
||||
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
|
||||
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshHealthLogs()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Health",
|
||||
subtitle = "Gateway status, phone node readiness, and recent log stream.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Gateway", if (isConnected) "Online" else "Offline"),
|
||||
SettingsMetric("Node", if (isNodeConnected) "Online" else "Waiting"),
|
||||
SettingsMetric("Models", modelCount.size.toString()),
|
||||
SettingsMetric("Logs", logsSummary.entries.size.toString()),
|
||||
),
|
||||
)
|
||||
HealthStatusPanel(
|
||||
gateway = statusText,
|
||||
node = if (isNodeConnected) "Online" else "Waiting",
|
||||
chat = if (chatHealthOk) "Ready" else "Needs connection",
|
||||
models = "${modelCount.size} available",
|
||||
voice = talkStatus,
|
||||
runs = if (pendingRunCount > 0) "$pendingRunCount active" else "Idle",
|
||||
isConnected = isConnected,
|
||||
isNodeConnected = isNodeConnected,
|
||||
chatHealthOk = chatHealthOk,
|
||||
modelsReady = modelCount.isNotEmpty(),
|
||||
voiceReady = talkStatus.lowercase() != "off",
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (logsRefreshing) "Refreshing" else "Refresh Logs",
|
||||
onClick = viewModel::refreshHealthLogs,
|
||||
enabled = isConnected && !logsRefreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
logsErrorText?.let { error ->
|
||||
ClawPanel {
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HealthStatusPanel(
|
||||
gateway: String,
|
||||
node: String,
|
||||
chat: String,
|
||||
models: String,
|
||||
voice: String,
|
||||
runs: String,
|
||||
isConnected: Boolean,
|
||||
isNodeConnected: Boolean,
|
||||
chatHealthOk: Boolean,
|
||||
modelsReady: Boolean,
|
||||
voiceReady: Boolean,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Runs", value = runs, healthy = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HealthStatusRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogsPanel(
|
||||
isConnected: Boolean,
|
||||
summary: GatewayHealthLogsSummary,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "RECENT LOGS", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
summary.fileName?.let { fileName ->
|
||||
Text(text = fileName, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load recent logs.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
summary.entries.isEmpty() ->
|
||||
ClawPanel {
|
||||
Text(text = "No recent log entries.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
else ->
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
val entries = summary.entries.takeLast(12)
|
||||
Column {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
GatewayLogRow(entry = entry)
|
||||
if (index != entries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.truncated) {
|
||||
Text(text = "Showing the latest log chunk.", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogRow(entry: GatewayLogEntry) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Text(text = compactLogTime(entry.time), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, modifier = Modifier.weight(0.72f), maxLines = 1)
|
||||
Column(modifier = Modifier.weight(2.7f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = entry.message, style = ClawTheme.type.caption, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.subsystem?.let { subsystem ->
|
||||
Text(text = subsystem, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
|
||||
}
|
||||
}
|
||||
|
||||
private fun compactLogTime(value: String?): String {
|
||||
val raw = value?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return "--:--"
|
||||
val time =
|
||||
raw
|
||||
.substringAfter('T', raw)
|
||||
.substringBefore('.')
|
||||
.substringBefore('+')
|
||||
.substringBefore('Z')
|
||||
return time.takeIf { it.length >= 5 }?.take(5) ?: raw.take(5)
|
||||
}
|
||||
|
||||
private fun logLevelStatus(level: String?): ClawStatus =
|
||||
when (level?.lowercase()) {
|
||||
"error", "fatal" -> ClawStatus.Danger
|
||||
"warn" -> ClawStatus.Warning
|
||||
"info" -> ClawStatus.Success
|
||||
else -> ClawStatus.Neutral
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayDeviceTokenSummary
|
||||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPairedDeviceSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun NodesDevicesSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val refreshing by viewModel.nodesDevicesRefreshing.collectAsState()
|
||||
val errorText by viewModel.nodesDevicesErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshNodesDevices()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Nodes & Devices",
|
||||
subtitle = "Live nodes, paired phones, and pending device requests.",
|
||||
icon = Icons.Default.Cloud,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Nodes", summary.nodes.size.toString()),
|
||||
SettingsMetric("Online", summary.nodes.count { it.connected }.toString()),
|
||||
SettingsMetric("Devices", if (summary.devicePairingAvailable) summary.pairedDevices.size.toString() else "Locked"),
|
||||
SettingsMetric("Pending", summary.pendingDevices.size.toString()),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshNodesDevices,
|
||||
enabled = isConnected && !refreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
errorText?.let {
|
||||
ClawPanel {
|
||||
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load nodes and paired devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
summary.isEmpty() ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No nodes or paired devices.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Linked phones and node hosts will appear here after pairing.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> NodesDevicesPanel(summary = summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodesDevicesPanel(summary: GatewayNodesDevicesSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (!summary.devicePairingAvailable) {
|
||||
ClawPanel {
|
||||
Text(text = "Pairing controls are not available from this connection.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
if (summary.pendingDevices.isNotEmpty()) {
|
||||
NodesSection(title = "Pending Requests") {
|
||||
summary.pendingDevices.forEachIndexed { index, device ->
|
||||
PendingDeviceRow(device = device)
|
||||
if (index != summary.pendingDevices.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.nodes.isNotEmpty()) {
|
||||
NodesSection(title = "Nodes") {
|
||||
summary.nodes.forEachIndexed { index, node ->
|
||||
NodeRow(node = node)
|
||||
if (index != summary.nodes.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.pairedDevices.isNotEmpty()) {
|
||||
NodesSection(title = "Paired Devices") {
|
||||
summary.pairedDevices.forEachIndexed { index, device ->
|
||||
PairedDeviceRow(device = device)
|
||||
if (index != summary.pairedDevices.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodesSection(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeRow(node: GatewayNodeSummary) {
|
||||
DeviceListRow(
|
||||
badge = nodeBadge(node.displayName ?: node.id),
|
||||
title = node.displayName ?: node.id,
|
||||
subtitle = nodeSubtitle(node),
|
||||
statusText = if (node.connected) "Online" else "Offline",
|
||||
status = if (node.connected) ClawStatus.Success else ClawStatus.Warning,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingDeviceRow(device: GatewayPendingDeviceSummary) {
|
||||
DeviceListRow(
|
||||
badge = nodeBadge(device.displayName ?: device.deviceId),
|
||||
title = device.displayName ?: "New device",
|
||||
subtitle = pendingDeviceSubtitle(device),
|
||||
statusText = if (device.repair) "Repair" else "Review",
|
||||
status = ClawStatus.Warning,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairedDeviceRow(device: GatewayPairedDeviceSummary) {
|
||||
DeviceListRow(
|
||||
badge = nodeBadge(device.displayName ?: device.deviceId),
|
||||
title = device.displayName ?: "Paired device",
|
||||
subtitle = pairedDeviceSubtitle(device),
|
||||
statusText = pairedDeviceStatusText(device.tokens),
|
||||
status = pairedDeviceStatus(device.tokens),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceListRow(
|
||||
badge: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
statusText: String,
|
||||
status: ClawStatus,
|
||||
) {
|
||||
ClawDetailRow(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
leading = { ClawTextBadge(text = badge) },
|
||||
trailing = { ClawStatusPill(text = statusText, status = status) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun GatewayNodesDevicesSummary.isEmpty(): Boolean = nodes.isEmpty() && pendingDevices.isEmpty() && pairedDevices.isEmpty()
|
||||
|
||||
private fun nodeSubtitle(node: GatewayNodeSummary): String {
|
||||
val kind = node.deviceFamily ?: "Node host"
|
||||
val version = node.version?.let { "OpenClaw $it" }
|
||||
val status = if (node.paired) "Paired" else "Unpaired"
|
||||
val commands =
|
||||
node.commands
|
||||
.take(2)
|
||||
.joinToString(", ")
|
||||
.takeIf { it.isNotBlank() }
|
||||
return listOfNotNull(kind, version, status, commands).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
|
||||
val roles = formatDeviceList(device.roles, "role")
|
||||
val scopes = formatDeviceList(device.scopes, "scope")
|
||||
val requested = device.requestedAtMs?.let { "requested ${relativeDeviceTime(it)}" }
|
||||
return listOfNotNull(roles, scopes, requested, device.remoteIp).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun pairedDeviceSubtitle(device: GatewayPairedDeviceSummary): String {
|
||||
val roles = formatDeviceList(device.roles, "role")
|
||||
val scopes = formatDeviceList(device.scopes, "scope")
|
||||
val tokens = "${device.tokens.count { !it.revoked }}/${device.tokens.size} active tokens"
|
||||
return listOfNotNull(roles, scopes, tokens, device.remoteIp).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun pairedDeviceStatusText(tokens: List<GatewayDeviceTokenSummary>): String =
|
||||
when {
|
||||
tokens.isEmpty() -> "Paired"
|
||||
tokens.any { !it.revoked } -> "Active"
|
||||
else -> "Needs Token"
|
||||
}
|
||||
|
||||
private fun pairedDeviceStatus(tokens: List<GatewayDeviceTokenSummary>): ClawStatus =
|
||||
when {
|
||||
tokens.isEmpty() -> ClawStatus.Neutral
|
||||
tokens.any { !it.revoked } -> ClawStatus.Success
|
||||
else -> ClawStatus.Warning
|
||||
}
|
||||
|
||||
private fun formatDeviceList(
|
||||
values: List<String>,
|
||||
fallback: String,
|
||||
): String? =
|
||||
when (values.size) {
|
||||
0 -> null
|
||||
1 -> values.first()
|
||||
else -> "${values.size} ${fallback}s"
|
||||
}
|
||||
|
||||
private fun nodeBadge(value: String): String =
|
||||
value
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "N" }
|
||||
|
||||
private fun relativeDeviceTime(timeMs: Long): String {
|
||||
val minutes = ((System.currentTimeMillis() - timeMs).coerceAtLeast(0L)) / 60_000L
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return "${minutes}m ago"
|
||||
val hours = minutes / 60L
|
||||
if (hours < 24) return "${hours}h ago"
|
||||
return "${hours / 24L}d ago"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.providerDisplayName
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
internal fun ProvidersModelsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onAddProvider: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val refreshing by viewModel.modelCatalogRefreshing.collectAsState()
|
||||
val errorText by viewModel.modelCatalogErrorText.collectAsState()
|
||||
val providerRows = providerRows(providers = providers, models = models)
|
||||
val modelGroups = sortedModelGroups(models)
|
||||
val setupRows = providerSetupRows(providerRows)
|
||||
var expandedModelProviders by rememberSaveable { mutableStateOf(emptyList<String>()) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshModelCatalog()
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 13.dp)) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp), contentPadding = PaddingValues(bottom = 112.dp)) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
ProviderHeaderIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
ProviderHeaderIconButton(icon = Icons.Default.Add, contentDescription = "Add provider", outlined = true, onClick = onAddProvider)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Providers & Models", style = ClawTheme.type.display.copy(fontSize = 14.8.sp, lineHeight = 18.sp), color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(
|
||||
text = "Connect and manage AI providers\nBrowse models and their capabilities.",
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderOverviewPanel(
|
||||
isConnected = isConnected,
|
||||
providerRows = providerRows,
|
||||
modelCount = models.size,
|
||||
onRefresh = viewModel::refreshModelCatalog,
|
||||
onSetup = onAddProvider,
|
||||
refreshing = refreshing,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Provider setup")
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSetupList(rows = setupRows, onSetup = onAddProvider)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Connected providers")
|
||||
}
|
||||
|
||||
item {
|
||||
if (!isConnected && providerRows.isEmpty()) {
|
||||
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness and model catalog.")
|
||||
} else {
|
||||
ProviderList(rows = providerRows, refreshing = refreshing)
|
||||
}
|
||||
}
|
||||
|
||||
errorText?.let { message ->
|
||||
item {
|
||||
ClawPanel {
|
||||
Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Model catalog")
|
||||
}
|
||||
|
||||
if (modelGroups.isEmpty()) {
|
||||
item {
|
||||
ModelCatalogEmpty(
|
||||
title = if (refreshing) "Loading models" else "No models loaded",
|
||||
body = if (isConnected) "Refresh after configuring a provider on the Gateway." else "Connect the Gateway to browse models.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(modelGroups, key = { it.first }) { entry ->
|
||||
val expanded = expandedModelProviders.contains(entry.first)
|
||||
ModelGroup(
|
||||
provider = entry.first,
|
||||
models = entry.second,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedModelProviders =
|
||||
if (expanded) {
|
||||
expandedModelProviders - entry.first
|
||||
} else {
|
||||
expandedModelProviders + entry.first
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ProviderAddButton(onClick = onAddProvider, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProviderSetupRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val ready: Boolean,
|
||||
)
|
||||
|
||||
private data class ProviderRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val ready: Boolean,
|
||||
val modelCount: Int,
|
||||
)
|
||||
|
||||
private fun providerRows(
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): List<ProviderRow> {
|
||||
val modelCounts = models.groupingBy { it.provider }.eachCount()
|
||||
val authRows =
|
||||
providers.map { provider ->
|
||||
val ready = modelProviderReady(provider.status)
|
||||
ProviderRow(
|
||||
id = provider.id,
|
||||
name = provider.displayName,
|
||||
status = if (ready) "Ready" else "Needs setup",
|
||||
ready = ready,
|
||||
modelCount = modelCounts[provider.id] ?: 0,
|
||||
)
|
||||
}
|
||||
val missingAuthRows =
|
||||
modelCounts.keys
|
||||
.filter { provider -> authRows.none { it.id == provider } }
|
||||
.map { provider ->
|
||||
ProviderRow(
|
||||
id = provider,
|
||||
name = providerDisplayName(provider),
|
||||
status = "Ready",
|
||||
ready = true,
|
||||
modelCount = modelCounts[provider] ?: 0,
|
||||
)
|
||||
}
|
||||
return (authRows + missingAuthRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
|
||||
}
|
||||
|
||||
private fun providerSetupRows(providerRows: List<ProviderRow>): List<ProviderSetupRow> {
|
||||
val byId = providerRows.associateBy { it.id.trim().lowercase() }
|
||||
return listOf("openai", "anthropic", "google", "openrouter", "ollama").map { id ->
|
||||
val row = byId[id] ?: byId["ollama-local"].takeIf { id == "ollama" }
|
||||
ProviderSetupRow(
|
||||
id = id,
|
||||
name = providerDisplayName(id),
|
||||
subtitle = providerSetupSubtitle(id, row),
|
||||
ready = row?.ready == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerSetupSubtitle(
|
||||
id: String,
|
||||
row: ProviderRow?,
|
||||
): String =
|
||||
when {
|
||||
row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready"
|
||||
row != null -> "Finish setup to use ${row.name}"
|
||||
id == "ollama" -> "Use models running on your network"
|
||||
else -> "Add provider credentials on your Gateway"
|
||||
}
|
||||
|
||||
internal fun modelProviderReady(status: String): Boolean {
|
||||
val normalized = status.trim().lowercase()
|
||||
return normalized == "ok" ||
|
||||
normalized == "ready" ||
|
||||
normalized == "healthy" ||
|
||||
normalized == "configured" ||
|
||||
normalized == "static"
|
||||
}
|
||||
|
||||
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
|
||||
models
|
||||
.groupBy { it.provider }
|
||||
.entries
|
||||
.sortedWith(compareBy({ providerPriority(it.key) }, { providerDisplayName(it.key).lowercase() }))
|
||||
.map { it.key to it.value }
|
||||
|
||||
private fun providerPriority(row: ProviderRow): Int = providerPriority(row.id)
|
||||
|
||||
private fun providerPriority(provider: String): Int =
|
||||
when (provider.trim().lowercase()) {
|
||||
"openai" -> 0
|
||||
"anthropic" -> 1
|
||||
"google" -> 2
|
||||
"openrouter" -> 3
|
||||
"ollama", "ollama-local" -> 4
|
||||
"codex", "openai-codex" -> 5
|
||||
else -> 100
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderList(
|
||||
rows: List<ProviderRow>,
|
||||
refreshing: Boolean,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
if (rows.isEmpty()) {
|
||||
ProviderListRow(ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
|
||||
} else {
|
||||
val visibleRows = rows.take(5)
|
||||
visibleRows.forEachIndexed { index, row ->
|
||||
ProviderListRow(row)
|
||||
if (index != visibleRows.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderOverviewPanel(
|
||||
isConnected: Boolean,
|
||||
providerRows: List<ProviderRow>,
|
||||
modelCount: Int,
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
val readyCount = providerRows.count { it.ready }
|
||||
val needsSetupCount = providerRows.count { !it.ready }
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
|
||||
}
|
||||
Text(
|
||||
text = if (isConnected) "Choose a provider below, then finish credentials on your Gateway." else "Connect your Gateway before adding model providers.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.weight(1f))
|
||||
ClawPrimaryButton(text = "Setup Provider", onClick = onSetup, enabled = isConnected, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderMetricTile(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 9.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = value, style = ClawTheme.type.title, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupList(
|
||||
rows: List<ProviderSetupRow>,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
rows.forEachIndexed { index, row ->
|
||||
ProviderSetupListRow(row = row, onClick = onSetup)
|
||||
if (index != rows.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupListRow(
|
||||
row: ProviderSetupRow,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = if (row.ready) "Ready" else "Setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderListRow(row: ProviderRow) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = row.status, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderBadge(text: String) {
|
||||
Surface(modifier = Modifier.size(30.dp), shape = RoundedCornerShape(ClawTheme.radii.row), color = ClawTheme.colors.surfacePressed, border = BorderStroke(1.dp, ClawTheme.colors.border)) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = providerInitials(text), style = ClawTheme.type.label, color = ClawTheme.colors.text, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerInitials(value: String): String =
|
||||
value
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "AI" }
|
||||
|
||||
@Composable
|
||||
private fun ModelCatalogEmpty(
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 11.dp, vertical = 10.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelGroup(
|
||||
provider: String,
|
||||
models: List<GatewayModelSummary>,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = providerDisplayName(provider))
|
||||
Text(text = providerDisplayName(provider), style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ProviderMiniTag(text = "${models.size} models")
|
||||
Icon(imageVector = if (expanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (expanded) "Collapse ${providerDisplayName(provider)} models" else "Expand ${providerDisplayName(provider)} models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
val visibleModels = if (expanded) models else models.take(3)
|
||||
visibleModels.forEachIndexed { index, model ->
|
||||
ModelRow(model)
|
||||
if (index != visibleModels.lastIndex || models.size > visibleModels.size) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
if (models.size > visibleModels.size) {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "View all models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, modifier = Modifier.weight(1f))
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "View all models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelRow(model: GatewayModelSummary) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
modelCapabilityLabels(model).take(3).forEach { label ->
|
||||
ProviderMiniTag(text = label)
|
||||
}
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
}
|
||||
|
||||
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
|
||||
buildList {
|
||||
if (model.supportsReasoning) add("Reasoning")
|
||||
if (model.supportsVision) add("Vision")
|
||||
if (model.supportsAudio) add("Voice")
|
||||
if (model.supportsDocuments) add("Docs")
|
||||
if ((model.contextTokens ?: 0L) >= 100_000L) add("Long context")
|
||||
if (isEmpty()) add("Fast")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSectionLabel(title: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = title.uppercase(), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderHeaderIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
outlined: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = if (outlined) BorderStroke(1.dp, ClawTheme.colors.borderStrong) else null,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(if (outlined) 17.dp else 20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderAddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Add, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
Text(text = "Open Gateway Setup", style = ClawTheme.type.label, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderMiniTag(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,5 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
return
|
||||
}
|
||||
|
||||
PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
ShellScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.material.icons.outlined.AccessTime
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.MicNone
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
internal fun SessionsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onOpenCommand: () -> Unit,
|
||||
onOpenChat: () -> Unit,
|
||||
) {
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatSessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
var filter by rememberSaveable { mutableStateOf(SessionFilter.Recent) }
|
||||
var compactLayout by rememberSaveable { mutableStateOf(false) }
|
||||
var recentFirst by rememberSaveable { mutableStateOf(true) }
|
||||
val visibleSessions =
|
||||
sessions
|
||||
.let { rows ->
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> rows
|
||||
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
|
||||
}
|
||||
}.let { rows ->
|
||||
if (recentFirst) {
|
||||
rows.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||
} else {
|
||||
rows.sortedBy { it.updatedAtMs ?: 0L }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
|
||||
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
|
||||
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.clickable { recentFirst = !recentFirst }
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(text = "Sort: ${if (recentFirst) "Newest" else "Oldest"}", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(11.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
SessionOutlineIconButton(icon = Icons.Default.Storage, contentDescription = "Toggle session layout", onClick = { compactLayout = !compactLayout })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text(text = if (compactLayout) "Layout: Compact" else "Layout: Detailed", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
|
||||
if (visibleSessions.isEmpty()) {
|
||||
item {
|
||||
ClawEmptyState(
|
||||
title = emptySessionTitle(filter),
|
||||
body = emptySessionBody(filter),
|
||||
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(visibleSessions, key = { it.key }) { session ->
|
||||
val active = session.key == chatSessionKey
|
||||
SessionRow(
|
||||
title = displaySessionTitle(session.displayName),
|
||||
subtitle = if (active) "Current session" else "OpenClaw session",
|
||||
metadata = session.updatedAtMs?.let(::relativeSessionTime) ?: "now",
|
||||
active = active,
|
||||
compact = compactLayout,
|
||||
onClick = {
|
||||
viewModel.switchChatSession(session.key)
|
||||
onOpenChat()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterPill(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
active: Boolean = false,
|
||||
live: Boolean = false,
|
||||
dropdown: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick ?: {},
|
||||
enabled = onClick != null,
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = if (active) ClawTheme.colors.surfaceRaised else Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
|
||||
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
|
||||
if (live) {
|
||||
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
if (dropdown) {
|
||||
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(11.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
metadata: String,
|
||||
active: Boolean,
|
||||
compact: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = if (active) Icons.Default.StarBorder else Icons.Outlined.ChatBubbleOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.5.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (active) {
|
||||
Box(modifier = Modifier.size(3.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
}
|
||||
if (!compact) {
|
||||
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SessionMiniTag(text = "Workspace")
|
||||
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textMuted)
|
||||
Text(text = metadata, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionPlainIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionOutlineIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionMiniTag(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SessionFilter {
|
||||
Recent,
|
||||
Live,
|
||||
}
|
||||
|
||||
private fun emptySessionTitle(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "No sessions yet"
|
||||
SessionFilter.Live -> "No live session"
|
||||
}
|
||||
|
||||
private fun emptySessionBody(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "Start a new conversation and it will show up here."
|
||||
SessionFilter.Live -> "Open Chat to start or resume the current session."
|
||||
}
|
||||
|
||||
private fun relativeSessionTime(updatedAtMs: Long): String {
|
||||
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
|
||||
val minutes = deltaMs / 60_000L
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return "${minutes}m"
|
||||
val hours = minutes / 60
|
||||
if (hours < 24) return "${hours}h"
|
||||
return "${hours / 24}d"
|
||||
}
|
||||
|
||||
private fun displaySessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
|
||||
1146
apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt
Normal file
1146
apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -209,6 +209,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val callLogPermissionAvailable = remember { SensitiveFeatureConfig.callLogEnabled }
|
||||
val photosPermissionAvailable = remember { SensitiveFeatureConfig.photosEnabled }
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
@@ -245,8 +246,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
var photosPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
if (photosPermissionAvailable) {
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
val photosPermissionLauncher =
|
||||
@@ -347,8 +351,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
notificationListenerEnabled = isNotificationListenerEnabled(context)
|
||||
installedNotificationApps = queryInstalledApps(context, notificationForwardingPackages)
|
||||
photosPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (photosPermissionAvailable) {
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
false
|
||||
}
|
||||
contactsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
@@ -980,31 +987,33 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
item {
|
||||
Column(modifier = Modifier.settingsRowModifier()) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos", style = mobileHeadline) },
|
||||
supportingContent = { Text("Access recent photos.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
if (photosPermissionAvailable) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos", style = mobileHeadline) },
|
||||
supportingContent = { Text("Access recent photos.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
}
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
|
||||
1019
apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt
Normal file
1019
apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewaySkillSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawListPanel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun SkillsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val skillsSummary by viewModel.skillsSummary.collectAsState()
|
||||
val skillsRefreshing by viewModel.skillsRefreshing.collectAsState()
|
||||
val skillsErrorText by viewModel.skillsErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val skills = skillsSummary.skills
|
||||
val readyCount = skills.count { skillReady(it) }
|
||||
val needsSetupCount = skills.count { skillNeedsSetup(it) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshSkills()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Skills",
|
||||
subtitle = "Installed capabilities available to OpenClaw.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Installed", skills.size.toString()),
|
||||
SettingsMetric("Ready", readyCount.toString()),
|
||||
SettingsMetric("Needs Setup", needsSetupCount.toString()),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (skillsRefreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshSkills,
|
||||
enabled = isConnected && !skillsRefreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
skillsErrorText?.let { errorText ->
|
||||
ClawPanel {
|
||||
Text(text = errorText, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load skills.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
skills.isEmpty() ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No skills installed.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> SkillsPanel(skills = skills)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
|
||||
ClawListPanel(items = skills) { skill ->
|
||||
SkillListRow(skill = skill)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillListRow(skill: GatewaySkillSummary) {
|
||||
ClawDetailRow(
|
||||
title = skill.name,
|
||||
subtitle = skillSubtitle(skill),
|
||||
leading = { ClawTextBadge(text = skillBadge(skill)) },
|
||||
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun skillReady(skill: GatewaySkillSummary): Boolean = !skill.disabled && skill.eligible && skill.missingCount == 0
|
||||
|
||||
private fun skillNeedsSetup(skill: GatewaySkillSummary): Boolean = !skill.disabled && (skill.blockedByAllowlist || !skill.eligible || skill.missingCount > 0)
|
||||
|
||||
private fun skillStatusText(skill: GatewaySkillSummary): String =
|
||||
when {
|
||||
skill.disabled -> "Off"
|
||||
skillNeedsSetup(skill) -> "Setup"
|
||||
else -> "Ready"
|
||||
}
|
||||
|
||||
private fun skillStatus(skill: GatewaySkillSummary): ClawStatus =
|
||||
when {
|
||||
skill.disabled -> ClawStatus.Neutral
|
||||
skillNeedsSetup(skill) -> ClawStatus.Warning
|
||||
else -> ClawStatus.Success
|
||||
}
|
||||
|
||||
private fun skillSubtitle(skill: GatewaySkillSummary): String {
|
||||
val issue =
|
||||
when {
|
||||
skill.disabled -> "Disabled"
|
||||
skill.blockedByAllowlist -> "Blocked"
|
||||
skill.missingCount > 0 -> "${skill.missingCount} missing"
|
||||
!skill.eligible -> "Needs setup"
|
||||
else -> null
|
||||
}
|
||||
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
|
||||
when (skill.source) {
|
||||
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"
|
||||
"openclaw-managed" -> "Installed"
|
||||
"openclaw-workspace" -> "Workspace"
|
||||
"openclaw-extra" -> "Extra"
|
||||
else -> "Skill"
|
||||
}
|
||||
|
||||
private fun skillBadge(skill: GatewaySkillSummary): String {
|
||||
skill.emoji?.let { return it }
|
||||
return skill.name
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "S" }
|
||||
}
|
||||
954
apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt
Normal file
954
apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt
Normal file
@@ -0,0 +1,954 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.VoiceCaptureMode
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import ai.openclaw.app.voice.VoiceConversationRole
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.PhoneDisabled
|
||||
import androidx.compose.material.icons.filled.RecordVoiceOver
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
@Composable
|
||||
fun VoiceScreen(
|
||||
viewModel: MainViewModel,
|
||||
onOpenCommand: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val gatewayStatus by viewModel.statusText.collectAsState()
|
||||
val voiceCaptureMode by viewModel.voiceCaptureMode.collectAsState()
|
||||
val micEnabled by viewModel.micEnabled.collectAsState()
|
||||
val micCooldown by viewModel.micCooldown.collectAsState()
|
||||
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
|
||||
val micStatusText by viewModel.micStatusText.collectAsState()
|
||||
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
|
||||
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
|
||||
val micConversation by viewModel.micConversation.collectAsState()
|
||||
val micIsSending by viewModel.micIsSending.collectAsState()
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
|
||||
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
|
||||
|
||||
var pendingAction by remember { mutableStateOf<VoiceAction?>(null) }
|
||||
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
|
||||
val requestMicPermission =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
hasMicPermission = granted
|
||||
if (granted) {
|
||||
when (pendingAction) {
|
||||
VoiceAction.Talk -> viewModel.setTalkModeEnabled(true)
|
||||
VoiceAction.Dictation -> viewModel.setMicEnabled(true)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
pendingAction = null
|
||||
}
|
||||
|
||||
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
|
||||
val voiceActive = micEnabled || micIsSending || talkModeEnabled
|
||||
val activeStatus =
|
||||
voiceStatusLabel(
|
||||
gatewayStatus = gatewayStatus,
|
||||
voiceCaptureMode = voiceCaptureMode,
|
||||
micStatusText = micStatusText,
|
||||
micQueuedMessages = micQueuedMessages.size,
|
||||
micIsSending = micIsSending,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
)
|
||||
|
||||
if (talkModeEnabled) {
|
||||
TalkSessionScreen(
|
||||
entries = talkModeConversation,
|
||||
listening = talkModeListening,
|
||||
speaking = talkModeSpeaking,
|
||||
speakerEnabled = speakerEnabled,
|
||||
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
onEndTalk = { viewModel.setTalkModeEnabled(false) },
|
||||
onOpenVoiceSettings = onOpenVoiceSettings,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (voiceCaptureMode == VoiceCaptureMode.ManualMic || micEnabled || micIsSending) {
|
||||
DictationScreen(
|
||||
liveTranscript = micLiveTranscript,
|
||||
conversation = micConversation,
|
||||
listening = micEnabled,
|
||||
sending = micIsSending,
|
||||
statusText = activeStatus,
|
||||
gatewayStatus = gatewayStatus,
|
||||
onCancel = { viewModel.cancelMicCapture() },
|
||||
onSend = { viewModel.setMicEnabled(false) },
|
||||
onOpenVoiceSettings = onOpenVoiceSettings,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
VoiceHeader(
|
||||
statusText = if (voiceActive) activeStatus else "Your voice command center.",
|
||||
speakerEnabled = speakerEnabled,
|
||||
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
onOpenCommand = onOpenCommand,
|
||||
)
|
||||
|
||||
VoiceHero(
|
||||
gatewayStatus = gatewayStatus,
|
||||
voiceCaptureMode = voiceCaptureMode,
|
||||
micEnabled = micEnabled,
|
||||
talkModeEnabled = talkModeEnabled,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
micLiveTranscript = micLiveTranscript,
|
||||
onStartTalk = {
|
||||
runVoiceAction(
|
||||
action = VoiceAction.Talk,
|
||||
hasMicPermission = hasMicPermission,
|
||||
requestPermission = {
|
||||
pendingAction = VoiceAction.Talk
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
run = { viewModel.setTalkModeEnabled(!talkModeEnabled) },
|
||||
)
|
||||
},
|
||||
onStartDictation = {
|
||||
if (micCooldown) return@VoiceHero
|
||||
runVoiceAction(
|
||||
action = VoiceAction.Dictation,
|
||||
hasMicPermission = hasMicPermission,
|
||||
requestPermission = {
|
||||
pendingAction = VoiceAction.Dictation
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
run = { viewModel.setMicEnabled(!micEnabled) },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if (!hasMicPermission) {
|
||||
VoicePermissionPanel(
|
||||
onRequestPermission = {
|
||||
pendingAction = VoiceAction.Talk
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
VoiceTranscript(
|
||||
entries = activeConversation,
|
||||
showThinking = micIsSending && activeConversation.none { it.role == VoiceConversationRole.Assistant && it.isStreaming },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DictationScreen(
|
||||
liveTranscript: String?,
|
||||
conversation: List<VoiceConversationEntry>,
|
||||
listening: Boolean,
|
||||
sending: Boolean,
|
||||
statusText: String,
|
||||
gatewayStatus: String,
|
||||
onCancel: () -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
val lastUserText = conversation.lastOrNull { it.role == VoiceConversationRole.User }?.text
|
||||
val draftText = liveTranscript?.takeIf { it.isNotBlank() } ?: lastUserText.orEmpty()
|
||||
val speechProviderReady = gatewayStatus.isVoiceGatewayReady()
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(0.82f),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 12.dp), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = draftText.ifBlank { if (sending) "Sending to chat..." else "Start speaking..." },
|
||||
style = ClawTheme.type.title.copy(fontSize = 15.sp, lineHeight = 19.sp),
|
||||
color = if (draftText.isBlank()) ClawTheme.colors.textSubtle else ClawTheme.colors.text,
|
||||
maxLines = 7,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
DictationWaveform(active = listening || sending)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(imageVector = Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(15.dp), tint = if (listening) ClawTheme.colors.success else ClawTheme.colors.textMuted)
|
||||
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(16.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Speech provider", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
sending -> "Sending"
|
||||
speechProviderReady -> "Ready"
|
||||
else -> "Offline"
|
||||
},
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color =
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textMuted
|
||||
},
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textSubtle
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(imageVector = Icons.Default.Info, contentDescription = null, modifier = Modifier.size(16.dp), tint = ClawTheme.colors.textMuted)
|
||||
Text(text = "Tip: stop listening to send the captured turn.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ClawSecondaryButton(text = "Cancel", icon = Icons.Default.Close, onClick = onCancel, modifier = Modifier.weight(0.95f))
|
||||
ClawPrimaryButton(text = if (sending) "Sending" else "Send to Chat", icon = Icons.AutoMirrored.Filled.Send, onClick = onSend, enabled = !sending, modifier = Modifier.weight(1.25f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DictationWaveform(active: Boolean) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
List(48) { index ->
|
||||
val height = if (active) 3 + ((index * 7) % 16) else 3 + (index % 3) * 2
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 2.dp, height = height.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkSessionScreen(
|
||||
entries: List<VoiceConversationEntry>,
|
||||
listening: Boolean,
|
||||
speaking: Boolean,
|
||||
speakerEnabled: Boolean,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onEndTalk: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
|
||||
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Realtime Talk", style = ClawTheme.type.title.copy(fontSize = 14.sp, lineHeight = 17.sp), color = ClawTheme.colors.text)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (speaking || listening) ClawTheme.colors.success else ClawTheme.colors.textSubtle))
|
||||
Text(
|
||||
text =
|
||||
if (speaking) {
|
||||
"OpenClaw speaking"
|
||||
} else if (listening) {
|
||||
"Realtime voice"
|
||||
} else {
|
||||
"Connected"
|
||||
},
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(58.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
TalkWaveform(active = listening || speaking)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = "Live transcript", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
TalkTranscript(entries = entries, modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TalkControl(icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, label = if (speakerEnabled) "Mute" else "Unmute", onClick = onToggleSpeaker)
|
||||
TalkControl(icon = Icons.Default.PhoneDisabled, label = "End", primary = true, onClick = onEndTalk)
|
||||
TalkControl(icon = Icons.Default.GraphicEq, label = "Voice", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkTranscript(
|
||||
entries: List<VoiceConversationEntry>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
if (entries.isEmpty()) {
|
||||
item {
|
||||
TalkTranscriptCard(label = "OpenClaw", text = "Listening for your next turn.", muted = true)
|
||||
}
|
||||
} else {
|
||||
items(entries.takeLast(6), key = { it.id }) { entry ->
|
||||
TalkTranscriptCard(
|
||||
label = if (entry.role == VoiceConversationRole.User) "You" else "OpenClaw",
|
||||
text = if (entry.isStreaming && entry.text.isBlank()) "Listening response..." else entry.text,
|
||||
muted = entry.isStreaming,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkTranscriptCard(
|
||||
label: String,
|
||||
text: String,
|
||||
muted: Boolean = false,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(text = label, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = text, style = ClawTheme.type.body, color = if (muted) ClawTheme.colors.textMuted else ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkControl(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
primary: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = if (primary) ClawTheme.colors.primary else ClawTheme.colors.canvas,
|
||||
contentColor = if (primary) ClawTheme.colors.primaryText else ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (primary) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = label, modifier = Modifier.size(if (primary) 20.dp else 18.dp))
|
||||
}
|
||||
}
|
||||
Text(text = label, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkWaveform(active: Boolean) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
listOf(4, 12, 24, 34, 46, 28, 12, 38, 44, 24, 12, 30, 42, 18, 6).forEachIndexed { index, height ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 3.dp, height = (if (active) height else 6 + index % 4 * 5).dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceHeader(
|
||||
statusText: String,
|
||||
speakerEnabled: Boolean,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onOpenCommand: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "O P E N C L A W",
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
VoiceAvatar(text = "OC")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
VoicePlainIconButton(
|
||||
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
onClick = onToggleSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceAvatar(text: String) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePlainIconButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceHero(
|
||||
gatewayStatus: String,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micEnabled: Boolean,
|
||||
talkModeEnabled: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
micLiveTranscript: String?,
|
||||
onStartTalk: () -> Unit,
|
||||
onStartDictation: () -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
VoiceOrb(
|
||||
active = micEnabled || talkModeEnabled,
|
||||
listening = talkModeListening || voiceCaptureMode == VoiceCaptureMode.ManualMic,
|
||||
speaking = talkModeSpeaking,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (micEnabled || talkModeEnabled) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
talkModeSpeaking -> "OpenClaw is replying"
|
||||
talkModeListening -> "Listening"
|
||||
talkModeEnabled -> "Talk is live"
|
||||
micEnabled -> "Dictation is listening"
|
||||
else -> "Ready to talk"
|
||||
},
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
if (!micLiveTranscript.isNullOrBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Text(
|
||||
text = micLiveTranscript.trim(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
VoiceModeRow(
|
||||
title = if (talkModeEnabled) "End Talk" else "Realtime Talk",
|
||||
subtitle = if (talkModeEnabled) "Conversation is live" else "Natural conversation in real time",
|
||||
icon = if (talkModeEnabled) Icons.Default.PhoneDisabled else Icons.Default.RecordVoiceOver,
|
||||
onClick = onStartTalk,
|
||||
)
|
||||
VoiceModeRow(
|
||||
title = if (micEnabled) "Stop Dictation" else "Dictation",
|
||||
subtitle = if (micEnabled) "Listening for one turn" else "Convert speech to text",
|
||||
icon = if (micEnabled) Icons.Default.MicOff else Icons.Default.TextFields,
|
||||
onClick = onStartDictation,
|
||||
)
|
||||
}
|
||||
|
||||
VoiceProviderCard(gatewayStatus = gatewayStatus)
|
||||
|
||||
VoicePrimaryAction(
|
||||
text = if (talkModeEnabled) "End Talk" else "Start Talk",
|
||||
icon = if (talkModeEnabled) Icons.Default.PhoneDisabled else Icons.Default.Phone,
|
||||
onClick = onStartTalk,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceModeRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(21.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceProviderCard(gatewayStatus: String) {
|
||||
val ready = gatewayStatus.isVoiceGatewayReady()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Provider", style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (ready) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
Text(text = if (ready) "Ready" else "Offline", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePrimaryAction(
|
||||
text: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Text(text = text, modifier = Modifier.padding(start = 8.dp), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceOrb(
|
||||
active: Boolean,
|
||||
listening: Boolean,
|
||||
speaking: Boolean,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(132.dp),
|
||||
shape = CircleShape,
|
||||
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(
|
||||
imageVector =
|
||||
when {
|
||||
speaking -> Icons.Default.RecordVoiceOver
|
||||
listening -> Icons.Default.GraphicEq
|
||||
else -> Icons.Default.Mic
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(38.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Waveform(active = active)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Waveform(active: Boolean) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
listOf(6, 11, 17, 23, 14, 9, 20, 14, 7).forEachIndexed { index, height ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTranscript(
|
||||
entries: List<VoiceConversationEntry>,
|
||||
showThinking: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(entries.size, showThinking) {
|
||||
if (entries.isNotEmpty() || showThinking) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
) {
|
||||
if (showThinking) {
|
||||
item(key = "thinking") {
|
||||
VoiceThinkingCard()
|
||||
}
|
||||
}
|
||||
|
||||
items(entries.asReversed(), key = { it.id }) { entry ->
|
||||
VoiceTurnCard(entry = entry)
|
||||
}
|
||||
|
||||
if (entries.isEmpty() && !showThinking) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = "Live transcript", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 9.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "No transcript yet", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "Your words and OpenClaw replies will appear here.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTurnCard(entry: VoiceConversationEntry) {
|
||||
val isUser = entry.role == VoiceConversationRole.User
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.82f else 0.92f),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = if (isUser) ClawTheme.colors.surfacePressed else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (entry.isStreaming) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(
|
||||
text = if (isUser) "You" else "OpenClaw",
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
)
|
||||
Text(
|
||||
text = if (entry.isStreaming && entry.text.isBlank()) "Listening..." else entry.text,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceThinkingCard() {
|
||||
ClawPanel {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawStatusPill(text = "Sending", status = ClawStatus.Warning)
|
||||
Text(text = "OpenClaw is preparing a response.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePermissionPanel(onRequestPermission: () -> Unit) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawStatusPill(text = "Permission needed", status = ClawStatus.Warning)
|
||||
Text(text = "Microphone access is needed.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "OpenClaw only listens when you start Talk or Dictation.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
ClawSecondaryButton(text = "Enable Microphone", icon = Icons.Default.Mic, onClick = onRequestPermission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class VoiceAction {
|
||||
Talk,
|
||||
Dictation,
|
||||
}
|
||||
|
||||
private fun runVoiceAction(
|
||||
action: VoiceAction,
|
||||
hasMicPermission: Boolean,
|
||||
requestPermission: () -> Unit,
|
||||
run: () -> Unit,
|
||||
) {
|
||||
if (hasMicPermission) {
|
||||
run()
|
||||
} else {
|
||||
requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
private fun voiceStatusLabel(
|
||||
gatewayStatus: String,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micStatusText: String,
|
||||
micQueuedMessages: Int,
|
||||
micIsSending: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
): String =
|
||||
when {
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "OpenClaw is speaking"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Listening"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk is live"
|
||||
micIsSending -> "Sending dictation"
|
||||
voiceCaptureMode == VoiceCaptureMode.ManualMic -> micStatusText.ifBlank { "Listening" }
|
||||
micQueuedMessages > 0 -> "$micQueuedMessages queued"
|
||||
!gatewayStatus.isVoiceGatewayReady() -> "Gateway offline"
|
||||
else -> "Ready to talk"
|
||||
}
|
||||
|
||||
private fun String.isVoiceGatewayReady(): Boolean {
|
||||
val status = lowercase()
|
||||
return !status.contains("offline") && !status.contains("not connected") && !status.contains("failed") && !status.contains("error")
|
||||
}
|
||||
|
||||
private fun String.voiceGatewayLabel(): String = if (isVoiceGatewayReady()) "Connected and ready" else "Gateway not connected"
|
||||
|
||||
private fun Context.hasRecordAudioPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
@@ -96,8 +96,10 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
|
||||
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
|
||||
|
||||
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
|
||||
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
|
||||
val hasStreamingAssistant = activeConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
|
||||
val showThinkingBubble = micIsSending && !hasStreamingAssistant
|
||||
|
||||
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
|
||||
@@ -131,8 +133,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
pendingVoicePermissionAction = null
|
||||
}
|
||||
|
||||
LaunchedEffect(micConversation.size, showThinkingBubble) {
|
||||
val total = micConversation.size + if (showThinkingBubble) 1 else 0
|
||||
LaunchedEffect(voiceCaptureMode, activeConversation.size, showThinkingBubble) {
|
||||
val total = activeConversation.size + if (showThinkingBubble) 1 else 0
|
||||
if (total > 0) {
|
||||
listState.animateScrollToItem(total - 1)
|
||||
}
|
||||
@@ -154,7 +156,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (micConversation.isEmpty() && !showThinkingBubble) {
|
||||
if (activeConversation.isEmpty() && !showThinkingBubble) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
|
||||
@@ -185,7 +187,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
items(items = micConversation, key = { it.id }) { entry ->
|
||||
items(items = activeConversation, key = { it.id }) { entry ->
|
||||
VoiceTurnBubble(entry = entry)
|
||||
}
|
||||
|
||||
@@ -347,10 +349,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "Talk speaking"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Talk listening"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk on"
|
||||
micEnabled || micIsSending || micCooldown -> micStatusText
|
||||
queueCount > 0 -> "$queueCount queued"
|
||||
micIsSending -> "Sending"
|
||||
micCooldown -> "Cooldown"
|
||||
micEnabled -> "Listening"
|
||||
else -> "Mic off"
|
||||
}
|
||||
val stateColor =
|
||||
|
||||
@@ -141,7 +141,7 @@ fun ChatComposer(
|
||||
|
||||
if (!healthOk) {
|
||||
Text(
|
||||
text = "Gateway is offline. Connect first in the Connect tab.",
|
||||
text = "Gateway is offline. Open Settings to reconnect.",
|
||||
style = mobileCallout,
|
||||
color = ai.openclaw.app.ui.mobileWarning,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
|
||||
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
|
||||
internal const val CHAT_IMAGE_MAX_BASE64_CHARS = 300 * 1024
|
||||
private const val CHAT_ATTACHMENT_START_QUALITY = 85
|
||||
private const val CHAT_DECODE_MAX_DIMENSION = 1600
|
||||
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
|
||||
@@ -35,7 +35,7 @@ internal fun loadSizedImageAttachment(
|
||||
if (bitmap == null) {
|
||||
throw IllegalStateException("unsupported attachment")
|
||||
}
|
||||
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
|
||||
val maxBytes = (CHAT_IMAGE_MAX_BASE64_CHARS / 4) * 3
|
||||
val encoded =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = bitmap.width,
|
||||
|
||||
@@ -75,10 +75,13 @@ import org.commonmark.node.SoftLineBreak
|
||||
import org.commonmark.node.StrongEmphasis
|
||||
import org.commonmark.node.ThematicBreak
|
||||
import org.commonmark.parser.Parser
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
import org.commonmark.node.Image as MarkdownImage
|
||||
import org.commonmark.node.Text as MarkdownTextNode
|
||||
|
||||
private const val LIST_INDENT_DP = 14
|
||||
private const val DATA_IMAGE_HEADER_MAX_CHARS = 64
|
||||
private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$")
|
||||
|
||||
private val markdownParser: Parser by lazy {
|
||||
@@ -547,15 +550,13 @@ private fun AnnotatedString.Builder.appendLinkNode(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
if (destination.isEmpty()) {
|
||||
withStyle(linkStyle) {
|
||||
appendInlineNode(
|
||||
link.firstChild,
|
||||
inlineCodeBg = inlineCodeBg,
|
||||
inlineCodeColor = inlineCodeColor,
|
||||
linkColor = linkColor,
|
||||
)
|
||||
}
|
||||
if (destination.isEmpty() || !isSafeMarkdownLinkDestination(destination)) {
|
||||
appendInlineNode(
|
||||
link.firstChild,
|
||||
inlineCodeBg = inlineCodeBg,
|
||||
inlineCodeColor = inlineCodeColor,
|
||||
linkColor = linkColor,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -569,6 +570,14 @@ private fun AnnotatedString.Builder.appendLinkNode(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSafeMarkdownLinkDestination(destination: String): Boolean {
|
||||
val scheme =
|
||||
runCatching { URI(destination).scheme?.lowercase(Locale.US) }
|
||||
.getOrNull()
|
||||
?: return false
|
||||
return scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
internal fun buildChatInlineMarkdown(
|
||||
text: String,
|
||||
linkColor: Color = Color.Blue,
|
||||
@@ -606,9 +615,10 @@ private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? {
|
||||
return parseDataImageDestination(only.destination)
|
||||
}
|
||||
|
||||
private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
||||
internal fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
||||
val raw = destination?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return null
|
||||
if (raw.length > CHAT_IMAGE_MAX_BASE64_CHARS + DATA_IMAGE_HEADER_MAX_CHARS) return null
|
||||
val match = dataImageRegex.matchEntire(raw) ?: return null
|
||||
val subtype =
|
||||
match.groupValues
|
||||
@@ -623,6 +633,7 @@ private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (base64.isEmpty()) return null
|
||||
if (base64.length > CHAT_IMAGE_MAX_BASE64_CHARS) return null
|
||||
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
|
||||
}
|
||||
|
||||
@@ -650,7 +661,7 @@ private data class TableRenderRow(
|
||||
val cells: List<AnnotatedString>,
|
||||
)
|
||||
|
||||
private data class ParsedDataImage(
|
||||
internal data class ParsedDataImage(
|
||||
val mimeType: String,
|
||||
val base64: String,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
val errorText by viewModel.chatError.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatDraft by viewModel.chatDraft.collectAsState()
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
val attachments = remember { mutableStateListOf<PendingImageAttachment>() }
|
||||
val pickImages =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
||||
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val next =
|
||||
uris.take(8).mapNotNull { uri ->
|
||||
try {
|
||||
loadSizedImageAttachment(resolver, uri)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
attachments.addAll(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
|
||||
if (loadSessionKey != null) {
|
||||
viewModel.loadChat(loadSessionKey)
|
||||
}
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {
|
||||
val accepted =
|
||||
dispatchPendingAssistantAutoSend(
|
||||
pendingPrompt = pendingAssistantAutoSend,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
) { prompt ->
|
||||
viewModel.sendChatAwaitAcceptance(message = prompt, thinking = thinkingLevel, attachments = emptyList())
|
||||
}
|
||||
if (accepted) {
|
||||
viewModel.clearPendingAssistantAutoSend()
|
||||
}
|
||||
}
|
||||
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(chatDraft) {
|
||||
val draft = chatDraft?.trim()?.ifEmpty { null } ?: return@LaunchedEffect
|
||||
input = draft
|
||||
viewModel.clearChatDraft()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 18.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
ChatHeader(
|
||||
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onBack = onBack,
|
||||
onMore = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
},
|
||||
)
|
||||
|
||||
errorText?.takeIf { it.isNotBlank() }?.let { error ->
|
||||
ChatNotice(title = "Chat needs attention", body = userFacingChatError(error))
|
||||
}
|
||||
|
||||
ChatMessageList(
|
||||
messages = messages,
|
||||
pendingRunCount = pendingRunCount,
|
||||
pendingToolCalls = pendingToolCalls,
|
||||
streamingAssistantText = streamingAssistantText,
|
||||
healthOk = healthOk,
|
||||
onStarterPrompt = { prompt -> input = prompt },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
ChatComposer(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
attachments = attachments,
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onThinkingLevelChange = viewModel::setChatThinkingLevel,
|
||||
onPickImages = { pickImages.launch("image/*") },
|
||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||
onVoice = onVoice,
|
||||
onAbort = viewModel::abortChat,
|
||||
onSend = {
|
||||
val message = input.trim()
|
||||
if (message.isEmpty() && attachments.isEmpty()) return@ChatComposer
|
||||
val outgoing =
|
||||
attachments.map { attachment ->
|
||||
OutgoingAttachment(
|
||||
type = "image",
|
||||
mimeType = attachment.mimeType,
|
||||
fileName = attachment.fileName,
|
||||
base64 = attachment.base64,
|
||||
)
|
||||
}
|
||||
input = ""
|
||||
attachments.clear()
|
||||
scope.launch {
|
||||
viewModel.sendChat(message = message, thinking = thinkingLevel, attachments = outgoing)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionTitle: String,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onBack: () -> Unit,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
HeaderIcon(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Text(
|
||||
text = sessionTitle,
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
ModelPill(
|
||||
text =
|
||||
when {
|
||||
pendingRunCount > 0 -> "Working"
|
||||
healthOk -> "auto"
|
||||
else -> "offline"
|
||||
},
|
||||
status =
|
||||
when {
|
||||
pendingRunCount > 0 -> ClawStatus.Warning
|
||||
healthOk -> ClawStatus.Neutral
|
||||
else -> ClawStatus.Danger
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelPill(
|
||||
text: String,
|
||||
status: ClawStatus,
|
||||
) {
|
||||
val borderColor =
|
||||
if (status == ClawStatus.Warning) {
|
||||
ClawTheme.colors.warning
|
||||
} else {
|
||||
ClawTheme.colors.border
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 7.dp, vertical = 1.5.dp),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderIcon(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatMessageList(
|
||||
messages: List<ChatMessage>,
|
||||
pendingRunCount: Int,
|
||||
pendingToolCalls: List<ChatPendingToolCall>,
|
||||
streamingAssistantText: String?,
|
||||
healthOk: Boolean,
|
||||
onStarterPrompt: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val displayMessages = remember(messages) { messages.asReversed() }
|
||||
val stream = streamingAssistantText?.trim()
|
||||
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
|
||||
listState.animateScrollToItem(index = 0)
|
||||
}
|
||||
LaunchedEffect(stream) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
listState.scrollToItem(index = 0)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
contentPadding = PaddingValues(top = 6.dp, bottom = 3.dp),
|
||||
) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatBubble(role = "assistant", live = true, content = listOf(ChatMessageContent(text = stream)), timestampMs = null)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
item(key = "tools") {
|
||||
ToolBubble(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "thinking") {
|
||||
ChatThinkingBubble()
|
||||
}
|
||||
}
|
||||
|
||||
items(items = displayMessages, key = { it.id }) { message ->
|
||||
ChatBubble(role = message.role, live = false, content = message.content, timestampMs = message.timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && stream.isNullOrBlank()) {
|
||||
EmptyChatHint(healthOk = healthOk, onStarterPrompt = onStarterPrompt, modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyChatHint(
|
||||
healthOk: Boolean,
|
||||
onStarterPrompt: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(text = if (healthOk) "Ready when you are" else "Gateway offline", style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp), color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text =
|
||||
if (healthOk) {
|
||||
"Start with a prompt, or use voice."
|
||||
} else {
|
||||
"Reconnect from Settings to send messages."
|
||||
},
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
if (healthOk) {
|
||||
StarterPromptList(onStarterPrompt = onStarterPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StarterPromptList(onStarterPrompt: (String) -> Unit) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
starterPrompts.forEachIndexed { index, prompt ->
|
||||
StarterPromptRow(prompt = prompt, onClick = { onStarterPrompt(prompt.message) })
|
||||
if (index != starterPrompts.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StarterPromptRow(
|
||||
prompt: StarterPrompt,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 54.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(30.dp)
|
||||
.background(ClawTheme.colors.surfacePressed, RoundedCornerShape(ClawTheme.radii.row)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = prompt.mark, style = ClawTheme.type.label, color = ClawTheme.colors.text)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = prompt.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = prompt.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class StarterPrompt(
|
||||
val mark: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
private val starterPrompts =
|
||||
listOf(
|
||||
StarterPrompt(mark = "1", title = "Catch me up", subtitle = "Summarize recent sessions and next steps.", message = "Catch me up on my recent OpenClaw sessions and suggest next steps."),
|
||||
StarterPrompt(mark = "2", title = "Plan the work", subtitle = "Turn a goal into an actionable checklist.", message = "Help me turn this goal into a practical checklist: "),
|
||||
StarterPrompt(mark = "3", title = "Use this phone", subtitle = "Ask OpenClaw to use Android capabilities.", message = "What can you help me do from this phone right now?"),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ChatBubble(
|
||||
role: String,
|
||||
live: Boolean,
|
||||
content: List<ChatMessageContent>,
|
||||
timestampMs: Long?,
|
||||
) {
|
||||
val normalizedRole = role.trim().lowercase(Locale.US)
|
||||
val isUser = normalizedRole == "user"
|
||||
val displayableContent =
|
||||
content.filter { part ->
|
||||
when (part.type) {
|
||||
"text" -> !part.text.isNullOrBlank()
|
||||
else -> part.base64 != null
|
||||
}
|
||||
}
|
||||
if (displayableContent.isEmpty()) return
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
live -> "OpenClaw · Live"
|
||||
isUser -> "You"
|
||||
normalizedRole == "system" -> "System"
|
||||
else -> "OpenClaw"
|
||||
},
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
displayableContent.forEach { part ->
|
||||
if (part.type == "text") {
|
||||
ChatText(text = part.text.orEmpty(), textColor = ClawTheme.colors.text)
|
||||
} else {
|
||||
Text(text = part.fileName ?: "Attachment", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
timestampMs?.let {
|
||||
Text(
|
||||
text = formatChatTimestamp(it),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatText(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
) {
|
||||
if (text.hasMarkdownSyntax()) {
|
||||
ChatMarkdown(text = text, textColor = textColor)
|
||||
} else {
|
||||
Text(
|
||||
text = text,
|
||||
style = ClawTheme.type.body,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawStatusPill(text = "Tools running", status = ClawStatus.Warning)
|
||||
toolCalls.take(4).forEach { tool ->
|
||||
ClawListItem(title = tool.name, subtitle = "OpenClaw is working")
|
||||
}
|
||||
if (toolCalls.size > 4) {
|
||||
Text(text = "+${toolCalls.size - 4} more", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatThinkingBubble() {
|
||||
ClawPanel {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawStatusPill(text = "Thinking", status = ClawStatus.Warning)
|
||||
Text(text = "OpenClaw is preparing a response.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatNotice(
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(6.dp).background(ClawTheme.colors.warning, CircleShape))
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatComposer(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onThinkingLevelChange: (String) -> Unit,
|
||||
onPickImages: () -> Unit,
|
||||
onRemoveAttachment: (String) -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onAbort: () -> Unit,
|
||||
onSend: () -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().imePadding(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (attachments.isNotEmpty()) {
|
||||
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
|
||||
SendButton(
|
||||
enabled = healthOk && pendingRunCount == 0 && (value.trim().isNotEmpty() || attachments.isNotEmpty()),
|
||||
onClick = onSend,
|
||||
)
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
Surface(
|
||||
onClick = onAbort,
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(8.dp).background(ClawTheme.colors.danger, RoundedCornerShape(2.dp)))
|
||||
Text(text = "Stop", style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatContextMeter(
|
||||
thinkingLevel: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.width(178.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(3.dp)
|
||||
.background(ClawTheme.colors.surfacePressed, RoundedCornerShape(999.dp)),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
|
||||
.height(3.dp)
|
||||
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatInputPill(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onPickImages: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Surface(onClick = onPickImages, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = ClawTheme.colors.surfaceRaised, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.AttachFile, contentDescription = "Attach image", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text),
|
||||
cursorBrush = SolidColor(ClawTheme.colors.primary),
|
||||
minLines = 1,
|
||||
maxLines = 4,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) {
|
||||
if (value.isEmpty()) {
|
||||
Text(text = "Message OpenClaw", style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = onVoice,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.Mic, contentDescription = "Voice", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentStrip(
|
||||
attachments: List<PendingImageAttachment>,
|
||||
onRemoveAttachment: (String) -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
attachments.forEach { attachment ->
|
||||
AttachmentChip(fileName = attachment.fileName, onRemove = { onRemoveAttachment(attachment.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentChip(
|
||||
fileName: String,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 9.dp, top = 5.dp, end = 5.dp, bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(text = fileName, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Surface(onClick = onRemove, modifier = Modifier.size(22.dp), shape = CircleShape, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.Close, contentDescription = "Remove attachment", modifier = Modifier.size(13.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentSessionTitle(
|
||||
sessionKey: String,
|
||||
sessions: List<ai.openclaw.app.chat.ChatSessionEntry>,
|
||||
): String {
|
||||
val entry = sessions.firstOrNull { it.key == sessionKey }
|
||||
val name = entry?.displayName?.takeIf { it.isNotBlank() } ?: return "New chat"
|
||||
return friendlySessionName(name)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = if (enabled) ClawTheme.colors.primary else ClawTheme.colors.surfacePressed,
|
||||
contentColor = if (enabled) ClawTheme.colors.primaryText else ClawTheme.colors.textSubtle,
|
||||
border = BorderStroke(1.dp, if (enabled) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun userFacingChatError(error: String): String {
|
||||
val lower = error.lowercase(Locale.US)
|
||||
return when {
|
||||
lower.contains("not connected") -> "Gateway is offline. Open Settings to reconnect."
|
||||
lower.contains("unauthorized") || lower.contains("auth") -> "Gateway authentication needs attention."
|
||||
else -> error
|
||||
}
|
||||
}
|
||||
|
||||
private fun thinkingDisplay(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> "Low"
|
||||
"medium" -> "Medium"
|
||||
"high" -> "High"
|
||||
else -> "Off"
|
||||
}
|
||||
|
||||
private fun thinkingValue(display: String): String =
|
||||
when (display.lowercase(Locale.US)) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
private fun nextThinkingValue(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"off" -> "low"
|
||||
"low" -> "medium"
|
||||
"medium" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
private fun thinkingMeterWidth(value: String): Float =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> 0.34f
|
||||
"medium" -> 0.58f
|
||||
"high" -> 0.82f
|
||||
else -> 0.18f
|
||||
}
|
||||
|
||||
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
|
||||
|
||||
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
|
||||
|
||||
private fun String.hasMarkdownSyntax(): Boolean =
|
||||
any { it == '#' || it == '*' || it == '`' || it == '[' || it == '|' } ||
|
||||
contains("\n- ") ||
|
||||
contains("\n1. ")
|
||||
@@ -71,6 +71,16 @@ internal suspend fun dispatchPendingAssistantAutoSend(
|
||||
return dispatch(prompt)
|
||||
}
|
||||
|
||||
internal fun resolveInitialChatLoadSessionKey(
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
): String? {
|
||||
val current = sessionKey.trim()
|
||||
val main = mainSessionKey.trim().ifEmpty { "main" }
|
||||
if (current.isNotEmpty() && current != "main" && current != main) return null
|
||||
return main
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
@@ -87,7 +97,10 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat(mainSessionKey)
|
||||
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
|
||||
if (loadSessionKey != null) {
|
||||
viewModel.loadChat(loadSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal enum class ClawStatus {
|
||||
Neutral,
|
||||
Success,
|
||||
Warning,
|
||||
Danger,
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.lg),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(ClawTheme.colors.canvas)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawSectionHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawPrimaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
icon: ImageVector? = null,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
disabledContainerColor = ClawTheme.colors.surfacePressed,
|
||||
disabledContentColor = ClawTheme.colors.textSubtle,
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(text = text, style = ClawTheme.type.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawSecondaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
icon: ImageVector? = null,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = if (enabled) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface,
|
||||
contentColor = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
|
||||
border = BorderStroke(1.dp, if (enabled) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
}
|
||||
Text(text = text, style = ClawTheme.type.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = if (enabled) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface,
|
||||
contentColor = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawStatusPill(
|
||||
text: String,
|
||||
status: ClawStatus,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = ClawTheme.colors
|
||||
val (dotColor, backgroundColor) =
|
||||
when (status) {
|
||||
ClawStatus.Neutral -> colors.textSubtle to colors.surfaceRaised
|
||||
ClawStatus.Success -> colors.success to colors.successSoft
|
||||
ClawStatus.Warning -> colors.warning to colors.warningSoft
|
||||
ClawStatus.Danger -> colors.danger to colors.dangerSoft
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = backgroundColor,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dotColor),
|
||||
)
|
||||
Text(text = text, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawPill(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val surfaceModifier =
|
||||
if (onClick == null) {
|
||||
modifier
|
||||
} else {
|
||||
modifier.clickable(onClick = onClick)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = surfaceModifier,
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = if (selected) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, if (selected) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
style = ClawTheme.type.caption,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun <T> ClawListPanel(
|
||||
items: List<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
row: @Composable (T) -> Unit,
|
||||
) {
|
||||
ClawPanel(modifier = modifier, contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
ClawSeparatedColumn(items = items, row = row)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun <T> ClawSeparatedColumn(
|
||||
items: List<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
row: @Composable (T) -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
items.forEachIndexed { index, item ->
|
||||
row(item)
|
||||
if (index != items.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawDetailRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
leading: @Composable () -> Unit,
|
||||
trailing: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
leading()
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawTextBadge(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawIconBadge(
|
||||
icon: ImageVector,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawListItem(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
metadata: String? = null,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val rowModifier =
|
||||
if (onClick == null) {
|
||||
modifier
|
||||
} else {
|
||||
modifier.clickable(onClick = onClick)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
rowModifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = ClawTheme.spacing.touchTarget)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.padding(horizontal = 2.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
leading?.invoke()
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (metadata != null) {
|
||||
Text(text = metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1)
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawSegmentedControl(
|
||||
options: List<String>,
|
||||
selected: String,
|
||||
onSelect: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.pill))
|
||||
.border(1.dp, ClawTheme.colors.border, RoundedCornerShape(ClawTheme.radii.pill))
|
||||
.padding(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
val active = option == selected
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.pill))
|
||||
.background(if (active) ClawTheme.colors.primary else Color.Transparent)
|
||||
.clickable { onSelect(option) }
|
||||
.padding(horizontal = 9.dp, vertical = 7.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = option,
|
||||
style = ClawTheme.type.caption,
|
||||
color = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.control))
|
||||
.background(ClawTheme.colors.surfaceRaised)
|
||||
.border(1.dp, ClawTheme.colors.border, RoundedCornerShape(ClawTheme.radii.control))
|
||||
.padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text),
|
||||
cursorBrush = SolidColor(ClawTheme.colors.primary),
|
||||
minLines = minLines,
|
||||
decorationBox = { innerTextField ->
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
if (value.isEmpty()) {
|
||||
Text(text = placeholder, style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawComponentShowcase(modifier: Modifier = Modifier) {
|
||||
var selected by rememberSaveable { mutableStateOf("Chat") }
|
||||
var prompt by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
ClawScaffold(modifier = modifier) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
ClawTopBar(
|
||||
title = "OpenClaw",
|
||||
subtitle = "Local command center",
|
||||
navigation = { ClawAvatarMark(text = "OC") },
|
||||
actions = {
|
||||
ClawIconButton(icon = Icons.Default.Search, contentDescription = "Search", onClick = {})
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "OpenClaw", style = ClawTheme.type.display, color = ClawTheme.colors.text)
|
||||
Text(text = "Design system prototype", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
ClawStatusPill(text = "Connected", status = ClawStatus.Success)
|
||||
}
|
||||
|
||||
ClawSegmentedControl(
|
||||
options = listOf("Chat", "Voice", "Sessions"),
|
||||
selected = selected,
|
||||
onSelect = { selected = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ClawSectionHeader(title = "Sessions")
|
||||
ClawListItem(
|
||||
title = "Testing testing 1 2 3",
|
||||
subtitle = "14 messages · Android",
|
||||
metadata = "now",
|
||||
)
|
||||
ClawListItem(
|
||||
title = "Provider setup",
|
||||
subtitle = "OpenClaw gateway",
|
||||
metadata = "8m",
|
||||
)
|
||||
}
|
||||
|
||||
ClawTextField(value = prompt, onValueChange = { prompt = it }, placeholder = "Ask OpenClaw anything", minLines = 3)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawPrimaryButton(text = "Start Chat", onClick = {}, modifier = Modifier.weight(1f))
|
||||
ClawSecondaryButton(text = "Voice", onClick = {}, modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawPill(text = "Realtime", selected = true)
|
||||
ClawPill(text = "Dictation")
|
||||
ClawPill(text = "Screen")
|
||||
}
|
||||
|
||||
ClawEmptyState(
|
||||
title = "Nothing needs your attention",
|
||||
body = "OpenClaw will surface approvals, failed jobs, and channel issues here.",
|
||||
)
|
||||
|
||||
ClawBottomNav(
|
||||
items =
|
||||
listOf(
|
||||
ClawNavItem(key = "overview", label = "Home", icon = Icons.Default.Home),
|
||||
ClawNavItem(key = "chat", label = "Chat", icon = Icons.Default.ChatBubble),
|
||||
ClawNavItem(key = "voice", label = "Voice", icon = Icons.Default.Mic),
|
||||
ClawNavItem(key = "settings", label = "Settings", icon = Icons.Default.Settings),
|
||||
),
|
||||
selectedKey = "chat",
|
||||
onSelect = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Immutable
|
||||
internal data class ClawNavItem(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun ClawTopBar(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
navigation: (@Composable () -> Unit)? = null,
|
||||
actions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
navigation?.invoke()
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
actions?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawBottomNav(
|
||||
items: List<ClawNavItem>,
|
||||
selectedKey: String,
|
||||
onSelect: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val safeInsets = WindowInsets.navigationBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Bottom)
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
ClawBottomNavItem(
|
||||
item = item,
|
||||
selected = item.key == selectedKey,
|
||||
onClick = { onSelect(item.key) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClawBottomNavItem(
|
||||
item: ClawNavItem,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textSubtle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Icon(imageVector = item.icon, contentDescription = item.label, modifier = Modifier.size(18.dp))
|
||||
Text(text = item.label, style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawAvatarMark(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(38.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview(
|
||||
name = "OpenClaw Design System",
|
||||
showBackground = true,
|
||||
backgroundColor = 0xFF030303,
|
||||
)
|
||||
@Composable
|
||||
private fun ClawComponentShowcasePreview() {
|
||||
ClawDesignTheme {
|
||||
ClawComponentShowcase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun ClawPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(9.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawSheetSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(18.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawEmptyState(
|
||||
title: String,
|
||||
body: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
ClawPanel(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawLoadingState(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ClawPanel(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
CircularProgressIndicator(color = ClawTheme.colors.primary, strokeWidth = 2.dp)
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawErrorState(
|
||||
title: String,
|
||||
body: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
ClawPanel(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawStatusPill(text = "Needs attention", status = ClawStatus.Danger)
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user