Compare commits
380 Commits
openrouter
...
dev/ci-add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4aefa3937 | ||
|
|
a4d5c7f673 | ||
|
|
9a3f62cb86 | ||
|
|
1007d71f0c | ||
|
|
9f703a44dc | ||
|
|
85ed6c7fa4 | ||
|
|
ad4dd0422e | ||
|
|
4ba9809f18 | ||
|
|
80d42eb0ba | ||
|
|
2b6cf03b47 | ||
|
|
88ffad1c4f | ||
|
|
875324e7c7 | ||
|
|
2d7428a7f2 | ||
|
|
47596257ea | ||
|
|
ab3045cb48 | ||
|
|
aaddbdae52 | ||
|
|
8d0e7997c8 | ||
|
|
31a7e4f937 | ||
|
|
c5194d8148 | ||
|
|
43c0a7fe1c | ||
|
|
e78ae48e69 | ||
|
|
4c1da23a71 | ||
|
|
3d2fe9284e | ||
|
|
a3b5f1b15c | ||
|
|
d1dc60774b | ||
|
|
d90cac990c | ||
|
|
0dd7033521 | ||
|
|
c80a09fc2f | ||
|
|
f831c48e56 | ||
|
|
c237a82b42 | ||
|
|
94b2fc14f2 | ||
|
|
0daf416908 | ||
|
|
dca8cf958b | ||
|
|
93bf75279f | ||
|
|
fe308a3aa1 | ||
|
|
7be921c434 | ||
|
|
5163833be5 | ||
|
|
d898ad6807 | ||
|
|
677450cd9b | ||
|
|
ac5944cde7 | ||
|
|
3b768a2851 | ||
|
|
2c8af78d20 | ||
|
|
48b0fd8d88 | ||
|
|
40425db0f1 | ||
|
|
6965a2cc9d | ||
|
|
e3d3893d5d | ||
|
|
4216449405 | ||
|
|
5842bcaaf7 | ||
|
|
991cf4d7fe | ||
|
|
578a6e27aa | ||
|
|
0a1f4f666a | ||
|
|
c7aec0660e | ||
|
|
1bf9f237f7 | ||
|
|
134c03a903 | ||
|
|
18b480dd3e | ||
|
|
0b51f0d762 | ||
|
|
7a9deb2400 | ||
|
|
3997316fb0 | ||
|
|
360851366f | ||
|
|
c75275f109 | ||
|
|
50e687d17d | ||
|
|
4a59b7786b | ||
|
|
8a352c8f9d | ||
|
|
b40da2cb7a | ||
|
|
72245855e5 | ||
|
|
7b2a221212 | ||
|
|
ac0c2f260f | ||
|
|
d2aee7da68 | ||
|
|
717129f7f9 | ||
|
|
b1430aaaca | ||
|
|
bccdc95a9b | ||
|
|
328b69be17 | ||
|
|
f16e32b73d | ||
|
|
8abce8a84d | ||
|
|
c448e5da6f | ||
|
|
6c42d34610 | ||
|
|
ee1ec3faba | ||
|
|
a459e237e8 | ||
|
|
47538bca4d | ||
|
|
05b28c147d | ||
|
|
0a48592475 | ||
|
|
3ad7958365 | ||
|
|
34a58b839c | ||
|
|
ec0728b357 | ||
|
|
0c7fa2b0d5 | ||
|
|
5f6e1c19bd | ||
|
|
7e005acd3c | ||
|
|
8ba1387ba2 | ||
|
|
7e32f1ce20 | ||
|
|
2267d58afc | ||
|
|
02842bef91 | ||
|
|
57326f72e6 | ||
|
|
370bbcd89b | ||
|
|
6f4665dda3 | ||
|
|
2d15dd757d | ||
|
|
861725fba1 | ||
|
|
de7b2ba7d5 | ||
|
|
7db839544d | ||
|
|
5958e5693c | ||
|
|
bc88e58fcf | ||
|
|
141f551a4c | ||
|
|
6ff209e932 | ||
|
|
b0befb5f5d | ||
|
|
40e23b05f7 | ||
|
|
313e2f2e85 | ||
|
|
68393bfa36 | ||
|
|
155dfa93e5 | ||
|
|
db31c0ccca | ||
|
|
cefd87f355 | ||
|
|
8577d015b2 | ||
|
|
4a5e9f0a4f | ||
|
|
c18452598a | ||
|
|
3299aeb904 | ||
|
|
8fdc0a2841 | ||
|
|
873182ec2d | ||
|
|
b8004a28cc | ||
|
|
6b7d3c3062 | ||
|
|
d4c560853c | ||
|
|
4e1a7cd60c | ||
|
|
4629054403 | ||
|
|
93b450349f | ||
|
|
2ca78a8aed | ||
|
|
db8e9b37c6 | ||
|
|
ad13c265ba | ||
|
|
7159d3b254 | ||
|
|
d6c088910b | ||
|
|
821520a057 | ||
|
|
f32eeae3bc | ||
|
|
7c951b01ab | ||
|
|
4fc4c5256a | ||
|
|
eb80b9acb3 | ||
|
|
ea237115a9 | ||
|
|
679bb087db | ||
|
|
1473fb19a5 | ||
|
|
01db1dde1a | ||
|
|
a13efbe2b5 | ||
|
|
6ac5dd2c0e | ||
|
|
eef247b7a4 | ||
|
|
9e0030b75f | ||
|
|
ddedb56c01 | ||
|
|
547374220c | ||
|
|
c8f4bca0c4 | ||
|
|
3011b00d39 | ||
|
|
cf95b2f3f4 | ||
|
|
203e3804b3 | ||
|
|
34424ce536 | ||
|
|
675c26b2b0 | ||
|
|
8b8451231c | ||
|
|
460808e0c8 | ||
|
|
f2c5c847bd | ||
|
|
c0b267a03a | ||
|
|
8860d2ed7f | ||
|
|
a4d1af1b11 | ||
|
|
5031b283a5 | ||
|
|
bdb90ea4ee | ||
|
|
d6cde28c8e | ||
|
|
f26cc60872 | ||
|
|
1ee1522daa | ||
|
|
34e78a7054 | ||
|
|
44bbe09bee | ||
|
|
1008c28f5a | ||
|
|
0621d0e9e8 | ||
|
|
3b40227bc6 | ||
|
|
d84eb46467 | ||
|
|
7af00f040a | ||
|
|
4d30f97407 | ||
|
|
ff948a6dd7 | ||
|
|
ad759c9446 | ||
|
|
9ccbd57016 | ||
|
|
52c9d3480f | ||
|
|
8524666454 | ||
|
|
517a8eafe5 | ||
|
|
c8e67ad5d5 | ||
|
|
54ddbc4660 | ||
|
|
7f95cdac78 | ||
|
|
cfdc551346 | ||
|
|
f895c9fba1 | ||
|
|
22927b0834 | ||
|
|
385a7eba33 | ||
|
|
a6fd76efeb | ||
|
|
21f8c3db18 | ||
|
|
392bbddf29 | ||
|
|
0cd47d830f | ||
|
|
90b4e54354 | ||
|
|
4434cae565 | ||
|
|
5e025c4ba3 | ||
|
|
a13ff55bd9 | ||
|
|
96abc1c864 | ||
|
|
38e6da1fe0 | ||
|
|
a42e3cb78a | ||
|
|
bebf323775 | ||
|
|
5d82c82313 | ||
|
|
43590d8287 | ||
|
|
dae0e2b325 | ||
|
|
3e14192730 | ||
|
|
dbaf0a8ae2 | ||
|
|
5bd63b012c | ||
|
|
3fae903863 | ||
|
|
d5f8208c38 | ||
|
|
42c690632d | ||
|
|
1d17630dc6 | ||
|
|
2b1da4f5d8 | ||
|
|
aaeecc8c8d | ||
|
|
eab0a07f71 | ||
|
|
01ce144fa9 | ||
|
|
718dba8cb6 | ||
|
|
9822985ea6 | ||
|
|
fb5280e1b5 | ||
|
|
009abd306a | ||
|
|
8c53dfb74f | ||
|
|
7bf4080608 | ||
|
|
1de05ad068 | ||
|
|
2196456d4a | ||
|
|
6f200ea77f | ||
|
|
5b0851ebd8 | ||
|
|
19ecdce275 | ||
|
|
18652d181b | ||
|
|
f633a8cb22 | ||
|
|
78f8a29071 | ||
|
|
78fd194722 | ||
|
|
b2361292e7 | ||
|
|
57566c5e4d | ||
|
|
da6de49815 | ||
|
|
6341819d74 | ||
|
|
c396877dd9 | ||
|
|
79d00e20db | ||
|
|
f8d2534062 | ||
|
|
6fb8d8850e | ||
|
|
246896d64b | ||
|
|
64df61f697 | ||
|
|
ef4949b936 | ||
|
|
1409943863 | ||
|
|
3f82daefd8 | ||
|
|
ab9f06f4ff | ||
|
|
0bb0dfc9bc | ||
|
|
511c656cbc | ||
|
|
3a03e38378 | ||
|
|
30ac80b96b | ||
|
|
a749db9820 | ||
|
|
fa4b28d7af | ||
|
|
5292367324 | ||
|
|
35eb40a700 | ||
|
|
6fdb136688 | ||
|
|
44d1aa31f3 | ||
|
|
7b3d23b703 | ||
|
|
efc9d0a498 | ||
|
|
089d03453d | ||
|
|
4a5d368926 | ||
|
|
1c6b25ddbb | ||
|
|
9f16de2533 | ||
|
|
d2ff28dda7 | ||
|
|
f04e84f194 | ||
|
|
5af322f710 | ||
|
|
b64c1a56a1 | ||
|
|
41a4f1200b | ||
|
|
202c554d09 | ||
|
|
16349b6e93 | ||
|
|
efb4a34be4 | ||
|
|
e4b084c762 | ||
|
|
3e6c623cfe | ||
|
|
9c4eab69cc | ||
|
|
9c5941ba46 | ||
|
|
41d2993f7b | ||
|
|
d3ba57b7d7 | ||
|
|
6b4b6049b4 | ||
|
|
bbe9cb3022 | ||
|
|
61a7fc5e0e | ||
|
|
e895e85f54 | ||
|
|
e59eb814bd | ||
|
|
df55eeacdb | ||
|
|
fd8f8843bd | ||
|
|
a9bb96ade3 | ||
|
|
f1cbe7db1d | ||
|
|
95cd2210f9 | ||
|
|
539a15e63f | ||
|
|
4df4435c45 | ||
|
|
d41acf99a6 | ||
|
|
efe2a464af | ||
|
|
66d8117d44 | ||
|
|
0223416c61 | ||
|
|
2483f26c23 | ||
|
|
4027b3583e | ||
|
|
a3ec2d0734 | ||
|
|
9f03791aa9 | ||
|
|
f52ca0a712 | ||
|
|
ddccfd3ec1 | ||
|
|
f60eae83fa | ||
|
|
5935c4d23d | ||
|
|
1c4db91593 | ||
|
|
9d2066bd53 | ||
|
|
f57e70912c | ||
|
|
a7f4a53ce8 | ||
|
|
8f3bfbd1c4 | ||
|
|
f8dfd034f5 | ||
|
|
fc40ba8e7e | ||
|
|
1f2f79a7a7 | ||
|
|
6e09c1142e | ||
|
|
27677dd8bd | ||
|
|
be4f7ef361 | ||
|
|
6b83d82e82 | ||
|
|
6fb2d3d7d7 | ||
|
|
425003417d | ||
|
|
a8893094ea | ||
|
|
a03d852d65 | ||
|
|
e3b85b9829 | ||
|
|
3014a91b07 | ||
|
|
981de05181 | ||
|
|
9950440cf6 | ||
|
|
80d8fe7786 | ||
|
|
b37626ce6b | ||
|
|
1ee57cf727 | ||
|
|
4322ca6f4a | ||
|
|
afbb1af6c5 | ||
|
|
600c46b5a4 | ||
|
|
7d5ca1176d | ||
|
|
5915d479dc | ||
|
|
30098b04d7 | ||
|
|
f72214725d | ||
|
|
9bef525944 | ||
|
|
edd6289f26 | ||
|
|
d0b98c75e5 | ||
|
|
e332a717a8 | ||
|
|
23cfcd60df | ||
|
|
465536e811 | ||
|
|
3d1c3b78ec | ||
|
|
1861e76360 | ||
|
|
c248da0317 | ||
|
|
b7f4755020 | ||
|
|
3e82cbd55b | ||
|
|
11a968f5c3 | ||
|
|
5d8c665baf | ||
|
|
9df78b3379 | ||
|
|
20578da204 | ||
|
|
564fe6f089 | ||
|
|
dd8373a424 | ||
|
|
9be3c27bb7 | ||
|
|
e12184661e | ||
|
|
3a57106c1e | ||
|
|
2c30ba400b | ||
|
|
5d3af3bc62 | ||
|
|
e9f182def7 | ||
|
|
1b31e2f345 | ||
|
|
58d5b39c9a | ||
|
|
157d6d2db7 | ||
|
|
d5593d647c | ||
|
|
83715eca49 | ||
|
|
7dfa99a6f7 | ||
|
|
ac2b71f240 | ||
|
|
578bde1e0d | ||
|
|
e2c03845c7 | ||
|
|
1523ef2494 | ||
|
|
cdec53b22b | ||
|
|
a6afcb4c1d | ||
|
|
2a68bcbeb3 | ||
|
|
c8af8e9555 | ||
|
|
e77988f747 | ||
|
|
96ad19a627 | ||
|
|
fe81b1d712 | ||
|
|
d1ecb46076 | ||
|
|
fff59da962 | ||
|
|
9e3ea2687c | ||
|
|
cfd6b21d0e | ||
|
|
118507953b | ||
|
|
befa421a57 | ||
|
|
e6fdac7bfb | ||
|
|
67f90dae54 | ||
|
|
31face5740 | ||
|
|
0da6de6624 | ||
|
|
0eae9f456c | ||
|
|
561a10c491 | ||
|
|
c6b4de520a | ||
|
|
f49297e2c1 | ||
|
|
966228a6a9 | ||
|
|
5fb8f779ca | ||
|
|
88e29c728c | ||
|
|
a63ec41a7b | ||
|
|
64849e81f5 | ||
|
|
d3bb32273e | ||
|
|
7113dc21a9 | ||
|
|
4ab814fd50 |
185
.agents/skills/merge-pr/SKILL.md
Normal file
185
.agents/skills/merge-pr/SKILL.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
name: merge-pr
|
||||
description: Merge a GitHub PR via squash after /preparepr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success.
|
||||
---
|
||||
|
||||
# Merge PR
|
||||
|
||||
## Overview
|
||||
|
||||
Merge a prepared PR via `gh pr merge --squash` and clean up the worktree after success.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Use `gh pr merge --squash` as the only path to `main`.
|
||||
- Do not run `git push` at all during merge.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/Development/openclaw`, not `~/openclaw`.
|
||||
- Read `.local/review.md` and `.local/prep.md` in the worktree. Do not skip.
|
||||
- Clean up the real worktree directory `.worktrees/pr-<PR>` only after a successful merge.
|
||||
- Expect cleanup to remove `.local/` artifacts.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Ensure `gh pr merge` succeeds.
|
||||
- Ensure PR state is `MERGED`, never `CLOSED`.
|
||||
- Record the merge SHA.
|
||||
- Run cleanup only after merge success.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all merge steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all merge work.
|
||||
|
||||
```sh
|
||||
cd ~/Development/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
|
||||
## Load Local Artifacts (Mandatory)
|
||||
|
||||
Expect these files from earlier steps:
|
||||
|
||||
- `.local/review.md` from `/reviewpr`
|
||||
- `.local/prep.md` from `/preparepr`
|
||||
|
||||
```sh
|
||||
ls -la .local || true
|
||||
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found .local/review.md"
|
||||
sed -n '1,120p' .local/review.md
|
||||
else
|
||||
echo "Missing .local/review.md. Stop and run /reviewpr, then /preparepr."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f .local/prep.md ]; then
|
||||
echo "Found .local/prep.md"
|
||||
sed -n '1,120p' .local/prep.md
|
||||
else
|
||||
echo "Missing .local/prep.md. Stop and run /preparepr first."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,headRefName,baseRefName,headRepository,body --jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
```
|
||||
|
||||
2. Run sanity checks
|
||||
|
||||
Stop if any are true:
|
||||
|
||||
- PR is a draft.
|
||||
- Required checks are failing.
|
||||
- Branch is behind main.
|
||||
|
||||
```sh
|
||||
# Checks
|
||||
gh pr checks <PR>
|
||||
|
||||
# Check behind main
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
git merge-base --is-ancestor origin/main pr-<PR> || echo "PR branch is behind main, run /preparepr"
|
||||
```
|
||||
|
||||
If anything is failing or behind, stop and say to run `/preparepr`.
|
||||
|
||||
3. Merge PR and delete branch
|
||||
|
||||
If checks are still running, use `--auto` to queue the merge.
|
||||
|
||||
```sh
|
||||
# Check status first
|
||||
check_status=$(gh pr checks <PR> 2>&1)
|
||||
if echo "$check_status" | grep -q "pending\|queued"; then
|
||||
echo "Checks still running, using --auto to queue merge"
|
||||
gh pr merge <PR> --squash --delete-branch --auto
|
||||
echo "Merge queued. Monitor with: gh pr checks <PR> --watch"
|
||||
else
|
||||
gh pr merge <PR> --squash --delete-branch
|
||||
fi
|
||||
```
|
||||
|
||||
If merge fails, report the error and stop. Do not retry in a loop.
|
||||
If the PR needs changes beyond what `/preparepr` already did, stop and say to run `/preparepr` again.
|
||||
|
||||
4. Get merge SHA
|
||||
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
echo "merge_sha=$merge_sha"
|
||||
```
|
||||
|
||||
5. Optional comment
|
||||
|
||||
Use a literal multiline string or heredoc for newlines.
|
||||
|
||||
```sh
|
||||
gh pr comment <PR> -F - <<'EOF'
|
||||
Merged via squash.
|
||||
|
||||
- Merge commit: $merge_sha
|
||||
|
||||
Thanks @$contrib!
|
||||
EOF
|
||||
```
|
||||
|
||||
6. Verify PR state is MERGED
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json state --jq .state
|
||||
```
|
||||
|
||||
7. Clean up worktree only on success
|
||||
|
||||
Run cleanup only if step 6 returned `MERGED`.
|
||||
|
||||
```sh
|
||||
cd ~/Development/openclaw
|
||||
|
||||
git worktree remove ".worktrees/pr-<PR>" --force
|
||||
|
||||
git branch -D temp/pr-<PR> 2>/dev/null || true
|
||||
git branch -D pr-<PR> 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not close PRs.
|
||||
- End in MERGED state.
|
||||
- Clean up only after merge success.
|
||||
- Never push to main. Use `gh pr merge --squash` only.
|
||||
- Do not run `git push` at all in this command.
|
||||
4
.agents/skills/merge-pr/agents/openai.yaml
Normal file
4
.agents/skills/merge-pr/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Merge PR"
|
||||
short_description: "Merge GitHub PRs via squash"
|
||||
default_prompt: "Use $merge-pr to merge a GitHub PR via squash after preparation."
|
||||
248
.agents/skills/prepare-pr/SKILL.md
Normal file
248
.agents/skills/prepare-pr/SKILL.md
Normal file
@@ -0,0 +1,248 @@
|
||||
---
|
||||
name: prepare-pr
|
||||
description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /reviewpr. Never merge or push to main.
|
||||
---
|
||||
|
||||
# Prepare PR
|
||||
|
||||
## Overview
|
||||
|
||||
Prepare a PR branch for merge with review fixes, green gates, and an updated head branch.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`. Push only to the PR head branch.
|
||||
- Never run `git push` without specifying remote and branch explicitly. Do not run bare `git push`.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`. Stage only specific files changed.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/openclaw`.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Rebase PR commits onto `origin/main`.
|
||||
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
|
||||
- Run gates and pass.
|
||||
- Commit prep changes.
|
||||
- Push the updated HEAD back to the PR head branch.
|
||||
- Write `.local/prep.md` with a prep summary.
|
||||
- Output exactly: `PR is ready for /mergepr`.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all prep steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all prep work.
|
||||
|
||||
```sh
|
||||
cd ~/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
|
||||
## Load Review Findings (Mandatory)
|
||||
|
||||
```sh
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found review findings from /reviewpr"
|
||||
else
|
||||
echo "Missing .local/review.md. Run /reviewpr first and save findings."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read it
|
||||
sed -n '1,200p' .local/review.md
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta (author, head branch, head repo URL)
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository,body --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
```
|
||||
|
||||
2. Fetch the PR branch tip into a local ref
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
```
|
||||
|
||||
3. Rebase PR commits onto latest main
|
||||
|
||||
```sh
|
||||
# Move worktree to the PR tip first
|
||||
git reset --hard pr-<PR>
|
||||
|
||||
# Rebase onto current main
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
If conflicts happen:
|
||||
|
||||
- Resolve each conflicted file.
|
||||
- Run `git add <resolved_file>` for each file.
|
||||
- Run `git rebase --continue`.
|
||||
|
||||
If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report.
|
||||
|
||||
4. Fix issues from `.local/review.md`
|
||||
|
||||
- Fix all BLOCKER and IMPORTANT items.
|
||||
- NITs are optional.
|
||||
- Keep scope tight.
|
||||
|
||||
Keep a running log in `.local/prep.md`:
|
||||
|
||||
- List which review items you fixed.
|
||||
- List which files you touched.
|
||||
- Note behavior changes.
|
||||
|
||||
5. Update `CHANGELOG.md` if flagged in review
|
||||
|
||||
Check `.local/review.md` section H for guidance.
|
||||
If flagged and user-facing:
|
||||
|
||||
- Check if `CHANGELOG.md` exists.
|
||||
|
||||
```sh
|
||||
ls CHANGELOG.md 2>/dev/null
|
||||
```
|
||||
|
||||
- Follow existing format.
|
||||
- Add a concise entry with PR number and contributor.
|
||||
|
||||
6. Update docs if flagged in review
|
||||
|
||||
Check `.local/review.md` section G for guidance.
|
||||
If flagged, update only docs related to the PR changes.
|
||||
|
||||
7. Commit prep fixes
|
||||
|
||||
Stage only specific files:
|
||||
|
||||
```sh
|
||||
git add <file1> <file2> ...
|
||||
```
|
||||
|
||||
Preferred commit tool:
|
||||
|
||||
```sh
|
||||
committer "fix: <summary> (#<PR>) (thanks @$contrib)" <changed files>
|
||||
```
|
||||
|
||||
If `committer` is not found:
|
||||
|
||||
```sh
|
||||
git commit -m "fix: <summary> (#<PR>) (thanks @$contrib)"
|
||||
```
|
||||
|
||||
8. Run full gates before pushing
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm check
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Require all to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix and rerun cycles. If gates still fail after 3 attempts, stop and report the failures. Do not loop indefinitely.
|
||||
|
||||
9. Push updates back to the PR head branch
|
||||
|
||||
```sh
|
||||
# Ensure remote for PR head exists
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
|
||||
# Use force with lease after rebase
|
||||
# Double check: $head must NOT be "main" or "master"
|
||||
echo "Pushing to branch: $head"
|
||||
if [ "$head" = "main" ] || [ "$head" = "master" ]; then
|
||||
echo "ERROR: head branch is main/master. This is wrong. Stopping."
|
||||
exit 1
|
||||
fi
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
10. Verify PR is not behind main (Mandatory)
|
||||
|
||||
```sh
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>-verify --force
|
||||
git merge-base --is-ancestor origin/main pr-<PR>-verify && echo "PR is up to date with main" || echo "ERROR: PR is still behind main, rebase again"
|
||||
git branch -D pr-<PR>-verify 2>/dev/null || true
|
||||
```
|
||||
|
||||
If still behind main, repeat steps 2 through 9.
|
||||
|
||||
11. Write prep summary artifacts (Mandatory)
|
||||
|
||||
Update `.local/prep.md` with:
|
||||
|
||||
- Current HEAD sha from `git rev-parse HEAD`.
|
||||
- Short bullet list of changes.
|
||||
- Gate results.
|
||||
- Push confirmation.
|
||||
- Rebase verification result.
|
||||
|
||||
Create or overwrite `.local/prep.md` and verify it exists and is non-empty:
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD
|
||||
ls -la .local/prep.md
|
||||
wc -l .local/prep.md
|
||||
```
|
||||
|
||||
12. Output
|
||||
|
||||
Include a diff stat summary:
|
||||
|
||||
```sh
|
||||
git diff --stat origin/main..HEAD
|
||||
git diff --shortstat origin/main..HEAD
|
||||
```
|
||||
|
||||
Report totals: X files changed, Y insertions(+), Z deletions(-).
|
||||
|
||||
If gates passed and push succeeded, print exactly:
|
||||
|
||||
```
|
||||
PR is ready for /mergepr
|
||||
```
|
||||
|
||||
Otherwise, list remaining failures and stop.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree on success. `/mergepr` may reuse it.
|
||||
- Do not run `gh pr merge`.
|
||||
- Never push to main. Only push to the PR head branch.
|
||||
- Run and pass all gates before pushing.
|
||||
4
.agents/skills/prepare-pr/agents/openai.yaml
Normal file
4
.agents/skills/prepare-pr/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Prepare PR"
|
||||
short_description: "Prepare GitHub PRs for merge"
|
||||
default_prompt: "Use $prepare-pr to prep a GitHub PR for merge without merging."
|
||||
228
.agents/skills/review-pr/SKILL.md
Normal file
228
.agents/skills/review-pr/SKILL.md
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
name: review-pr
|
||||
description: Review-only GitHub pull request analysis with the gh CLI. Use when asked to review a PR, provide structured feedback, or assess readiness to land. Do not merge, push, or make code changes you intend to keep.
|
||||
---
|
||||
|
||||
# Review PR
|
||||
|
||||
## Overview
|
||||
|
||||
Perform a thorough review-only PR assessment and return a structured recommendation on readiness for /preparepr.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, always ask. Never auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`, not during review, not ever.
|
||||
- Do not run `git push` at all during review. Treat review as read only.
|
||||
- Do not stop or kill the gateway. Do not run gateway stop commands. Do not kill processes on port 18792.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs, not a plan.
|
||||
|
||||
## Known Failure Modes
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/openclaw`.
|
||||
- Do not stop after printing the checklist. That is not completion.
|
||||
|
||||
## Writing Style for Output
|
||||
|
||||
- Write casual and direct.
|
||||
- Avoid em dashes and en dashes. Use commas or separate sentences.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Run the commands in the worktree and inspect the PR directly.
|
||||
- Produce the structured review sections A through J.
|
||||
- Save the full review to `.local/review.md` inside the worktree.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all review steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all review work.
|
||||
|
||||
```sh
|
||||
cd ~/Development/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
git fetch origin main
|
||||
|
||||
# Reuse existing worktree if it exists, otherwise create new
|
||||
if [ -d "$WORKTREE_DIR" ]; then
|
||||
cd "$WORKTREE_DIR"
|
||||
git checkout temp/pr-<PR> 2>/dev/null || git checkout -b temp/pr-<PR>
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
else
|
||||
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
|
||||
cd "$WORKTREE_DIR"
|
||||
fi
|
||||
|
||||
# Create local scratch space that persists across /reviewpr to /preparepr to /mergepr
|
||||
mkdir -p .local
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
Start on `origin/main` so you can check for existing implementations before looking at PR code.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta and context
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length,body}'
|
||||
```
|
||||
|
||||
2. Check if this already exists in main before looking at the PR branch
|
||||
|
||||
- Identify the core feature or fix from the PR title and description.
|
||||
- Search for existing implementations using keywords from the PR title, changed file paths, and function or component names from the diff.
|
||||
|
||||
```sh
|
||||
# Use keywords from the PR title and changed files
|
||||
rg -n "<keyword_from_pr_title>" -S src packages apps ui || true
|
||||
rg -n "<function_or_component_name>" -S src packages apps ui || true
|
||||
|
||||
git log --oneline --all --grep="<keyword_from_pr_title>" | head -20
|
||||
```
|
||||
|
||||
If it already exists, call it out as a BLOCKER or at least IMPORTANT.
|
||||
|
||||
3. Claim the PR
|
||||
|
||||
Assign yourself so others know someone is reviewing. Skip if the PR looks like spam or is a draft you plan to recommend closing.
|
||||
|
||||
```sh
|
||||
gh_user=$(gh api user --jq .login)
|
||||
gh pr edit <PR> --add-assignee "$gh_user"
|
||||
```
|
||||
|
||||
4. Read the PR description carefully
|
||||
|
||||
Use the body from step 1. Summarize goal, scope, and missing context.
|
||||
|
||||
5. Read the diff thoroughly
|
||||
|
||||
Minimum:
|
||||
|
||||
```sh
|
||||
gh pr diff <PR>
|
||||
```
|
||||
|
||||
If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit.
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
# Show changes without modifying the working tree
|
||||
|
||||
git diff --stat origin/main..pr-<PR>
|
||||
git diff origin/main..pr-<PR>
|
||||
```
|
||||
|
||||
If you want to browse the PR version of files directly, temporarily check out `pr-<PR>` in the worktree. Do not commit or push. Return to `temp/pr-<PR>` and reset to `origin/main` afterward.
|
||||
|
||||
```sh
|
||||
# Use only if needed
|
||||
# git checkout pr-<PR>
|
||||
# ...inspect files...
|
||||
|
||||
git checkout temp/pr-<PR>
|
||||
git reset --hard origin/main
|
||||
```
|
||||
|
||||
6. Validate the change is needed and valuable
|
||||
|
||||
Be honest. Call out low value AI slop.
|
||||
|
||||
7. Evaluate implementation quality
|
||||
|
||||
Review correctness, design, performance, and ergonomics.
|
||||
|
||||
8. Perform a security review
|
||||
|
||||
Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy.
|
||||
|
||||
9. Review tests and verification
|
||||
|
||||
Identify what exists, what is missing, and what would be a minimal regression test.
|
||||
|
||||
10. Check docs
|
||||
|
||||
Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples.
|
||||
|
||||
- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT.
|
||||
- If the PR adds a new feature or config option with no docs, flag as IMPORTANT.
|
||||
- If the change is purely internal with no user-facing impact, skip this.
|
||||
|
||||
11. Check changelog
|
||||
|
||||
Check if `CHANGELOG.md` exists and whether the PR warrants an entry.
|
||||
|
||||
- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT.
|
||||
- Leave the change for /preparepr, only flag it here.
|
||||
|
||||
12. Answer the key question
|
||||
|
||||
Decide if /preparepr can fix issues or the contributor must update the PR.
|
||||
|
||||
13. Save findings to the worktree
|
||||
|
||||
Write the full structured review sections A through J to `.local/review.md`.
|
||||
Create or overwrite the file and verify it exists and is non-empty.
|
||||
|
||||
```sh
|
||||
ls -la .local/review.md
|
||||
wc -l .local/review.md
|
||||
```
|
||||
|
||||
14. Output the structured review
|
||||
|
||||
Produce a review that matches what you saved to `.local/review.md`.
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /preparepr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE)
|
||||
- 1 to 3 sentences.
|
||||
|
||||
B) What changed
|
||||
|
||||
C) What is good
|
||||
|
||||
D) Security findings
|
||||
|
||||
E) Concerns or questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as BLOCKER, IMPORTANT, or NIT.
|
||||
- For each, point to file or area and propose a concrete fix.
|
||||
|
||||
F) Tests
|
||||
|
||||
G) Docs status
|
||||
|
||||
- State if related docs are up to date, missing, or not applicable.
|
||||
|
||||
H) Changelog
|
||||
|
||||
- State if `CHANGELOG.md` needs an entry and which category.
|
||||
|
||||
I) Follow ups (optional)
|
||||
|
||||
J) Suggested PR comment (optional)
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree after review.
|
||||
- Review only, do not merge, do not push.
|
||||
4
.agents/skills/review-pr/agents/openai.yaml
Normal file
4
.agents/skills/review-pr/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Review PR"
|
||||
short_description: "Review GitHub PRs without merging"
|
||||
default_prompt: "Use $review-pr to perform a thorough, review-only GitHub PR review."
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,7 +2,7 @@ blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Onboarding
|
||||
url: https://discord.gg/clawd
|
||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
|
||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in \#help.
|
||||
- name: Support
|
||||
url: https://discord.gg/clawd
|
||||
about: Get help from Krill and the community on Discord in #help.
|
||||
about: Get help from Krill and the community on Discord in \#help.
|
||||
|
||||
69
.github/actions/discord-notify/action.yml
vendored
Normal file
69
.github/actions/discord-notify/action.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Discord Notify
|
||||
description: Send notifications to Discord webhook
|
||||
|
||||
inputs:
|
||||
webhook_url:
|
||||
description: Discord webhook URL
|
||||
required: true
|
||||
title:
|
||||
description: Notification title
|
||||
required: true
|
||||
description:
|
||||
description: Notification description
|
||||
required: true
|
||||
color:
|
||||
description: Embed color (decimal)
|
||||
required: false
|
||||
default: "3447003"
|
||||
username:
|
||||
description: Bot username
|
||||
required: false
|
||||
default: "OpenClaw CI"
|
||||
avatar_url:
|
||||
description: Bot avatar URL
|
||||
required: false
|
||||
default: "https://avatars.githubusercontent.com/u/182880377"
|
||||
timestamp:
|
||||
description: Include timestamp
|
||||
required: false
|
||||
default: "true"
|
||||
fields:
|
||||
description: JSON array of embed fields
|
||||
required: false
|
||||
default: "[]"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Send Discord notification
|
||||
shell: bash
|
||||
run: |
|
||||
TIMESTAMP=""
|
||||
if [ "${{ inputs.timestamp }}" = "true" ]; then
|
||||
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
fi
|
||||
|
||||
# Build JSON payload with jq to handle escaping properly
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg username "${{ inputs.username }}" \
|
||||
--arg avatar_url "${{ inputs.avatar_url }}" \
|
||||
--arg title "${{ inputs.title }}" \
|
||||
--arg description "${{ inputs.description }}" \
|
||||
--argjson color "${{ inputs.color }}" \
|
||||
--argjson fields '${{ inputs.fields }}' \
|
||||
--arg timestamp "$TIMESTAMP" \
|
||||
--argjson add_timestamp "${{ inputs.timestamp == 'true' }}" \
|
||||
'{
|
||||
username: $username,
|
||||
avatar_url: $avatar_url,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
description: $description,
|
||||
color: $color,
|
||||
fields: $fields
|
||||
} + (if $add_timestamp then {timestamp: $timestamp} else {} end)]
|
||||
}')
|
||||
|
||||
curl -sS -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${{ inputs.webhook_url }}"
|
||||
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
@@ -9,6 +9,12 @@
|
||||
- "src/discord/**"
|
||||
- "extensions/discord/**"
|
||||
- "docs/channels/discord.md"
|
||||
"channel: feishu":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/feishu/**"
|
||||
- "extensions/feishu/**"
|
||||
- "docs/channels/feishu.md"
|
||||
"channel: googlechat":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
136
.github/workflows/ci.yml
vendored
136
.github/workflows/ci.yml
vendored
@@ -2,8 +2,13 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
install-check:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -89,9 +94,6 @@ jobs:
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run
|
||||
- runtime: bun
|
||||
task: build
|
||||
command: bunx tsc -p tsconfig.json --noEmit false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -188,7 +190,9 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
CLAWDBOT_TEST_WORKERS: 1
|
||||
# Keep total concurrency predictable on the 4 vCPU runner:
|
||||
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
|
||||
OPENCLAW_TEST_WORKERS: 2
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -211,6 +215,25 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
# Defender sometimes intercepts process spawning (vitest workers). If this fails
|
||||
# (eg hardened images), keep going and rely on worker limiting above.
|
||||
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
|
||||
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
|
||||
Write-Host "Defender exclusions applied."
|
||||
} catch {
|
||||
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -272,15 +295,13 @@ jobs:
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
checks-macos:
|
||||
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
|
||||
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
|
||||
# running 4 separate jobs per PR (as before) starved the queue. One job
|
||||
# per PR allows 5 PRs to run macOS checks simultaneously.
|
||||
macos:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -300,6 +321,7 @@ jobs:
|
||||
done
|
||||
exit 1
|
||||
|
||||
# --- Node/pnpm setup (for TS tests) ---
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -339,71 +361,20 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
# --- Run all checks sequentially (fast gates first) ---
|
||||
- name: TS tests (macOS)
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
- task: build
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
- task: test
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
run: pnpm test
|
||||
|
||||
# --- Xcode/Swift setup ---
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: |
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
@@ -411,8 +382,35 @@ jobs:
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
- name: Swift lint
|
||||
run: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Swift test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
|
||||
349
.github/workflows/deployment-strategy.yml
vendored
Normal file
349
.github/workflows/deployment-strategy.yml
vendored
Normal file
@@ -0,0 +1,349 @@
|
||||
name: Deployment Strategy
|
||||
|
||||
# Reusable deployment workflow for staged releases
|
||||
#
|
||||
# Deployment targets by stage:
|
||||
# - alpha: npm @alpha tag only
|
||||
# - beta: npm @beta tag + Docker (ghcr.io) beta tag
|
||||
# - stable: npm @latest + Docker latest + multi-arch manifest
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
deployment_stage:
|
||||
description: "Deployment stage: alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
app_version:
|
||||
description: "Version of the application to deploy"
|
||||
required: true
|
||||
type: string
|
||||
source_branch:
|
||||
description: "Source branch for deployment"
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
deployment_status:
|
||||
description: "Status of the deployment"
|
||||
value: ${{ jobs.deploy-summary.outputs.status }}
|
||||
npm_url:
|
||||
description: "npm package URL"
|
||||
value: ${{ jobs.deploy-summary.outputs.npm_url }}
|
||||
docker_url:
|
||||
description: "Docker image URL"
|
||||
value: ${{ jobs.deploy-summary.outputs.docker_url }}
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
DISCORD_WEBHOOK_URL:
|
||||
required: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# npm publish (all stages)
|
||||
npm-publish:
|
||||
name: npm Publish (${{ inputs.deployment_stage }})
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
outputs:
|
||||
status: ${{ steps.publish.outputs.status }}
|
||||
npm_url: ${{ steps.publish.outputs.npm_url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_branch }}
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Determine npm tag
|
||||
id: npm-tag
|
||||
run: |
|
||||
case "${{ inputs.deployment_stage }}" in
|
||||
alpha)
|
||||
echo "tag=alpha" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
beta)
|
||||
echo "tag=beta" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
stable)
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Publish to npm
|
||||
id: publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$NODE_AUTH_TOKEN" ]; then
|
||||
echo "NPM_TOKEN not set, skipping publish"
|
||||
echo "status=skipped" >> $GITHUB_OUTPUT
|
||||
echo "npm_url=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
NPM_TAG="${{ steps.npm-tag.outputs.tag }}"
|
||||
|
||||
if npm publish --tag "$NPM_TAG" --access public; then
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
echo "npm_url=https://www.npmjs.com/package/openclaw/v/${{ inputs.app_version }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=failed" >> $GITHUB_OUTPUT
|
||||
echo "npm_url=" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Docker build - amd64 (beta+ only)
|
||||
docker-amd64:
|
||||
name: Docker amd64 (${{ inputs.deployment_stage }})
|
||||
if: inputs.deployment_stage != 'alpha'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_branch }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.app_version }}-amd64
|
||||
type=raw,value=${{ inputs.deployment_stage }}-amd64
|
||||
|
||||
- name: Build and push amd64
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Docker build - arm64 (beta+ only)
|
||||
docker-arm64:
|
||||
name: Docker arm64 (${{ inputs.deployment_stage }})
|
||||
if: inputs.deployment_stage != 'alpha'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_branch }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.app_version }}-arm64
|
||||
type=raw,value=${{ inputs.deployment_stage }}-arm64
|
||||
|
||||
- name: Build and push arm64
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Create multi-arch manifest (beta+ only)
|
||||
docker-manifest:
|
||||
name: Docker Manifest (${{ inputs.deployment_stage }})
|
||||
if: inputs.deployment_stage != 'alpha'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker-amd64, docker-arm64]
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
docker_url: ${{ steps.manifest.outputs.docker_url }}
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest
|
||||
id: manifest
|
||||
run: |
|
||||
STAGE="${{ inputs.deployment_stage }}"
|
||||
VERSION="${{ inputs.app_version }}"
|
||||
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
|
||||
# Create version manifest
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
|
||||
# Create stage manifest (beta or latest)
|
||||
if [ "$STAGE" = "stable" ]; then
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:latest" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
echo "docker_url=${IMAGE}:latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${STAGE}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
echo "docker_url=${IMAGE}:${STAGE}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Deployment summary
|
||||
deploy-summary:
|
||||
name: Deployment Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [npm-publish, docker-manifest]
|
||||
if: "!cancelled()"
|
||||
outputs:
|
||||
status: ${{ steps.summary.outputs.status }}
|
||||
npm_url: ${{ steps.summary.outputs.npm_url }}
|
||||
docker_url: ${{ steps.summary.outputs.docker_url }}
|
||||
steps:
|
||||
- name: Summarize deployment
|
||||
id: summary
|
||||
run: |
|
||||
NPM_STATUS="${{ needs.npm-publish.outputs.status || 'skipped' }}"
|
||||
NPM_URL="${{ needs.npm-publish.outputs.npm_url }}"
|
||||
DOCKER_URL="${{ needs.docker-manifest.outputs.docker_url || '' }}"
|
||||
|
||||
echo "npm_url=$NPM_URL" >> $GITHUB_OUTPUT
|
||||
echo "docker_url=$DOCKER_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "$NPM_STATUS" = "success" ] || [ "$NPM_STATUS" = "skipped" ]; then
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=failed" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Generate summary
|
||||
echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stage | ${{ inputs.deployment_stage }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | ${{ inputs.app_version }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| npm | $NPM_STATUS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Docker | ${{ needs.docker-manifest.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Discord notification
|
||||
notify:
|
||||
name: Discord Notification
|
||||
needs: deploy-summary
|
||||
if: "!cancelled()"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord success notification
|
||||
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.deploy-summary.outputs.status == 'success' }}
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "🚀 Deployed: ${{ inputs.deployment_stage }} v${{ inputs.app_version }}"
|
||||
description: |
|
||||
**npm**: ${{ needs.deploy-summary.outputs.npm_url || 'skipped' }}
|
||||
**Docker**: ${{ needs.deploy-summary.outputs.docker_url || 'skipped' }}
|
||||
color: "3066993"
|
||||
|
||||
- name: Discord failure notification
|
||||
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.deploy-summary.outputs.status != 'success' }}
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "❌ Deployment Failed: ${{ inputs.deployment_stage }}"
|
||||
description: |
|
||||
**Version**: ${{ inputs.app_version }}
|
||||
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
color: "15158332"
|
||||
97
.github/workflows/feature-pr.yml
vendored
Normal file
97
.github/workflows/feature-pr.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
name: Feature PR
|
||||
|
||||
# Auto-create PR from dev/* branches to develop
|
||||
# This is the entry point for new features into the staging pipeline
|
||||
|
||||
# NOTE: push triggers disabled until staging pipeline is activated.
|
||||
# To enable: uncomment the push block and remove workflow_dispatch.
|
||||
# push:
|
||||
# branches:
|
||||
# - "dev/**"
|
||||
# - "feature/**"
|
||||
# - "fix/**"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: feature-pr-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
create-pr:
|
||||
name: Create PR to develop
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure develop branch exists
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if git ls-remote --heads origin develop | grep -q develop; then
|
||||
echo "develop branch already exists"
|
||||
else
|
||||
echo "develop branch does not exist — creating from main"
|
||||
git push origin origin/main:refs/heads/develop
|
||||
fi
|
||||
|
||||
- name: Check for existing PR
|
||||
id: check-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
TARGET="develop"
|
||||
|
||||
# Check if PR already exists
|
||||
EXISTING=$(gh pr list --head "$BRANCH" --base "$TARGET" --json number --jq '.[0].number // empty')
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT
|
||||
echo "PR #$EXISTING already exists for $BRANCH → $TARGET"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create PR
|
||||
if: steps.check-pr.outputs.exists != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
TARGET="develop"
|
||||
|
||||
# Extract title from branch name (dev/foo-bar → foo bar)
|
||||
TITLE=$(echo "$BRANCH" | sed 's|^dev/||; s|^feature/||; s|^fix/||; s|-| |g; s|_| |g')
|
||||
|
||||
# Capitalize first letter
|
||||
TITLE="$(echo "${TITLE:0:1}" | tr '[:lower:]' '[:upper:]')${TITLE:1}"
|
||||
|
||||
# Create PR body
|
||||
BODY=$(cat << 'PRBODY'
|
||||
Auto-created PR from feature branch.
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Describe your changes here -->
|
||||
|
||||
---
|
||||
*This PR was auto-created by the feature-pr workflow.*
|
||||
PRBODY
|
||||
)
|
||||
|
||||
gh pr create \
|
||||
--base "$TARGET" \
|
||||
--head "$BRANCH" \
|
||||
--title "$TITLE" \
|
||||
--body "$BODY"
|
||||
|
||||
echo "Created PR: $BRANCH → $TARGET"
|
||||
4
.github/workflows/formal-conformance.yml
vendored
4
.github/workflows/formal-conformance.yml
vendored
@@ -3,6 +3,10 @@ name: Formal models (informational conformance)
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: formal-conformance-${{ github.event.pull_request.number || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
formal_conformance:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
129
.github/workflows/generate-changelog.yml
vendored
Normal file
129
.github/workflows/generate-changelog.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
name: Generate Changelog
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version for the changelog"
|
||||
required: true
|
||||
type: string
|
||||
release_type:
|
||||
description: "Release type: alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
changelog:
|
||||
description: "Generated changelog content"
|
||||
value: ${{ jobs.generate.outputs.changelog }}
|
||||
changelog_file:
|
||||
description: "Path to changelog file"
|
||||
value: ${{ jobs.generate.outputs.changelog_file }}
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
name: Generate Changelog
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changelog: ${{ steps.generate.outputs.changelog }}
|
||||
changelog_file: ${{ steps.generate.outputs.changelog_file }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
id: generate
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
RELEASE_TYPE="${{ inputs.release_type }}"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
# Start building changelog
|
||||
CHANGELOG="## v${VERSION} (${DATE})\n\n"
|
||||
|
||||
# Initialize sections
|
||||
FEATURES=""
|
||||
FIXES=""
|
||||
DOCS=""
|
||||
CHORES=""
|
||||
OTHER=""
|
||||
|
||||
# Get commits since last tag
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
COMMITS=$(git log ${LATEST_TAG}..HEAD --oneline --format="%s")
|
||||
else
|
||||
COMMITS=$(git log --oneline --format="%s" | head -50)
|
||||
fi
|
||||
|
||||
# Categorize commits by conventional commit type
|
||||
while IFS= read -r commit; do
|
||||
if [ -z "$commit" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract type from conventional commit
|
||||
if [[ "$commit" =~ ^feat(\(.+\))?:\ (.+)$ ]]; then
|
||||
FEATURES="${FEATURES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^fix(\(.+\))?:\ (.+)$ ]]; then
|
||||
FIXES="${FIXES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^docs(\(.+\))?:\ (.+)$ ]]; then
|
||||
DOCS="${DOCS}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^chore(\(.+\))?:\ (.+)$ ]]; then
|
||||
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^refactor(\(.+\))?:\ (.+)$ ]]; then
|
||||
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^test(\(.+\))?:\ (.+)$ ]]; then
|
||||
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^ci(\(.+\))?:\ (.+)$ ]]; then
|
||||
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
|
||||
else
|
||||
# Non-conventional commit, add to other
|
||||
OTHER="${OTHER}- ${commit}\n"
|
||||
fi
|
||||
done <<< "$COMMITS"
|
||||
|
||||
# Build final changelog
|
||||
if [ -n "$FEATURES" ]; then
|
||||
CHANGELOG="${CHANGELOG}### ✨ Features\n\n${FEATURES}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$FIXES" ]; then
|
||||
CHANGELOG="${CHANGELOG}### 🐛 Bug Fixes\n\n${FIXES}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$DOCS" ]; then
|
||||
CHANGELOG="${CHANGELOG}### 📚 Documentation\n\n${DOCS}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$CHORES" ]; then
|
||||
CHANGELOG="${CHANGELOG}### 🔧 Maintenance\n\n${CHORES}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$OTHER" ]; then
|
||||
CHANGELOG="${CHANGELOG}### Other Changes\n\n${OTHER}\n"
|
||||
fi
|
||||
|
||||
# If no categorized commits, add a simple message
|
||||
if [ -z "$FEATURES" ] && [ -z "$FIXES" ] && [ -z "$DOCS" ] && [ -z "$CHORES" ] && [ -z "$OTHER" ]; then
|
||||
CHANGELOG="${CHANGELOG}No notable changes in this release.\n"
|
||||
fi
|
||||
|
||||
# Add release metadata
|
||||
CHANGELOG="${CHANGELOG}\n---\n\n"
|
||||
CHANGELOG="${CHANGELOG}**Release Type**: ${RELEASE_TYPE}\n"
|
||||
CHANGELOG="${CHANGELOG}**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LATEST_TAG:-initial}...v${VERSION}\n"
|
||||
|
||||
# Escape for multiline output (random delimiter prevents collision with commit messages)
|
||||
DELIMITER="CHANGELOG_$(openssl rand -hex 16)"
|
||||
echo "changelog<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo -e "$CHANGELOG" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "changelog_file=CHANGELOG.md" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also write to step summary
|
||||
echo "## Generated Changelog" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo -e "$CHANGELOG" >> $GITHUB_STEP_SUMMARY
|
||||
96
.github/workflows/hotfix-pr.yml
vendored
Normal file
96
.github/workflows/hotfix-pr.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: Hotfix PR
|
||||
|
||||
# Emergency hotfix workflow - bypasses staging pipeline
|
||||
# Use for critical security fixes or production-breaking bugs only
|
||||
#
|
||||
# Flow: hotfix/* → main (directly, with expedited review)
|
||||
|
||||
# NOTE: push triggers disabled until staging pipeline is activated.
|
||||
# To enable: uncomment the push block and remove workflow_dispatch.
|
||||
# push:
|
||||
# branches:
|
||||
# - "hotfix/**"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: hotfix-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
create-pr:
|
||||
name: Create Hotfix PR
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for existing PR
|
||||
id: check-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
|
||||
EXISTING=$(gh pr list --head "$BRANCH" --base main --json number --jq '.[0].number // empty')
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT
|
||||
echo "Hotfix PR #$EXISTING already exists"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Hotfix PR
|
||||
if: steps.check-pr.outputs.exists != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
|
||||
# Extract title from branch name
|
||||
TITLE=$(echo "$BRANCH" | sed 's|^hotfix/||; s|-| |g; s|_| |g')
|
||||
TITLE="🚨 HOTFIX: $(echo "${TITLE:0:1}" | tr '[:lower:]' '[:upper:]')${TITLE:1}"
|
||||
|
||||
# Create PR body
|
||||
BODY=$(cat << 'PRBODY'
|
||||
## 🚨 Emergency Hotfix
|
||||
|
||||
**This PR bypasses the normal staging pipeline.**
|
||||
|
||||
### What's broken?
|
||||
<!-- Describe the production issue -->
|
||||
|
||||
### Root cause
|
||||
<!-- Brief explanation of what went wrong -->
|
||||
|
||||
### Fix
|
||||
<!-- What this hotfix does -->
|
||||
|
||||
### Verification
|
||||
- [ ] Tested locally
|
||||
- [ ] Reviewed by at least one other maintainer
|
||||
- [ ] Post-merge monitoring plan in place
|
||||
|
||||
---
|
||||
⚠️ **After merging:** Cherry-pick this fix to `develop`, `alpha`, and `beta` branches to keep them in sync.
|
||||
|
||||
*This PR was auto-created by the hotfix-pr workflow.*
|
||||
PRBODY
|
||||
)
|
||||
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "$TITLE" \
|
||||
--label "hotfix,priority:critical" \
|
||||
--body "$BODY"
|
||||
|
||||
echo "Created hotfix PR: $BRANCH → main"
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
install-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
321
.github/workflows/promote-branch.yml
vendored
Normal file
321
.github/workflows/promote-branch.yml
vendored
Normal file
@@ -0,0 +1,321 @@
|
||||
name: Promote Branch
|
||||
|
||||
# Staged branch promotion for openclaw:
|
||||
#
|
||||
# develop → alpha → beta → main
|
||||
#
|
||||
# - External contributors: target `develop`
|
||||
# - develop → alpha: auto-creates PR after core checks pass
|
||||
# - alpha → beta: auto-creates PR after alpha tests pass (+ secrets scan)
|
||||
# - beta → main: auto-creates PR after full tests pass (+ Windows)
|
||||
#
|
||||
# Merging to main triggers a release (handled separately by release workflow)
|
||||
|
||||
# NOTE: push triggers disabled until staging pipeline is activated.
|
||||
# To enable: uncomment the push block below.
|
||||
# push:
|
||||
# branches:
|
||||
# - develop
|
||||
# - alpha
|
||||
# - beta
|
||||
# paths-ignore:
|
||||
# - "docs/**"
|
||||
# - "*.md"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_branch:
|
||||
description: "Source branch to promote from"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- develop
|
||||
- alpha
|
||||
- beta
|
||||
skip_tests:
|
||||
description: "Skip tests (use with caution)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: promote-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# Determine promotion target
|
||||
determine-target:
|
||||
name: Determine Target Branch
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
source: ${{ steps.determine.outputs.source }}
|
||||
target: ${{ steps.determine.outputs.target }}
|
||||
test_stage: ${{ steps.determine.outputs.test_stage }}
|
||||
should_promote: ${{ steps.determine.outputs.should_promote }}
|
||||
steps:
|
||||
- name: Determine promotion target
|
||||
id: determine
|
||||
run: |
|
||||
# Get source branch
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
SOURCE="${{ inputs.source_branch }}"
|
||||
else
|
||||
SOURCE="${{ github.ref_name }}"
|
||||
fi
|
||||
|
||||
echo "source=$SOURCE" >> $GITHUB_OUTPUT
|
||||
|
||||
case "$SOURCE" in
|
||||
develop)
|
||||
echo "target=alpha" >> $GITHUB_OUTPUT
|
||||
echo "test_stage=develop" >> $GITHUB_OUTPUT
|
||||
echo "should_promote=true" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
alpha)
|
||||
echo "target=beta" >> $GITHUB_OUTPUT
|
||||
echo "test_stage=alpha" >> $GITHUB_OUTPUT
|
||||
echo "should_promote=true" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
beta)
|
||||
echo "target=main" >> $GITHUB_OUTPUT
|
||||
echo "test_stage=beta" >> $GITHUB_OUTPUT
|
||||
echo "should_promote=true" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo "target=" >> $GITHUB_OUTPUT
|
||||
echo "test_stage=" >> $GITHUB_OUTPUT
|
||||
echo "should_promote=false" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
|
||||
# Ensure target branch exists (create from main if not)
|
||||
ensure-target-branch:
|
||||
name: Ensure Target Branch
|
||||
needs: determine-target
|
||||
if: needs.determine-target.outputs.should_promote == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create target branch if missing
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TARGET="${{ needs.determine-target.outputs.target }}"
|
||||
|
||||
if git ls-remote --exit-code origin "refs/heads/$TARGET" >/dev/null 2>&1; then
|
||||
echo "Branch '$TARGET' already exists"
|
||||
else
|
||||
echo "Branch '$TARGET' does not exist — creating from main"
|
||||
git push origin "origin/main:refs/heads/$TARGET"
|
||||
fi
|
||||
|
||||
# Run stage-appropriate tests
|
||||
run-tests:
|
||||
name: Run Tests
|
||||
needs: [determine-target, ensure-target-branch]
|
||||
if: ${{ needs.determine-target.outputs.should_promote == 'true' && (github.event_name != 'workflow_dispatch' || !inputs.skip_tests) }}
|
||||
uses: ./.github/workflows/testing-strategy.yml
|
||||
with:
|
||||
test_stage: ${{ needs.determine-target.outputs.test_stage }}
|
||||
app_version: ${{ github.sha }}
|
||||
secrets: inherit
|
||||
|
||||
# Create promotion PR
|
||||
create-promotion-pr:
|
||||
name: Create Promotion PR
|
||||
needs: [determine-target, ensure-target-branch, run-tests]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.determine-target.outputs.should_promote == 'true' &&
|
||||
(needs.run-tests.outputs.test_status == 'passed' || (github.event_name == 'workflow_dispatch' && inputs.skip_tests))
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.output-pr.outputs.pull-request-number }}
|
||||
pr_url: ${{ steps.output-pr.outputs.pull-request-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.determine-target.outputs.source }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get commit info
|
||||
id: commits
|
||||
run: |
|
||||
TARGET="${{ needs.determine-target.outputs.target }}"
|
||||
|
||||
# Fetch target branch
|
||||
git fetch origin $TARGET 2>/dev/null || true
|
||||
|
||||
# Get commits not in target
|
||||
if git rev-parse origin/$TARGET >/dev/null 2>&1; then
|
||||
COMMIT_COUNT=$(git rev-list --count origin/$TARGET..HEAD 2>/dev/null || echo "0")
|
||||
COMMIT_SUMMARY=$(git log origin/$TARGET..HEAD --oneline --format="- %s (%h)" 2>/dev/null | head -20 || echo "Initial promotion")
|
||||
else
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD 2>/dev/null || echo "0")
|
||||
COMMIT_SUMMARY=$(git log --oneline --format="- %s (%h)" 2>/dev/null | head -20 || echo "Initial promotion")
|
||||
fi
|
||||
|
||||
echo "count=$COMMIT_COUNT" >> $GITHUB_OUTPUT
|
||||
DELIM="COMMITS_$(openssl rand -hex 16)"
|
||||
echo "summary<<${DELIM}" >> $GITHUB_OUTPUT
|
||||
echo "$COMMIT_SUMMARY" >> $GITHUB_OUTPUT
|
||||
echo "${DELIM}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for existing PR
|
||||
id: check-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
SOURCE="${{ needs.determine-target.outputs.source }}"
|
||||
TARGET="${{ needs.determine-target.outputs.target }}"
|
||||
|
||||
EXISTING=$(gh pr list --head "$SOURCE" --base "$TARGET" --json number --jq '.[0].number // empty')
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT
|
||||
echo "pr_url=https://github.com/${{ github.repository }}/pull/$EXISTING" >> $GITHUB_OUTPUT
|
||||
echo "Promotion PR #$EXISTING already exists for $SOURCE → $TARGET"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create-pr
|
||||
if: steps.check-pr.outputs.exists != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
SOURCE="${{ needs.determine-target.outputs.source }}"
|
||||
TARGET="${{ needs.determine-target.outputs.target }}"
|
||||
TEST_STAGE="${{ needs.determine-target.outputs.test_stage }}"
|
||||
COMMIT_COUNT="${{ steps.commits.outputs.count }}"
|
||||
|
||||
# Write PR body to a temp file to avoid shell quoting issues
|
||||
BODY_FILE=$(mktemp)
|
||||
cat > "$BODY_FILE" <<__PRBODY__
|
||||
## Staged Promotion
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Source | \`${SOURCE}\` |
|
||||
| Target | \`${TARGET}\` |
|
||||
| Test Stage | \`${TEST_STAGE}\` |
|
||||
|
||||
### Changes (${COMMIT_COUNT} commits)
|
||||
|
||||
${{ steps.commits.outputs.summary }}
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Changes reviewed
|
||||
- [ ] CI passing
|
||||
- [ ] Ready to promote
|
||||
|
||||
---
|
||||
*Auto-generated by the branch promotion workflow.*
|
||||
__PRBODY__
|
||||
|
||||
PR_URL=$(gh pr create \
|
||||
--base "$TARGET" \
|
||||
--head "$SOURCE" \
|
||||
--title "🚀 Promote: $SOURCE → $TARGET" \
|
||||
--body-file "$BODY_FILE" \
|
||||
--label "promotion")
|
||||
|
||||
rm -f "$BODY_FILE"
|
||||
|
||||
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||
|
||||
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
|
||||
echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
|
||||
echo "Created promotion PR: $SOURCE → $TARGET"
|
||||
|
||||
- name: Output existing PR
|
||||
id: output-pr
|
||||
run: |
|
||||
if [ "${{ steps.check-pr.outputs.exists }}" = "true" ]; then
|
||||
echo "pull-request-number=${{ steps.check-pr.outputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
echo "pull-request-url=${{ steps.check-pr.outputs.pr_url }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "pull-request-number=${{ steps.create-pr.outputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
echo "pull-request-url=${{ steps.create-pr.outputs.pr_url }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Auto-merge for develop → alpha (fast-track, new PRs only)
|
||||
auto-merge:
|
||||
name: Auto-merge (develop → alpha)
|
||||
needs: [determine-target, create-promotion-pr]
|
||||
if: |
|
||||
needs.determine-target.outputs.source == 'develop' &&
|
||||
needs.create-promotion-pr.outputs.pr_number != '' &&
|
||||
needs.create-promotion-pr.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Enable auto-merge
|
||||
run: |
|
||||
gh pr merge ${{ needs.create-promotion-pr.outputs.pr_number }} \
|
||||
--auto \
|
||||
--squash \
|
||||
--repo ${{ github.repository }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Notify about promotion
|
||||
notify-promotion:
|
||||
name: Notify Promotion
|
||||
needs: [determine-target, create-promotion-pr]
|
||||
if: "!cancelled() && needs.create-promotion-pr.outputs.pr_url != ''"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "🔄 Promotion PR: ${{ needs.determine-target.outputs.source }} → ${{ needs.determine-target.outputs.target }}"
|
||||
description: |
|
||||
**PR**: ${{ needs.create-promotion-pr.outputs.pr_url }}
|
||||
**Stage**: ${{ needs.determine-target.outputs.test_stage }}
|
||||
color: "3447003"
|
||||
|
||||
# Handle failed tests
|
||||
notify-failure:
|
||||
name: Notify Test Failure
|
||||
needs: [determine-target, run-tests]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.run-tests.outputs.test_status != 'passed' &&
|
||||
!inputs.skip_tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "❌ Promotion Blocked: ${{ needs.determine-target.outputs.source }}"
|
||||
description: |
|
||||
**Target**: ${{ needs.determine-target.outputs.target }}
|
||||
**Reason**: Tests failed
|
||||
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
color: "15158332"
|
||||
265
.github/workflows/release-orchestrator.yml
vendored
Normal file
265
.github/workflows/release-orchestrator.yml
vendored
Normal file
@@ -0,0 +1,265 @@
|
||||
name: Release Orchestrator
|
||||
|
||||
# Orchestrates staged releases for openclaw
|
||||
#
|
||||
# This workflow is called when code is promoted to main (stable release)
|
||||
# or can be triggered manually for alpha/beta releases from their branches.
|
||||
#
|
||||
# Flow: version → changelog → test → deploy → release
|
||||
|
||||
# NOTE: push-to-main trigger disabled until staging pipeline is activated.
|
||||
# To enable: uncomment the push block below.
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# paths-ignore:
|
||||
# - "docs/**"
|
||||
# - "*.md"
|
||||
# - ".github/workflows/docs-*.yml"
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_type:
|
||||
description: "Release type: alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
source_branch:
|
||||
description: "Source branch for the release"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Perform a dry run without publishing"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
version:
|
||||
description: "The released version"
|
||||
value: ${{ jobs.version.outputs.new_version }}
|
||||
release_url:
|
||||
description: "URL to the GitHub release"
|
||||
value: ${{ jobs.release.outputs.release_url }}
|
||||
status:
|
||||
description: "Release status"
|
||||
value: ${{ jobs.release.outputs.status }}
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
DISCORD_WEBHOOK_URL:
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
# Determine release parameters (push vs workflow_call)
|
||||
determine-params:
|
||||
name: Determine Parameters
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_type: ${{ steps.params.outputs.release_type }}
|
||||
source_branch: ${{ steps.params.outputs.source_branch }}
|
||||
dry_run: ${{ steps.params.outputs.dry_run }}
|
||||
steps:
|
||||
- name: Set parameters
|
||||
id: params
|
||||
run: |
|
||||
# When triggered by push to main, use stable defaults
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
echo "release_type=stable" >> $GITHUB_OUTPUT
|
||||
echo "source_branch=main" >> $GITHUB_OUTPUT
|
||||
echo "dry_run=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# workflow_call - use provided inputs
|
||||
echo "release_type=${{ inputs.release_type }}" >> $GITHUB_OUTPUT
|
||||
echo "source_branch=${{ inputs.source_branch }}" >> $GITHUB_OUTPUT
|
||||
echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Get commits since last release
|
||||
get-commits:
|
||||
name: Get Commits
|
||||
needs: determine-params
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
commits: ${{ steps.commits.outputs.commits }}
|
||||
has_changes: ${{ steps.commits.outputs.has_changes }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.determine-params.outputs.source_branch }}
|
||||
|
||||
- name: Get commits since last tag
|
||||
id: commits
|
||||
run: |
|
||||
# Get latest tag for this release type
|
||||
case "${{ needs.determine-params.outputs.release_type }}" in
|
||||
alpha)
|
||||
PATTERN="v*-alpha.*"
|
||||
;;
|
||||
beta)
|
||||
PATTERN="v*-beta.*"
|
||||
;;
|
||||
stable)
|
||||
PATTERN="v[0-9]*.[0-9]*.[0-9]*"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Filter out prerelease tags for stable (glob * matches -alpha/-beta suffixes)
|
||||
if [ "${{ needs.determine-params.outputs.release_type }}" = "stable" ]; then
|
||||
LATEST_TAG=$(git tag -l "$PATTERN" --sort=-v:refname | grep -v -E '-(alpha|beta)\.' | head -1)
|
||||
else
|
||||
LATEST_TAG=$(git tag -l "$PATTERN" --sort=-v:refname | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
# No previous tag, use all commits
|
||||
LATEST_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
echo "No previous ${{ needs.determine-params.outputs.release_type }} tag found, using initial commit"
|
||||
else
|
||||
echo "Latest ${{ needs.determine-params.outputs.release_type }} tag: $LATEST_TAG"
|
||||
fi
|
||||
|
||||
COMMITS=$(git log ${LATEST_TAG}..HEAD --oneline --format="- %s (%h)")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "commits=" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
DELIM="COMMITS_$(openssl rand -hex 16)"
|
||||
echo "commits<<${DELIM}" >> $GITHUB_OUTPUT
|
||||
echo "$COMMITS" >> $GITHUB_OUTPUT
|
||||
echo "${DELIM}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Version operations
|
||||
version:
|
||||
name: Version
|
||||
needs: [determine-params, get-commits]
|
||||
if: needs.get-commits.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/version-operations.yml
|
||||
with:
|
||||
release_type: ${{ needs.determine-params.outputs.release_type }}
|
||||
source_branch: ${{ needs.determine-params.outputs.source_branch }}
|
||||
should_bump: true
|
||||
dry_run: ${{ needs.determine-params.outputs.dry_run }}
|
||||
|
||||
# Generate changelog
|
||||
changelog:
|
||||
name: Changelog
|
||||
needs: [determine-params, get-commits, version]
|
||||
if: needs.get-commits.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/generate-changelog.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.new_version }}
|
||||
release_type: ${{ needs.determine-params.outputs.release_type }}
|
||||
|
||||
# Run full test suite for the release type
|
||||
test:
|
||||
name: Test
|
||||
needs: [determine-params, get-commits, version]
|
||||
if: needs.get-commits.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/testing-strategy.yml
|
||||
with:
|
||||
test_stage: ${{ needs.determine-params.outputs.release_type }}
|
||||
app_version: ${{ needs.version.outputs.new_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Deploy (npm + Docker)
|
||||
deploy:
|
||||
name: Deploy
|
||||
needs: [determine-params, version, test]
|
||||
if: ${{ needs.determine-params.outputs.dry_run != 'true' && needs.test.outputs.test_status == 'passed' }}
|
||||
uses: ./.github/workflows/deployment-strategy.yml
|
||||
with:
|
||||
deployment_stage: ${{ needs.determine-params.outputs.release_type }}
|
||||
app_version: ${{ needs.version.outputs.new_version }}
|
||||
source_branch: ${{ needs.determine-params.outputs.source_branch }}
|
||||
secrets: inherit
|
||||
|
||||
# Create GitHub release
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: [determine-params, version, changelog, deploy]
|
||||
if: ${{ needs.determine-params.outputs.dry_run != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_url: ${{ steps.create-release.outputs.html_url }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.determine-params.outputs.source_branch }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create-release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.version.outputs.new_version }}
|
||||
name: openclaw ${{ needs.version.outputs.new_version }}
|
||||
body: ${{ needs.changelog.outputs.changelog }}
|
||||
prerelease: ${{ needs.determine-params.outputs.release_type != 'stable' }}
|
||||
draft: false
|
||||
|
||||
- name: Set status
|
||||
id: status
|
||||
run: echo "status=success" >> $GITHUB_OUTPUT
|
||||
|
||||
# Notify on success
|
||||
notify-success:
|
||||
name: Notify Success
|
||||
needs: [determine-params, version, release]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "🎉 Released: openclaw v${{ needs.version.outputs.new_version }}"
|
||||
description: |
|
||||
**Type**: ${{ needs.determine-params.outputs.release_type }}
|
||||
**Release**: ${{ needs.release.outputs.release_url }}
|
||||
color: "3066993"
|
||||
|
||||
# Notify on failure
|
||||
notify-failure:
|
||||
name: Notify Failure
|
||||
needs: [determine-params, version, test, deploy, release]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.version.result != 'skipped' &&
|
||||
(needs.test.result == 'failure' || needs.deploy.result == 'failure' || needs.release.result == 'failure')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "❌ Release Failed: ${{ needs.determine-params.outputs.release_type }}"
|
||||
description: |
|
||||
**Branch**: ${{ needs.determine-params.outputs.source_branch }}
|
||||
**Tests**: ${{ needs.test.outputs.test_status }}
|
||||
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
color: "15158332"
|
||||
51
.github/workflows/release.yml
vendored
Normal file
51
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Release
|
||||
|
||||
# Manual release workflow - triggers the release orchestrator
|
||||
#
|
||||
# Branch → Release Type mapping:
|
||||
# alpha → releases from 'alpha' branch with -alpha.N suffix
|
||||
# beta → releases from 'beta' branch with -beta.N suffix
|
||||
# stable → releases from 'main' branch with YYYY.M.D version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_type:
|
||||
description: "Release type"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- alpha
|
||||
- beta
|
||||
- stable
|
||||
default: "alpha"
|
||||
dry_run:
|
||||
description: "Dry run (no publish)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
determine-branch:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
branch: ${{ steps.branch.outputs.name }}
|
||||
steps:
|
||||
- name: Determine source branch
|
||||
id: branch
|
||||
run: |
|
||||
case "${{ inputs.release_type }}" in
|
||||
alpha) echo "name=alpha" >> $GITHUB_OUTPUT ;;
|
||||
beta) echo "name=beta" >> $GITHUB_OUTPUT ;;
|
||||
stable) echo "name=main" >> $GITHUB_OUTPUT ;;
|
||||
esac
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: determine-branch
|
||||
uses: ./.github/workflows/release-orchestrator.yml
|
||||
with:
|
||||
release_type: ${{ inputs.release_type }}
|
||||
source_branch: ${{ needs.determine-branch.outputs.branch }}
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
secrets: inherit
|
||||
256
.github/workflows/rollback.yml
vendored
Normal file
256
.github/workflows/rollback.yml
vendored
Normal file
@@ -0,0 +1,256 @@
|
||||
name: Rollback
|
||||
|
||||
# Emergency rollback workflow
|
||||
#
|
||||
# Reverts npm + Docker to a previous known-good version.
|
||||
# Does NOT revert git — the bad commits stay in history.
|
||||
# Create a hotfix branch to fix forward after rolling back.
|
||||
#
|
||||
# What it does:
|
||||
# 1. Re-tags the previous version as @latest / :latest on npm + Docker
|
||||
# 2. Creates a GitHub release noting the rollback
|
||||
# 3. Notifies Discord
|
||||
#
|
||||
# What it does NOT do:
|
||||
# - Revert git commits (fix forward instead)
|
||||
# - Remove the bad version from npm (use `npm unpublish` manually if needed)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rollback_to:
|
||||
description: "Version to roll back to (e.g. 2026.2.5)"
|
||||
required: true
|
||||
type: string
|
||||
reason:
|
||||
description: "Reason for rollback"
|
||||
required: true
|
||||
type: string
|
||||
rollback_npm:
|
||||
description: "Roll back npm dist-tag"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
rollback_docker:
|
||||
description: "Roll back Docker :latest tag"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: rollback
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Validate the target version exists
|
||||
validate:
|
||||
name: Validate Target Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_exists: ${{ steps.check.outputs.tag_exists }}
|
||||
npm_exists: ${{ steps.check.outputs.npm_exists }}
|
||||
docker_exists: ${{ steps.check.outputs.docker_exists }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version
|
||||
id: check
|
||||
run: |
|
||||
VERSION="${{ inputs.rollback_to }}"
|
||||
|
||||
# Check git tag
|
||||
if git tag -l "v${VERSION}" | grep -q .; then
|
||||
echo "tag_exists=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Git tag v${VERSION} exists"
|
||||
else
|
||||
echo "tag_exists=false" >> $GITHUB_OUTPUT
|
||||
echo "❌ Git tag v${VERSION} not found"
|
||||
fi
|
||||
|
||||
# Check npm
|
||||
if npm view "openclaw@${VERSION}" version 2>/dev/null | grep -q "${VERSION}"; then
|
||||
echo "npm_exists=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ npm version ${VERSION} exists"
|
||||
else
|
||||
echo "npm_exists=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ npm version ${VERSION} not found (npm rollback will be skipped)"
|
||||
fi
|
||||
|
||||
# Check Docker
|
||||
if docker manifest inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}" >/dev/null 2>&1; then
|
||||
echo "docker_exists=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Docker image ${VERSION} exists"
|
||||
else
|
||||
echo "docker_exists=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Docker image ${VERSION} not found (Docker rollback will be skipped)"
|
||||
fi
|
||||
|
||||
- name: Fail if tag doesn't exist
|
||||
if: steps.check.outputs.tag_exists != 'true'
|
||||
run: |
|
||||
echo "::error::Version v${{ inputs.rollback_to }} does not exist as a git tag"
|
||||
exit 1
|
||||
|
||||
# Roll back npm dist-tag
|
||||
rollback-npm:
|
||||
name: Rollback npm
|
||||
needs: validate
|
||||
if: ${{ inputs.rollback_npm && needs.validate.outputs.npm_exists == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
status: ${{ steps.rollback.outputs.status }}
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Roll back npm @latest tag
|
||||
id: rollback
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ inputs.rollback_to }}"
|
||||
|
||||
if [ -z "$NODE_AUTH_TOKEN" ]; then
|
||||
echo "::warning::NPM_TOKEN not set, skipping npm rollback"
|
||||
echo "status=skipped" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Move the @latest dist-tag to the rollback version
|
||||
if npm dist-tag add "openclaw@${VERSION}" latest; then
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
echo "✅ npm @latest now points to ${VERSION}"
|
||||
|
||||
# Show current dist-tags for verification
|
||||
npm dist-tag ls openclaw
|
||||
else
|
||||
echo "status=failed" >> $GITHUB_OUTPUT
|
||||
echo "::error::Failed to update npm dist-tag"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Roll back Docker :latest tag
|
||||
rollback-docker:
|
||||
name: Rollback Docker
|
||||
needs: validate
|
||||
if: ${{ inputs.rollback_docker && needs.validate.outputs.docker_exists == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
status: ${{ steps.rollback.outputs.status }}
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Roll back Docker :latest tag
|
||||
id: rollback
|
||||
run: |
|
||||
VERSION="${{ inputs.rollback_to }}"
|
||||
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
|
||||
# Re-tag the rollback version as :latest
|
||||
if docker buildx imagetools create -t "${IMAGE}:latest" "${IMAGE}:${VERSION}"; then
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
echo "✅ Docker :latest now points to ${VERSION}"
|
||||
else
|
||||
echo "status=failed" >> $GITHUB_OUTPUT
|
||||
echo "::error::Failed to retag Docker image"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create rollback release note
|
||||
create-rollback-release:
|
||||
name: Create Rollback Release
|
||||
needs: [validate, rollback-npm, rollback-docker]
|
||||
if: "!cancelled() && needs.validate.result == 'success'"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get current version
|
||||
id: current
|
||||
run: |
|
||||
CURRENT=$(node -p "require('./package.json').version")
|
||||
echo "version=$CURRENT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create rollback release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ inputs.rollback_to }}
|
||||
name: "⚠️ Rollback to openclaw ${{ inputs.rollback_to }}"
|
||||
body: |
|
||||
## ⚠️ Rollback
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Rolled back from | `${{ steps.current.outputs.version }}` |
|
||||
| Rolled back to | `${{ inputs.rollback_to }}` |
|
||||
| Initiated by | @${{ github.actor }} |
|
||||
|
||||
### Reason
|
||||
|
||||
${{ inputs.reason }}
|
||||
|
||||
### Rollback Status
|
||||
|
||||
| Target | Status |
|
||||
|--------|--------|
|
||||
| npm @latest | ${{ needs.rollback-npm.outputs.status || 'skipped' }} |
|
||||
| Docker :latest | ${{ needs.rollback-docker.outputs.status || 'skipped' }} |
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. Investigate the issue in the rolled-back version
|
||||
2. Create a `hotfix/*` branch with the fix
|
||||
3. Merge via the hotfix workflow to restore forward progress
|
||||
|
||||
---
|
||||
*This release was created by the rollback workflow.*
|
||||
make_latest: false
|
||||
prerelease: false
|
||||
|
||||
# Notify
|
||||
notify:
|
||||
name: Discord Notification
|
||||
needs: [validate, rollback-npm, rollback-docker, create-rollback-release]
|
||||
if: "!cancelled() && needs.validate.result == 'success'"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "⚠️ ROLLBACK: openclaw → v${{ inputs.rollback_to }}"
|
||||
description: |
|
||||
**Reason**: ${{ inputs.reason }}
|
||||
**Initiated by**: @${{ github.actor }}
|
||||
**npm**: ${{ needs.rollback-npm.outputs.status || 'skipped' }}
|
||||
**Docker**: ${{ needs.rollback-docker.outputs.status || 'skipped' }}
|
||||
color: "15105570"
|
||||
153
.github/workflows/testing-strategy.yml
vendored
Normal file
153
.github/workflows/testing-strategy.yml
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
name: Testing Strategy
|
||||
|
||||
# Reusable testing workflow for staged releases
|
||||
# Passes test_stage to ci.yml to control which platform tests run
|
||||
#
|
||||
# Progressive test coverage by stage:
|
||||
# - develop/alpha: core checks + secrets + android
|
||||
# - beta: + Windows tests
|
||||
# - stable: + macOS tests, macOS app, install smoke
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
test_stage:
|
||||
description: "Testing stage: develop, alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
app_version:
|
||||
description: "Version of the application being tested"
|
||||
required: false
|
||||
type: string
|
||||
default: "dev"
|
||||
outputs:
|
||||
test_status:
|
||||
description: "Overall test status"
|
||||
value: ${{ jobs.test-summary.outputs.overall_status }}
|
||||
secrets:
|
||||
DISCORD_WEBHOOK_URL:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
# Run CI with stage-appropriate platform gates
|
||||
ci:
|
||||
name: Core CI
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
test_stage: ${{ inputs.test_stage }}
|
||||
secrets: inherit
|
||||
|
||||
# Install smoke test (stable only)
|
||||
install-smoke:
|
||||
name: Install Smoke Test
|
||||
if: inputs.test_stage == 'stable'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run installer smoke tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
|
||||
CLAWDBOT_NO_ONBOARD: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
|
||||
run: pnpm test:install:smoke
|
||||
|
||||
# Test summary
|
||||
test-summary:
|
||||
name: Test Summary (${{ inputs.test_stage }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ci, install-smoke]
|
||||
if: "!cancelled()"
|
||||
outputs:
|
||||
overall_status: ${{ steps.summary.outputs.overall_status }}
|
||||
steps:
|
||||
- name: Generate summary
|
||||
id: summary
|
||||
run: |
|
||||
echo "## 🧪 Test Results - ${{ inputs.test_stage }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Test Suite | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| CI (checks + secrets) | ${{ needs.ci.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Install Smoke | ${{ needs.install-smoke.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# CI must pass (includes platform-specific jobs based on test_stage)
|
||||
if [ "${{ needs.ci.result }}" != "success" ]; then
|
||||
echo "overall_status=failed" >> $GITHUB_OUTPUT
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ❌ CI failed" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stage-specific checks
|
||||
STAGE="${{ inputs.test_stage }}"
|
||||
FAILED=false
|
||||
|
||||
if [ "$STAGE" = "stable" ]; then
|
||||
if [ "${{ needs.install-smoke.result }}" = "failure" ]; then
|
||||
FAILED=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FAILED" = "true" ]; then
|
||||
echo "overall_status=failed" >> $GITHUB_OUTPUT
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ❌ Some stage-specific tests failed" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "overall_status=passed" >> $GITHUB_OUTPUT
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ✅ All required tests passed!" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Discord notifications
|
||||
notify:
|
||||
name: Discord Notification
|
||||
needs: test-summary
|
||||
if: "!cancelled()"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord success notification
|
||||
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.test-summary.outputs.overall_status == 'passed' }}
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "✅ Tests Passed: ${{ inputs.test_stage }} v${{ inputs.app_version }}"
|
||||
description: "All tests passed for ${{ inputs.test_stage }} stage!"
|
||||
color: "3066993"
|
||||
|
||||
- name: Discord failure notification
|
||||
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.test-summary.outputs.overall_status != 'passed' }}
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "❌ Tests Failed: ${{ inputs.test_stage }} v${{ inputs.app_version }}"
|
||||
description: |
|
||||
Some tests failed for ${{ inputs.test_stage }} stage.
|
||||
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
color: "15158332"
|
||||
188
.github/workflows/version-operations.yml
vendored
Normal file
188
.github/workflows/version-operations.yml
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
name: Version Operations
|
||||
|
||||
# Version bump workflow for openclaw
|
||||
#
|
||||
# Version format: YYYY.M.D (stable) or YYYY.M.D-{alpha,beta}.N (prerelease)
|
||||
# Examples: 2026.2.6, 2026.2.6-alpha.1, 2026.2.6-beta.3
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_type:
|
||||
description: "Release type: alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
source_branch:
|
||||
description: "Source branch"
|
||||
required: true
|
||||
type: string
|
||||
should_bump:
|
||||
description: "Whether to bump the version"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
dry_run:
|
||||
description: "Perform a dry run without committing"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
current_version:
|
||||
description: "Current version before bump"
|
||||
value: ${{ jobs.version.outputs.current_version }}
|
||||
new_version:
|
||||
description: "New version after bump"
|
||||
value: ${{ jobs.version.outputs.new_version }}
|
||||
version_tag:
|
||||
description: "Version tag (with v prefix)"
|
||||
value: ${{ jobs.version.outputs.version_tag }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: Version Operations
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
current_version: ${{ steps.get-version.outputs.current }}
|
||||
new_version: ${{ steps.bump-version.outputs.new }}
|
||||
version_tag: ${{ steps.bump-version.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_branch }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Get current version
|
||||
id: get-version
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
|
||||
- name: Calculate new version
|
||||
id: bump-version
|
||||
run: |
|
||||
CURRENT="${{ steps.get-version.outputs.current }}"
|
||||
RELEASE_TYPE="${{ inputs.release_type }}"
|
||||
|
||||
# Get current date components
|
||||
YEAR=$(date +%Y)
|
||||
MONTH=$(date +%-m)
|
||||
DAY=$(date +%-d)
|
||||
TODAY="${YEAR}.${MONTH}.${DAY}"
|
||||
|
||||
# Parse current version to check if it's today + same type
|
||||
# Patterns: YYYY.M.D or YYYY.M.D-type.N
|
||||
if [[ "$CURRENT" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([a-z]+)\.([0-9]+))?$ ]]; then
|
||||
CURR_DATE="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
|
||||
CURR_TYPE="${BASH_REMATCH[5]}"
|
||||
CURR_NUM="${BASH_REMATCH[6]:-0}"
|
||||
else
|
||||
CURR_DATE=""
|
||||
CURR_TYPE=""
|
||||
CURR_NUM=0
|
||||
fi
|
||||
|
||||
case "$RELEASE_TYPE" in
|
||||
alpha)
|
||||
if [ "$CURR_DATE" = "$TODAY" ] && [ "$CURR_TYPE" = "alpha" ]; then
|
||||
# Same day, same type - increment prerelease number
|
||||
NEW_NUM=$((CURR_NUM + 1))
|
||||
else
|
||||
# New day or different type - start at 1
|
||||
NEW_NUM=1
|
||||
fi
|
||||
NEW_VERSION="${TODAY}-alpha.${NEW_NUM}"
|
||||
;;
|
||||
beta)
|
||||
if [ "$CURR_DATE" = "$TODAY" ] && [ "$CURR_TYPE" = "beta" ]; then
|
||||
NEW_NUM=$((CURR_NUM + 1))
|
||||
else
|
||||
NEW_NUM=1
|
||||
fi
|
||||
NEW_VERSION="${TODAY}-beta.${NEW_NUM}"
|
||||
;;
|
||||
stable)
|
||||
# Stable releases use date; append counter if tag already exists
|
||||
if git tag -l "v${TODAY}" | grep -q .; then
|
||||
# Tag exists, find next available counter
|
||||
COUNTER=1
|
||||
while git tag -l "v${TODAY}.${COUNTER}" | grep -q .; do
|
||||
COUNTER=$((COUNTER + 1))
|
||||
done
|
||||
NEW_VERSION="${TODAY}.${COUNTER}"
|
||||
else
|
||||
NEW_VERSION="${TODAY}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unknown release type: $RELEASE_TYPE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "new=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "New version: $NEW_VERSION"
|
||||
|
||||
- name: Update package.json
|
||||
if: ${{ inputs.should_bump && !inputs.dry_run }}
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.bump-version.outputs.new }}"
|
||||
|
||||
# Update package.json version
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
pkg.version = '$NEW_VERSION';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
|
||||
echo "Updated package.json to version $NEW_VERSION"
|
||||
|
||||
- name: Sync extension versions
|
||||
if: ${{ inputs.should_bump && !inputs.dry_run }}
|
||||
run: |
|
||||
# Run plugins:sync if available (aligns extension package versions)
|
||||
if npm run --silent plugins:sync 2>/dev/null; then
|
||||
echo "Extension versions synced"
|
||||
else
|
||||
echo "plugins:sync not available, skipping"
|
||||
fi
|
||||
|
||||
- name: Commit version bump
|
||||
if: ${{ inputs.should_bump && !inputs.dry_run }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
NEW_VERSION="${{ steps.bump-version.outputs.new }}"
|
||||
|
||||
# Stage all version-related changes
|
||||
git add package.json
|
||||
git add extensions/*/package.json 2>/dev/null || true
|
||||
|
||||
# Check if there are changes to commit
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version changes to commit"
|
||||
else
|
||||
git commit -m "chore: bump version to $NEW_VERSION"
|
||||
git push origin ${{ inputs.source_branch }}
|
||||
fi
|
||||
|
||||
- name: Create tag
|
||||
if: ${{ inputs.should_bump && !inputs.dry_run }}
|
||||
run: |
|
||||
TAG="${{ steps.bump-version.outputs.tag }}"
|
||||
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
git push origin "$TAG"
|
||||
5
.github/workflows/workflow-sanity.yml
vendored
5
.github/workflows/workflow-sanity.yml
vendored
@@ -3,6 +3,11 @@ name: Workflow Sanity
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -64,10 +64,14 @@ apps/ios/*.mobileprovision
|
||||
|
||||
# Local untracked files
|
||||
.local/
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
# Agent credentials and memory (NEVER COMMIT)
|
||||
memory/
|
||||
.agent/*.json
|
||||
!.agent/workflows/
|
||||
|
||||
52
.markdownlint-cli2.jsonc
Normal file
52
.markdownlint-cli2.jsonc
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"globs": ["docs/**/*.md", "docs/**/*.mdx", "README.md"],
|
||||
"ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**"],
|
||||
"config": {
|
||||
"default": true,
|
||||
|
||||
"MD013": false,
|
||||
"MD025": false,
|
||||
"MD029": false,
|
||||
|
||||
"MD033": {
|
||||
"allowed_elements": [
|
||||
"Note",
|
||||
"Info",
|
||||
"Tip",
|
||||
"Warning",
|
||||
"Card",
|
||||
"CardGroup",
|
||||
"Columns",
|
||||
"Steps",
|
||||
"Step",
|
||||
"Tabs",
|
||||
"Tab",
|
||||
"Accordion",
|
||||
"AccordionGroup",
|
||||
"CodeGroup",
|
||||
"Frame",
|
||||
"Callout",
|
||||
"ParamField",
|
||||
"ResponseField",
|
||||
"RequestExample",
|
||||
"ResponseExample",
|
||||
"img",
|
||||
"a",
|
||||
"br",
|
||||
"details",
|
||||
"summary",
|
||||
"p",
|
||||
"strong",
|
||||
"picture",
|
||||
"source",
|
||||
"Tooltip",
|
||||
"Check",
|
||||
],
|
||||
},
|
||||
|
||||
"MD036": false,
|
||||
"MD040": false,
|
||||
"MD041": false,
|
||||
"MD046": false,
|
||||
},
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
"oxc/no-map-spread": "off",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-extraneous-class": "off",
|
||||
"typescript/no-redundant-type-constituents": "off",
|
||||
"typescript/no-unsafe-type-assertion": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/require-post-message-target-origin": "off"
|
||||
@@ -25,6 +24,7 @@
|
||||
"assets/",
|
||||
"dist/",
|
||||
"docs/_layouts/",
|
||||
"extensions/",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml/",
|
||||
|
||||
@@ -8,98 +8,63 @@ Input
|
||||
- If missing: use the most recent PR mentioned in the conversation.
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (review-only)
|
||||
Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
|
||||
Do (end-to-end)
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
|
||||
|
||||
1. Identify PR meta + context
|
||||
1. Repo clean: `git status`.
|
||||
2. Identify PR meta (author + head branch):
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length}'
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
```
|
||||
|
||||
2. Read the PR description carefully
|
||||
- Summarize the stated goal, scope, and any “why now?” rationale.
|
||||
- Call out any missing context: motivation, alternatives considered, rollout/compat notes, risk.
|
||||
3. Fast-forward base:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
4. Create temp base branch from main:
|
||||
- `git checkout -b temp/landpr-<ts-or-pr>`
|
||||
5. Check out PR branch locally:
|
||||
- `gh pr checkout <PR>`
|
||||
6. Rebase PR branch onto temp base:
|
||||
- `git rebase temp/landpr-<ts-or-pr>`
|
||||
- Fix conflicts; keep history tidy.
|
||||
7. Fix + tests + changelog:
|
||||
- Implement fixes + add/adjust tests
|
||||
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
|
||||
8. Decide merge strategy:
|
||||
- Rebase if we want to preserve commit history
|
||||
- Squash if we want a single clean commit
|
||||
- If unclear, ask
|
||||
9. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
10. Commit via committer (include # + contributor in commit message):
|
||||
- `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
|
||||
- `land_sha=$(git rev-parse HEAD)`
|
||||
11. Push updated PR branch (rebase => usually needs force):
|
||||
|
||||
3. Read the diff thoroughly (prefer full diff)
|
||||
```sh
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
```sh
|
||||
gh pr diff <PR>
|
||||
# If you need more surrounding context for files:
|
||||
gh pr checkout <PR> # optional; still review-only
|
||||
git show --stat
|
||||
```
|
||||
12. Merge PR (must show MERGED on GitHub):
|
||||
- Rebase: `gh pr merge <PR> --rebase`
|
||||
- Squash: `gh pr merge <PR> --squash`
|
||||
- Never `gh pr close` (closing is wrong)
|
||||
13. Sync main:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
14. Comment on PR with what we did + SHAs + thanks:
|
||||
|
||||
4. Validate the change is needed / valuable
|
||||
- What user/customer/dev pain does this solve?
|
||||
- Is this change the smallest reasonable fix?
|
||||
- Are we introducing complexity for marginal benefit?
|
||||
- Are we changing behavior/contract in a way that needs docs or a release note?
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
gh pr comment <PR> --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!"
|
||||
```
|
||||
|
||||
5. Evaluate implementation quality + optimality
|
||||
- Correctness: edge cases, error handling, null/undefined, concurrency, ordering.
|
||||
- Design: is the abstraction/architecture appropriate or over/under-engineered?
|
||||
- Performance: hot paths, allocations, queries, network, N+1s, caching.
|
||||
- Security/privacy: authz/authn, input validation, secrets, logging PII.
|
||||
- Backwards compatibility: public APIs, config, migrations.
|
||||
- Style consistency: formatting, naming, patterns used elsewhere.
|
||||
|
||||
6. Tests & verification
|
||||
- Identify what’s covered by tests (unit/integration/e2e).
|
||||
- Are there regression tests for the bug fixed / scenario added?
|
||||
- Missing tests? Call out exact cases that should be added.
|
||||
- If tests are present, do they actually assert the important behavior (not just snapshots / happy path)?
|
||||
|
||||
7. Follow-up refactors / cleanup suggestions
|
||||
- Any code that should be simplified before merge?
|
||||
- Any TODOs that should be tickets vs addressed now?
|
||||
- Any deprecations, docs, types, or lint rules we should adjust?
|
||||
|
||||
8. Key questions to answer explicitly
|
||||
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
|
||||
- Any blocking concerns (must-fix before merge)?
|
||||
- Is this PR ready to land, or does it need work?
|
||||
|
||||
9. Output (structured)
|
||||
Produce a review with these sections:
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION
|
||||
- 1–3 sentence rationale.
|
||||
|
||||
B) What changed
|
||||
|
||||
- Brief bullet summary of the diff/behavioral changes.
|
||||
|
||||
C) What’s good
|
||||
|
||||
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
|
||||
|
||||
D) Concerns / questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as:
|
||||
- BLOCKER (must fix before merge)
|
||||
- IMPORTANT (should fix before merge)
|
||||
- NIT (optional)
|
||||
- For each: point to the file/area and propose a concrete fix or alternative.
|
||||
|
||||
E) Tests
|
||||
|
||||
- What exists.
|
||||
- What’s missing (specific scenarios).
|
||||
|
||||
F) Follow-ups (optional)
|
||||
|
||||
- Non-blocking refactors/tickets to open later.
|
||||
|
||||
G) Suggested PR comment (optional)
|
||||
|
||||
- Offer: “Want me to draft a PR comment to the author?”
|
||||
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.
|
||||
|
||||
Rules / Guardrails
|
||||
|
||||
- Review only: do not merge (`gh pr merge`), do not push branches, do not edit code.
|
||||
- If you need clarification, ask questions rather than guessing.
|
||||
15. Verify PR state == MERGED:
|
||||
- `gh pr view <PR> --json state --jq .state`
|
||||
16. Delete temp branch:
|
||||
- `git branch -D temp/landpr-<ts-or-pr>`
|
||||
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["oxc.oxc-vscode"]
|
||||
}
|
||||
22
.vscode/settings.json
vendored
Normal file
22
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifierEnding": "js",
|
||||
"typescript.reportStyleChecksAsWarnings": false,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.experimental.useTsgo": true
|
||||
}
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -28,6 +28,14 @@
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
## Docs i18n (zh-CN)
|
||||
|
||||
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
|
||||
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
|
||||
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
|
||||
- See `docs/.i18n/README.md`.
|
||||
- The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it.
|
||||
|
||||
## exe.dev VM ops (general)
|
||||
|
||||
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
||||
@@ -50,6 +58,7 @@
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build`
|
||||
- TypeScript checks: `pnpm tsgo`
|
||||
- Lint/format: `pnpm check`
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
@@ -86,6 +95,8 @@
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- Read this when submitting a PR: `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr))
|
||||
- Read this when submitting an issue: `docs/help/submitting-an-issue.md` ([Submitting an Issue](https://docs.openclaw.ai/help/submitting-an-issue))
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
||||
|
||||
136
CHANGELOG.md
136
CHANGELOG.md
@@ -2,19 +2,149 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
|
||||
- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204.
|
||||
- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204.
|
||||
- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204.
|
||||
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
|
||||
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
|
||||
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
|
||||
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
|
||||
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
|
||||
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
|
||||
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
|
||||
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
|
||||
|
||||
### Added
|
||||
|
||||
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
|
||||
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
|
||||
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
|
||||
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
|
||||
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
|
||||
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.
|
||||
- Update: harden Control UI asset handling in update flow. (#10146) Thanks @gumadeiras.
|
||||
- Security: add skill/plugin code safety scanner; redact credentials from config.get gateway responses. (#9806, #9858) Thanks @abdelsfane.
|
||||
- Exec approvals: coerce bare string allowlist entries to objects. (#9903) Thanks @mcaxtr.
|
||||
- Slack: add mention stripPatterns for /new and /reset. (#9971) Thanks @ironbyte-rgb.
|
||||
- Chrome extension: fix bundled path resolution. (#8914) Thanks @kelvinCB.
|
||||
- Compaction/errors: allow multiple compaction retries on context overflow; show clear billing errors. (#8928, #8391) Thanks @Glucksberg.
|
||||
|
||||
## 2026.2.3
|
||||
|
||||
### Changes
|
||||
|
||||
- Telegram: remove last `@ts-nocheck` from `bot-handlers.ts`, use Grammy types directly, deduplicate `StickerMetadata`. Zero `@ts-nocheck` remaining in `src/telegram/`. (#9206)
|
||||
- Telegram: remove `@ts-nocheck` from `bot-message.ts`, type deps via `Omit<BuildTelegramMessageContextParams>`, widen `allMedia` to `TelegramMediaRef[]`. (#9180)
|
||||
- Telegram: remove `@ts-nocheck` from `bot.ts`, fix duplicate `bot.catch` error handler (Grammy overrides), remove dead reaction `message_thread_id` routing, harden sticker cache guard. (#9077)
|
||||
- Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.
|
||||
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
|
||||
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
|
||||
- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.
|
||||
- Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.
|
||||
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
|
||||
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
|
||||
- Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and `atMs` inputs.
|
||||
- Cron: delete one-shot jobs after success by default; add `--keep-after-run` for CLI.
|
||||
- Cron: suppress messaging tools during announce delivery so summaries post consistently.
|
||||
- Cron: avoid duplicate deliveries when isolated runs send messages directly.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
|
||||
- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
|
||||
- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.
|
||||
- Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.
|
||||
- Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman.
|
||||
- Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo.
|
||||
- Web UI: apply button styling to the new-messages indicator.
|
||||
- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua.
|
||||
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
|
||||
- Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.
|
||||
- Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.
|
||||
- Security: gate `whatsapp_login` tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.
|
||||
- Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.
|
||||
- Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.
|
||||
- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing.
|
||||
- Cron: reload store data when the store file is recreated or mtime changes.
|
||||
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
|
||||
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
|
||||
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
|
||||
|
||||
## 2026.2.2-3
|
||||
|
||||
### Fixes
|
||||
|
||||
- Update: ship legacy daemon-cli shim for pre-tsdown update imports (fixes daemon restart after npm update).
|
||||
|
||||
## 2026.2.2-2
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: promote BlueBubbles as the recommended iMessage integration; mark imsg channel as legacy. (#8415) Thanks @tyler6204.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI status: resolve build-info from bundled dist output (fixes "unknown" commit in npm builds).
|
||||
|
||||
## 2026.2.2-1
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI status: fall back to build-info for version detection (fixes "unknown" in beta builds). Thanks @gumadeira.
|
||||
|
||||
## 2026.2.2
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: seed zh-CN translations. (#6619) Thanks @joshp123.
|
||||
- Docs: expand zh-Hans navigation and fix zh-CN index asset paths. (#7242) Thanks @joshp123.
|
||||
- Docs: add zh-CN landing notice + AI-translated image. (#7303) Thanks @joshp123.
|
||||
- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn).
|
||||
- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.
|
||||
- Subagents: discourage direct messaging tool use unless a specific external recipient is requested.
|
||||
- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.
|
||||
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
|
||||
- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204.
|
||||
- Docs: zh-CN translations seed + polish, pipeline guidance, nav/landing updates, and typo fixes. (#8202, #6995, #6619, #7242, #7303, #7415) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan, @joshp123, @lailoo.
|
||||
- Docs: add zh-CN i18n guardrails to avoid editing generated translations. (#8416) Thanks @joshp123.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir.
|
||||
- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed).
|
||||
- Onboarding: drop completion prompt now handled by install/update.
|
||||
- TUI: block onboarding output while TUI is active and restore terminal state on exit.
|
||||
- CLI: cache shell completion scripts in state dir and source cached files in profiles.
|
||||
- Zsh completion: escape option descriptions to avoid invalid option errors.
|
||||
- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode.
|
||||
- fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001)
|
||||
- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)
|
||||
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
|
||||
- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
|
||||
- Security: enforce access-group gating for Slack slash commands when channel type lookup fails.
|
||||
- Security: require validated shared-secret auth before skipping device identity on gateway connect.
|
||||
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
||||
- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.
|
||||
- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
|
||||
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
||||
- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)
|
||||
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
|
||||
- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode.
|
||||
- fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001)
|
||||
- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
|
||||
- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update.
|
||||
- TUI: block onboarding output while TUI is active and restore terminal state on exit.
|
||||
- CLI/Zsh completion: cache scripts in state dir and escape option descriptions to avoid invalid option errors.
|
||||
- fix(ui): resolve Control UI asset path correctly.
|
||||
- fix(ui): refresh agent files after external edits.
|
||||
- Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir.
|
||||
- Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123.
|
||||
|
||||
## 2026.2.1
|
||||
|
||||
@@ -22,6 +22,12 @@ Welcome to the lobster tank! 🦞
|
||||
- **Christoph Nakazawa** - JS Infra
|
||||
- GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa)
|
||||
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
|
||||
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
|
||||
|
||||
- **Maximilian Nussbaumer** - DevOps, CI/CD
|
||||
- GitHub: [@quotentiroler](https://github.com/quotentiroler)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
@@ -35,6 +41,21 @@ Welcome to the lobster tank! 🦞
|
||||
- Keep PRs focused (one thing per PR)
|
||||
- Describe what & why
|
||||
|
||||
## Control UI Decorators
|
||||
|
||||
The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support
|
||||
`accessor` fields required for standard decorators). When adding reactive fields, keep the
|
||||
legacy style:
|
||||
|
||||
```ts
|
||||
@state() foo = "bar";
|
||||
@property({ type: Number }) count = 0;
|
||||
```
|
||||
|
||||
The root `tsconfig.json` is configured for legacy decorators (`experimentalDecorators: true`)
|
||||
with `useDefineForClassFields: false`. Avoid flipping these unless you are also updating the UI
|
||||
build tooling to support standard decorators.
|
||||
|
||||
## AI/Vibe-Coded PRs Welcome! 🤖
|
||||
|
||||
Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
|
||||
@@ -58,3 +79,46 @@ We are currently prioritizing:
|
||||
- **Performance**: Optimizing token usage and compaction logic.
|
||||
|
||||
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
|
||||
|
||||
## Core vs ClawHub
|
||||
|
||||
Not everything belongs in the main repo. Here's how to decide:
|
||||
|
||||
| Belongs in **Core** | Belongs on **[ClawHub](https://clawhub.ai)** |
|
||||
| ---------------------------------------------- | ---------------------------------------------------- |
|
||||
| Channel integrations (Telegram, Discord, etc.) | Domain-specific skills (QR codes, image tools, etc.) |
|
||||
| CLI commands and infrastructure | Custom workflows and automations |
|
||||
| Provider integrations (LLM backends) | Niche or experimental utilities |
|
||||
| Security, routing, and core plumbing | Third-party service integrations |
|
||||
|
||||
**Rule of thumb:** if it adds new dependencies or is useful to some users but not most, it belongs on ClawHub. When in doubt, ask in Discord or open a Discussion before writing code.
|
||||
|
||||
Skills submitted as PRs to this repo will be redirected to ClawHub. If the core maintainers later decide certain functionality should be first-party, it will be integrated into core.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
> **Note:** The staged promotion pipeline is not yet active. Workflows are in
|
||||
> place but dormant. For now, open PRs targeting `main` as usual. Once the
|
||||
> pipeline is activated, the flow below will apply.
|
||||
|
||||
We will use staged branch promotion to keep `main` stable:
|
||||
|
||||
```
|
||||
dev/* / feature/* / fix/* → develop → alpha → beta → main
|
||||
```
|
||||
|
||||
### For External Contributors (once pipeline is active)
|
||||
|
||||
1. Fork the repo
|
||||
2. Create your branch (`dev/my-feature`, `fix/some-bug`, etc.)
|
||||
3. Open a PR targeting `develop` (not `main`)
|
||||
4. CI runs lightweight checks only — no heavy platform tests on your PR
|
||||
5. Once merged to `develop`, your changes promote through alpha → beta → main automatically
|
||||
|
||||
### For Maintainers (once pipeline is active)
|
||||
|
||||
- **Regular changes**: merge to `develop`, let the pipeline promote
|
||||
- **Hotfixes**: use `hotfix/*` branches for emergency fixes that bypass staging directly to `main`
|
||||
- **Docs-only changes**: skip the test pipeline automatically (paths-ignore)
|
||||
|
||||
See [Pipeline docs](https://docs.openclaw.ai/reference/pipeline) for full details.
|
||||
|
||||
@@ -44,5 +44,5 @@ USER node
|
||||
#
|
||||
# For container platforms requiring external health checks:
|
||||
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
|
||||
# 2. Override CMD: ["node","dist/index.js","gateway","--allow-unconfigured","--bind","lan"]
|
||||
CMD ["node", "dist/index.js", "gateway", "--allow-unconfigured"]
|
||||
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
|
||||
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# iOS App Priorities (OpenClaw / Moltbot)
|
||||
|
||||
This report is based on repo code + docs in `/Users/mariano/Coding/openclaw`, with focus on:
|
||||
|
||||
- iOS Swift sources under `apps/ios/Sources`
|
||||
- Shared Swift packages under `apps/shared/OpenClawKit`
|
||||
- Gateway protocol + node docs in `docs/`
|
||||
- macOS node implementation under `apps/macos/Sources/OpenClaw/NodeMode`
|
||||
|
||||
## Current iOS state (what works today)
|
||||
|
||||
**Gateway connectivity + pairing**
|
||||
|
||||
- Uses the unified Gateway WebSocket protocol with device identity + challenge signing (via `GatewayChannel` in OpenClawKit).
|
||||
- Discovery via Bonjour (`NWBrowser`) for `_openclaw-gw._tcp` plus manual host/port fallback and TLS pinning support (`apps/ios/Sources/Gateway/*`).
|
||||
- Stores gateway token/password in Keychain (`GatewaySettingsStore.swift`).
|
||||
|
||||
**Node command handling** (implemented in `NodeAppModel.handleInvoke`)
|
||||
|
||||
- Canvas: `canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`.
|
||||
- A2UI: `canvas.a2ui.reset`, `canvas.a2ui.push`, `canvas.a2ui.pushJsonl`.
|
||||
- Camera: `camera.list`, `camera.snap`, `camera.clip`.
|
||||
- Screen: `screen.record` (ReplayKit-based screen recording).
|
||||
- Location: `location.get` (CoreLocation-based).
|
||||
- Foreground gating: returns `NODE_BACKGROUND_UNAVAILABLE` for canvas/camera/screen when backgrounded.
|
||||
|
||||
**Voice features**
|
||||
|
||||
- Voice Wake: continuous speech recognition with wake-word gating and gateway sync (`VoiceWakeManager.swift`).
|
||||
- Talk Mode: speech-to-text + chat.send + ElevenLabs streaming TTS + system voice fallback (`TalkModeManager.swift`).
|
||||
|
||||
**Chat UI**
|
||||
|
||||
- Uses shared SwiftUI chat client (`OpenClawChatUI`) and Gateway chat APIs (`IOSGatewayChatTransport.swift`).
|
||||
|
||||
**UI surface**
|
||||
|
||||
- Full-screen canvas with overlay controls for chat, settings, and Talk orb (`RootCanvas.swift`).
|
||||
- Settings for gateway selection, voice, camera, location, screen prevent-sleep, and debug flags (`SettingsTab.swift`).
|
||||
|
||||
## Protocol requirements the iOS app must honor
|
||||
|
||||
From `docs/gateway/protocol.md` + `docs/nodes/index.md` + OpenClawKit:
|
||||
|
||||
- WebSocket `connect` handshake with `role: "node"`, `caps`, `commands`, and `permissions` claims.
|
||||
- Device identity + challenge signing on connect; device token persistence.
|
||||
- Respond to `node.invoke.request` with `node.invoke.result`.
|
||||
- Emit node events (`node.event`) for voice transcripts and agent requests.
|
||||
- Use gateway RPCs needed by the iOS UI: `config.get`, `voicewake.get/set`, `chat.*`, `sessions.list`.
|
||||
|
||||
## Gaps / incomplete or mismatched behavior
|
||||
|
||||
**1) Declared commands exceed iOS implementation**
|
||||
`GatewayConnectionController.currentCommands()` includes:
|
||||
|
||||
- `system.run`, `system.which`, `system.notify`, `system.execApprovals.get`, `system.execApprovals.set`
|
||||
|
||||
…but `NodeAppModel.handleInvoke` does not implement any `system.*` commands and will return `INVALID_REQUEST: unknown command` for them. This is a protocol-level mismatch: the gateway will believe iOS supports system execution + notifications, but the node cannot fulfill those requests.
|
||||
|
||||
**2) Permissions map is always empty**
|
||||
iOS sends `permissions: [:]` in its connect options, while macOS node reports real permission states via `PermissionManager`. This means the gateway cannot reason about iOS permission availability even though camera/mic/location/screen limitations materially affect command success.
|
||||
|
||||
**3) Canvas parity gaps**
|
||||
|
||||
- `canvas.hide` is currently a no-op on iOS (returns ok but doesn’t change UI).
|
||||
- `canvas.present` ignores placement params (macOS supports window placement).
|
||||
|
||||
These may be acceptable platform limitations, but they should be explicitly handled/documented so the node surface is consistent and predictable.
|
||||
|
||||
## iOS vs. macOS node feature parity
|
||||
|
||||
macOS node mode (`apps/macos/Sources/OpenClaw/NodeMode/*`) supports:
|
||||
|
||||
- `system.run`, `system.which`, `system.notify`, `system.execApprovals.get/set`.
|
||||
- Permission reporting in `connect.permissions`.
|
||||
- Canvas window placement + hide.
|
||||
|
||||
iOS currently implements the shared node surface (canvas/camera/screen/location + voice) but does **not** match macOS on the system/exec side and permission reporting.
|
||||
|
||||
## Prioritized work items (ordered by importance)
|
||||
|
||||
1. **Fix the command/implementation mismatch for `system.*`**
|
||||
- Either remove `system.*` from iOS `currentCommands()` **or** implement iOS equivalents (at minimum `system.notify` via local notifications) with clear error semantics for unsupported actions.
|
||||
- This is the highest risk mismatch because it misleads the gateway and any operator about what the iOS node can actually do.
|
||||
|
||||
2. **Report real iOS permission state in `connect.permissions`**
|
||||
- Mirror macOS behavior by sending camera/microphone/location/screen-recording permission flags.
|
||||
- This enables the gateway to make better decisions and reduces “it failed because permissions” surprises.
|
||||
|
||||
3. **Clarify/normalize iOS canvas behaviors**
|
||||
- Decide how `canvas.hide` should behave on iOS (e.g., return to the local scaffold) and implement it.
|
||||
- Document that `canvas.present` ignores placement on iOS, or add a platform-specific best effort.
|
||||
|
||||
4. **Explicitly document platform deltas vs. macOS node**
|
||||
- The docs currently describe `system.*` under “Nodes” and cite macOS/headless node support. iOS should be clearly marked as not supporting system exec to avoid incorrect user expectations.
|
||||
|
||||
5. **Release readiness (if the goal is to move beyond internal preview)**
|
||||
- Docs state the iOS app is “internal preview” (`docs/platforms/ios.md`).
|
||||
- If public distribution is desired, build out TestFlight/App Store release steps (fastlane exists in `apps/ios/fastlane/`).
|
||||
|
||||
## Files referenced (key evidence)
|
||||
|
||||
- iOS node behavior: `apps/ios/Sources/Model/NodeAppModel.swift`
|
||||
- iOS command declarations: `apps/ios/Sources/Gateway/GatewayConnectionController.swift`
|
||||
- iOS discovery + TLS: `apps/ios/Sources/Gateway/*`
|
||||
- iOS voice: `apps/ios/Sources/Voice/*`
|
||||
- iOS screen/camera/location: `apps/ios/Sources/Screen/*`, `apps/ios/Sources/Camera/*`, `apps/ios/Sources/Location/*`
|
||||
- Shared protocol + commands: `apps/shared/OpenClawKit/Sources/OpenClawKit/*`
|
||||
- macOS node runtime: `apps/macos/Sources/OpenClaw/NodeMode/*`
|
||||
- Node + protocol docs: `docs/nodes/index.md`, `docs/gateway/protocol.md`, `docs/platforms/ios.md`
|
||||
103
README.md
103
README.md
@@ -34,7 +34,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
@@ -120,7 +120,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
@@ -144,7 +144,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
### Channels
|
||||
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [iMessage](https://docs.openclaw.ai/channels/imessage) (imsg), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
|
||||
|
||||
### Apps + nodes
|
||||
@@ -316,7 +316,7 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -375,9 +375,15 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
|
||||
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [iMessage](https://docs.openclaw.ai/channels/imessage)
|
||||
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
|
||||
|
||||
- macOS only; Messages must be signed in.
|
||||
- **Recommended** iMessage integration.
|
||||
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
|
||||
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
|
||||
|
||||
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
|
||||
|
||||
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
|
||||
@@ -490,44 +496,49 @@ Special thanks to Adam Doppelt for lobster.bot.
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a>
|
||||
<a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a>
|
||||
<a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a>
|
||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a>
|
||||
<a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="aerolalit" title="aerolalit"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a>
|
||||
<a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a>
|
||||
<a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
|
||||
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggialiang" title="nonggialiang"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a>
|
||||
<a href="https://github.com/evanotero"><img src="https://avatars.githubusercontent.com/u/13204105?v=4&s=48" width="48" height="48" alt="evanotero" title="evanotero"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="jlowin" title="jlowin"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryancontent" title="ryancontent"/></a> <a href="https://github.com/jasonsschin"><img src="https://avatars.githubusercontent.com/u/1456889?v=4&s=48" width="48" height="48" alt="jasonsschin" title="jasonsschin"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/ThanhNguyxn"><img src="https://avatars.githubusercontent.com/u/74597207?v=4&s=48" width="48" height="48" alt="ThanhNguyxn" title="ThanhNguyxn"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="kimitaka" title="kimitaka"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="yuting0624" title="yuting0624"/></a>
|
||||
<a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/baccula"><img src="https://avatars.githubusercontent.com/u/22080883?v=4&s=48" width="48" height="48" alt="baccula" title="baccula"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="manikv12" title="manikv12"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a>
|
||||
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||
<a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a>
|
||||
<a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/Hisleren"><img src="https://avatars.githubusercontent.com/u/83217244?v=4&s=48" width="48" height="48" alt="Hisleren" title="Hisleren"/></a>
|
||||
<a href="https://github.com/shatner"><img src="https://avatars.githubusercontent.com/u/17735435?v=4&s=48" width="48" height="48" alt="shatner" title="shatner"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a>
|
||||
<a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/robhparker"><img src="https://avatars.githubusercontent.com/u/7404740?v=4&s=48" width="48" height="48" alt="robhparker" title="robhparker"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a>
|
||||
<a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/abhijeet117"><img src="https://avatars.githubusercontent.com/u/192859219?v=4&s=48" width="48" height="48" alt="abhijeet117" title="abhijeet117"/></a>
|
||||
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="itsjling" title="itsjling"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a>
|
||||
<a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/spiceoogway"><img src="https://avatars.githubusercontent.com/u/105812383?v=4&s=48" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
||||
<a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/bravostation"><img src="https://avatars.githubusercontent.com/u/257991910?v=4&s=48" width="48" height="48" alt="bravostation" title="bravostation"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a>
|
||||
<a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a>
|
||||
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/search?q=xiaose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="xiaose" title="xiaose"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="danballance" title="danballance"/></a>
|
||||
<a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a>
|
||||
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/tewatia"><img src="https://avatars.githubusercontent.com/u/22875334?v=4&s=48" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
|
||||
<a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
||||
<a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/search?q=Ayush%20Ojha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ayush Ojha" title="Ayush Ojha"/></a> <a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a>
|
||||
<a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a>
|
||||
<a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a> <a href="https://github.com/fredheir"><img src="https://avatars.githubusercontent.com/u/3304869?v=4&s=48" width="48" height="48" alt="fredheir" title="fredheir"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HassanFleyah"><img src="https://avatars.githubusercontent.com/u/228002017?v=4&s=48" width="48" height="48" alt="HassanFleyah" title="HassanFleyah"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hclsys" title="hclsys"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||
<a href="https://github.com/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a>
|
||||
<a href="https://github.com/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loganaden"><img src="https://avatars.githubusercontent.com/u/1688420?v=4&s=48" width="48" height="48" alt="loganaden" title="loganaden"/></a> <a href="https://github.com/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a>
|
||||
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/mylukin"><img src="https://avatars.githubusercontent.com/u/1021019?v=4&s=48" width="48" height="48" alt="mylukin" title="mylukin"/></a> <a href="https://github.com/nathanbosse"><img src="https://avatars.githubusercontent.com/u/4040669?v=4&s=48" width="48" height="48" alt="nathanbosse" title="nathanbosse"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ozgur-polat"><img src="https://avatars.githubusercontent.com/u/26483942?v=4&s=48" width="48" height="48" alt="ozgur-polat" title="ozgur-polat"/></a>
|
||||
<a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/ryancnelson"><img src="https://avatars.githubusercontent.com/u/347171?v=4&s=48" width="48" height="48" alt="ryancnelson" title="ryancnelson"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a>
|
||||
<a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></a>
|
||||
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/yevhen"><img src="https://avatars.githubusercontent.com/u/107726?v=4&s=48" width="48" height="48" alt="yevhen" title="yevhen"/></a> <a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a>
|
||||
<a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
|
||||
<a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a>
|
||||
<a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a>
|
||||
<a href="https://github.com/abdelsfane"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="abdelsfane" title="abdelsfane"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="ethanpalm" title="ethanpalm"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a>
|
||||
<a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
|
||||
<a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
|
||||
<a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/M00N7682"><img src="https://avatars.githubusercontent.com/u/170746674?v=4&s=48" width="48" height="48" alt="M00N7682" title="M00N7682"/></a>
|
||||
<a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="aerolalit" title="aerolalit"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/lsh411"><img src="https://avatars.githubusercontent.com/u/6801488?v=4&s=48" width="48" height="48" alt="lsh411" title="lsh411"/></a>
|
||||
<a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="gut-puncture" title="gut-puncture"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
|
||||
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="pycckuu" title="pycckuu"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
|
||||
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="TinyTb" title="TinyTb"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/nicolasstanley"><img src="https://avatars.githubusercontent.com/u/60584925?v=4&s=48" width="48" height="48" alt="nicolasstanley" title="nicolasstanley"/></a> <a href="https://github.com/davidiach"><img src="https://avatars.githubusercontent.com/u/28102235?v=4&s=48" width="48" height="48" alt="davidiach" title="davidiach"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggialiang" title="nonggialiang"/></a>
|
||||
<a href="https://github.com/ironbyte-rgb"><img src="https://avatars.githubusercontent.com/u/230665944?v=4&s=48" width="48" height="48" alt="ironbyte-rgb" title="ironbyte-rgb"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a>
|
||||
<a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/evanotero"><img src="https://avatars.githubusercontent.com/u/13204105?v=4&s=48" width="48" height="48" alt="evanotero" title="evanotero"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="jlowin" title="jlowin"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a>
|
||||
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryancontent" title="ryancontent"/></a> <a href="https://github.com/jasonsschin"><img src="https://avatars.githubusercontent.com/u/1456889?v=4&s=48" width="48" height="48" alt="jasonsschin" title="jasonsschin"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a>
|
||||
<a href="https://github.com/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/ThanhNguyxn"><img src="https://avatars.githubusercontent.com/u/74597207?v=4&s=48" width="48" height="48" alt="ThanhNguyxn" title="ThanhNguyxn"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="18-RAJAT" title="18-RAJAT"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="kimitaka" title="kimitaka"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="yuting0624" title="yuting0624"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/baccula"><img src="https://avatars.githubusercontent.com/u/22080883?v=4&s=48" width="48" height="48" alt="baccula" title="baccula"/></a>
|
||||
<a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="manikv12" title="manikv12"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/fujiwara-tofu-shop"><img src="https://avatars.githubusercontent.com/u/259415332?v=4&s=48" width="48" height="48" alt="fujiwara-tofu-shop" title="fujiwara-tofu-shop"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
||||
<a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||
<a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a>
|
||||
<a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/therealZpoint-bot"><img src="https://avatars.githubusercontent.com/u/258706705?v=4&s=48" width="48" height="48" alt="therealZpoint-bot" title="therealZpoint-bot"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></a>
|
||||
<a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Hisleren"><img src="https://avatars.githubusercontent.com/u/83217244?v=4&s=48" width="48" height="48" alt="Hisleren" title="Hisleren"/></a> <a href="https://github.com/shatner"><img src="https://avatars.githubusercontent.com/u/17735435?v=4&s=48" width="48" height="48" alt="shatner" title="shatner"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/robhparker"><img src="https://avatars.githubusercontent.com/u/7404740?v=4&s=48" width="48" height="48" alt="robhparker" title="robhparker"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
|
||||
<a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
|
||||
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/abhijeet117"><img src="https://avatars.githubusercontent.com/u/192859219?v=4&s=48" width="48" height="48" alt="abhijeet117" title="abhijeet117"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
|
||||
<a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="itsjling" title="itsjling"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="mattqdev" title="mattqdev"/></a> <a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a>
|
||||
<a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/spiceoogway"><img src="https://avatars.githubusercontent.com/u/105812383?v=4&s=48" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
|
||||
<a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/bravostation"><img src="https://avatars.githubusercontent.com/u/257991910?v=4&s=48" width="48" height="48" alt="bravostation" title="bravostation"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a>
|
||||
<a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/search?q=xiaose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="xiaose" title="xiaose"/></a>
|
||||
<a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/bqcfjwhz85-arch"><img src="https://avatars.githubusercontent.com/u/239267175?v=4&s=48" width="48" height="48" alt="bqcfjwhz85-arch" title="bqcfjwhz85-arch"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a>
|
||||
<a href="https://github.com/search?q=damaozi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="damaozi" title="damaozi"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="danballance" title="danballance"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hclsys" title="hclsys"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a>
|
||||
<a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Marco%20Marandiz"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marco Marandiz" title="Marco Marandiz"/></a>
|
||||
<a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mattezell"><img src="https://avatars.githubusercontent.com/u/361409?v=4&s=48" width="48" height="48" alt="mattezell" title="mattezell"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
|
||||
<a href="https://github.com/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/tewatia"><img src="https://avatars.githubusercontent.com/u/22875334?v=4&s=48" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a>
|
||||
<a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/AlexZhangji"><img src="https://avatars.githubusercontent.com/u/3280924?v=4&s=48" width="48" height="48" alt="AlexZhangji" title="AlexZhangji"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
|
||||
<a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/search?q=Ayush%20Ojha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ayush Ojha" title="Ayush Ojha"/></a> <a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a>
|
||||
<a href="https://github.com/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/deepsoumya617"><img src="https://avatars.githubusercontent.com/u/80877391?v=4&s=48" width="48" height="48" alt="deepsoumya617" title="deepsoumya617"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a>
|
||||
<a href="https://github.com/dvrshil"><img src="https://avatars.githubusercontent.com/u/81693876?v=4&s=48" width="48" height="48" alt="dvrshil" title="dvrshil"/></a> <a href="https://github.com/dxd5001"><img src="https://avatars.githubusercontent.com/u/1886046?v=4&s=48" width="48" height="48" alt="dxd5001" title="dxd5001"/></a> <a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a> <a href="https://github.com/fredheir"><img src="https://avatars.githubusercontent.com/u/3304869?v=4&s=48" width="48" height="48" alt="fredheir" title="fredheir"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a>
|
||||
<a href="https://github.com/HassanFleyah"><img src="https://avatars.githubusercontent.com/u/228002017?v=4&s=48" width="48" height="48" alt="HassanFleyah" title="HassanFleyah"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a> <a href="https://github.com/ichbinlucaskim"><img src="https://avatars.githubusercontent.com/u/125564751?v=4&s=48" width="48" height="48" alt="ichbinlucaskim" title="ichbinlucaskim"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
|
||||
<a href="https://github.com/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loganaden"><img src="https://avatars.githubusercontent.com/u/1688420?v=4&s=48" width="48" height="48" alt="loganaden" title="loganaden"/></a> <a href="https://github.com/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></a>
|
||||
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/search?q=mac%20mimi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac mimi" title="mac mimi"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
|
||||
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/mylukin"><img src="https://avatars.githubusercontent.com/u/1021019?v=4&s=48" width="48" height="48" alt="mylukin" title="mylukin"/></a> <a href="https://github.com/nathanbosse"><img src="https://avatars.githubusercontent.com/u/4040669?v=4&s=48" width="48" height="48" alt="nathanbosse" title="nathanbosse"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Omar-Khaleel"><img src="https://avatars.githubusercontent.com/u/240748662?v=4&s=48" width="48" height="48" alt="Omar-Khaleel" title="Omar-Khaleel"/></a> <a href="https://github.com/ozgur-polat"><img src="https://avatars.githubusercontent.com/u/26483942?v=4&s=48" width="48" height="48" alt="ozgur-polat" title="ozgur-polat"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
|
||||
<a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/rafelbev"><img src="https://avatars.githubusercontent.com/u/467120?v=4&s=48" width="48" height="48" alt="rafelbev" title="rafelbev"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/ryancnelson"><img src="https://avatars.githubusercontent.com/u/347171?v=4&s=48" width="48" height="48" alt="ryancnelson" title="ryancnelson"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a>
|
||||
<a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/Shrinija17"><img src="https://avatars.githubusercontent.com/u/199155426?v=4&s=48" width="48" height="48" alt="Shrinija17" title="Shrinija17"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/stephenchen2025"><img src="https://avatars.githubusercontent.com/u/218387130?v=4&s=48" width="48" height="48" alt="stephenchen2025" title="stephenchen2025"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
||||
<a href="https://github.com/search?q=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/wytheme"><img src="https://avatars.githubusercontent.com/u/5009358?v=4&s=48" width="48" height="48" alt="wytheme" title="wytheme"/></a> <a href="https://github.com/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a>
|
||||
<a href="https://github.com/yevhen"><img src="https://avatars.githubusercontent.com/u/107726?v=4&s=48" width="48" height="48" alt="yevhen" title="yevhen"/></a> <a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
273
appcast.xml
273
appcast.xml
@@ -2,6 +2,100 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.2.3</title>
|
||||
<pubDate>Wed, 04 Feb 2026 17:47:10 -0800</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8900</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.3</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Telegram: remove last <code>@ts-nocheck</code> from <code>bot-handlers.ts</code>, use Grammy types directly, deduplicate <code>StickerMetadata</code>. Zero <code>@ts-nocheck</code> remaining in <code>src/telegram/</code>. (#9206)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot-message.ts</code>, type deps via <code>Omit<BuildTelegramMessageContextParams></code>, widen <code>allMedia</code> to <code>TelegramMediaRef[]</code>. (#9180)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot.ts</code>, fix duplicate <code>bot.catch</code> error handler (Grammy overrides), remove dead reaction <code>message_thread_id</code> routing, harden sticker cache guard. (#9077)</li>
|
||||
<li>Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.</li>
|
||||
<li>Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.</li>
|
||||
<li>Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.</li>
|
||||
<li>Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.</li>
|
||||
<li>Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.</li>
|
||||
<li>Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.</li>
|
||||
<li>Cron: default isolated jobs to announce delivery; accept ISO 8601 <code>schedule.at</code> in tool inputs.</li>
|
||||
<li>Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and <code>atMs</code> inputs.</li>
|
||||
<li>Cron: delete one-shot jobs after success by default; add <code>--keep-after-run</code> for CLI.</li>
|
||||
<li>Cron: suppress messaging tools during announce delivery so summaries post consistently.</li>
|
||||
<li>Cron: avoid duplicate deliveries when isolated runs send messages directly.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.</li>
|
||||
<li>TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.</li>
|
||||
<li>Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.</li>
|
||||
<li>Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.</li>
|
||||
<li>Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman.</li>
|
||||
<li>Web UI: resolve header logo path when <code>gateway.controlUi.basePath</code> is set. (#7178) Thanks @Yeom-JinHo.</li>
|
||||
<li>Web UI: apply button styling to the new-messages indicator.</li>
|
||||
<li>Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.</li>
|
||||
<li>Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.</li>
|
||||
<li>Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.</li>
|
||||
<li>Security: gate <code>whatsapp_login</code> tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.</li>
|
||||
<li>Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.</li>
|
||||
<li>Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.</li>
|
||||
<li>Cron: accept epoch timestamps and 0ms durations in CLI <code>--at</code> parsing.</li>
|
||||
<li>Cron: reload store data when the store file is recreated or mtime changes.</li>
|
||||
<li>Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.</li>
|
||||
<li>Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.</li>
|
||||
<li>macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.3/OpenClaw-2026.2.3.zip" length="22530161" type="application/octet-stream" sparkle:edSignature="7eHUaQC6cx87HWbcaPh9T437+LqfE9VtQBf4p9JBjIyBrqGYxxp9KPvI5unEjg55j9j2djCXhseSMeyyRmvYBg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.2</title>
|
||||
<pubDate>Tue, 03 Feb 2026 17:04:17 -0800</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8809</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn).</li>
|
||||
<li>Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.</li>
|
||||
<li>Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.</li>
|
||||
<li>Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.</li>
|
||||
<li>Config: allow setting a default subagent thinking level via <code>agents.defaults.subagents.thinking</code> (and per-agent <code>agents.list[].subagents.thinking</code>). (#7372) Thanks @tyler6204.</li>
|
||||
<li>Docs: zh-CN translations seed + polish, pipeline guidance, nav/landing updates, and typo fixes. (#8202, #6995, #6619, #7242, #7303, #7415) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan, @joshp123, @lailoo.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security: require operator.approvals for gateway /approve commands. (#1) Thanks @mitsuhiko, @yueyueL.</li>
|
||||
<li>Security: Matrix allowlists now require full MXIDs; ambiguous name resolution no longer grants access. Thanks @MegaManSec.</li>
|
||||
<li>Security: enforce access-group gating for Slack slash commands when channel type lookup fails.</li>
|
||||
<li>Security: require validated shared-secret auth before skipping device identity on gateway connect.</li>
|
||||
<li>Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).</li>
|
||||
<li>Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.</li>
|
||||
<li>fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)</li>
|
||||
<li>Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.</li>
|
||||
<li>fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)</li>
|
||||
<li>Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.</li>
|
||||
<li>Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode.</li>
|
||||
<li>fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001)</li>
|
||||
<li>Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.</li>
|
||||
<li>Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update.</li>
|
||||
<li>TUI: block onboarding output while TUI is active and restore terminal state on exit.</li>
|
||||
<li>CLI/Zsh completion: cache scripts in state dir and escape option descriptions to avoid invalid option errors.</li>
|
||||
<li>fix(ui): resolve Control UI asset path correctly.</li>
|
||||
<li>fix(ui): refresh agent files after external edits.</li>
|
||||
<li>Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir.</li>
|
||||
<li>Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.2/OpenClaw-2026.2.2.zip" length="22519052" type="application/octet-stream" sparkle:edSignature="a6viD+aS5EfY/RkPIPMfoQQNkJCk6QTdV5WobXFxyYwURskUm8/nXTHVXsCh1c5+0WKUnmlDIyf0i+6IWiavAA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.1</title>
|
||||
<pubDate>Mon, 02 Feb 2026 03:53:03 -0800</pubDate>
|
||||
@@ -68,182 +162,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.1/OpenClaw-2026.2.1.zip" length="22458919" type="application/octet-stream" sparkle:edSignature="kA/8VQlVdtYphcB1iuFrhWczwWKgkVZMfDfQ7T9WD405D8JKTv5CZ1n8lstIVkpk4xog3UhrfaaoTG8Bf8DMAQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.30</title>
|
||||
<pubDate>Sat, 31 Jan 2026 14:29:57 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8469</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.30</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.1.30</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: add <code>completion</code> command (Zsh/Bash/PowerShell/Fish) and auto-setup during postinstall/onboarding.</li>
|
||||
<li>CLI: add per-agent <code>models status</code> (<code>--agent</code> filter). (#4780) Thanks @jlowin.</li>
|
||||
<li>Agents: add Kimi K2.5 to the synthetic model catalog. (#4407) Thanks @manikv12.</li>
|
||||
<li>Auth: switch Kimi Coding to built-in provider; normalize OAuth profile email.</li>
|
||||
<li>Auth: add MiniMax OAuth plugin + onboarding option. (#4521) Thanks @Maosghoul.</li>
|
||||
<li>Agents: update pi SDK/API usage and dependencies.</li>
|
||||
<li>Web UI: refresh sessions after chat commands and improve session display names.</li>
|
||||
<li>Build: move TypeScript builds to <code>tsdown</code> + <code>tsgo</code> (faster builds, CI typechecks), update tsconfig target, and clean up lint rules.</li>
|
||||
<li>Build: align npm tar override and bin metadata so the <code>openclaw</code> CLI entrypoint is preserved in npm publishes.</li>
|
||||
<li>Docs: add pi/pi-dev docs and update OpenClaw branding + install links.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security: restrict local path extraction in media parser to prevent LFI. (#4880)</li>
|
||||
<li>Gateway: prevent token defaults from becoming the literal "undefined". (#4873) Thanks @Hisleren.</li>
|
||||
<li>Control UI: fix assets resolution for npm global installs. (#4909) Thanks @YuriNachos.</li>
|
||||
<li>macOS: avoid stderr pipe backpressure in gateway discovery. (#3304) Thanks @abhijeet117.</li>
|
||||
<li>Telegram: normalize account token lookup for non-normalized IDs. (#5055) Thanks @jasonsschin.</li>
|
||||
<li>Telegram: preserve delivery thread fallback and fix threadId handling in delivery context.</li>
|
||||
<li>Telegram: fix HTML nesting for overlapping styles/links. (#4578) Thanks @ThanhNguyxn.</li>
|
||||
<li>Telegram: accept numeric messageId/chatId in react actions. (#4533) Thanks @Ayush10.</li>
|
||||
<li>Telegram: honor per-account proxy dispatcher via undici fetch. (#4456) Thanks @spiceoogway.</li>
|
||||
<li>Telegram: scope skill commands to bound agent per bot. (#4360) Thanks @robhparker.</li>
|
||||
<li>BlueBubbles: debounce by messageId to preserve attachments in text+image messages. (#4984)</li>
|
||||
<li>Routing: prefer requesterOrigin over stale session entries for sub-agent announce delivery. (#4957)</li>
|
||||
<li>Extensions: restore embedded extension discovery typings.</li>
|
||||
<li>CLI: fix <code>tui:dev</code> port resolution.</li>
|
||||
<li>LINE: fix status command TypeError. (#4651)</li>
|
||||
<li>OAuth: skip expired-token warnings when refresh tokens are still valid. (#4593)</li>
|
||||
<li>Build: skip redundant UI install step in Dockerfile. (#4584) Thanks @obviyus.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.1.30/OpenClaw-2026.1.30.zip" length="22458594" type="application/octet-stream" sparkle:edSignature="77/GuEcruKGgu2CJyMq+OVwzaJ2v1VzRQC9NmOirKO3uH5Nn5HaoouwrOHnOanrzlD4OvPW0FS5GH2E4Ntu4CQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.29</title>
|
||||
<pubDate>Fri, 30 Jan 2026 06:24:15 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8345</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.29</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.1.29</h2>
|
||||
Status: stable.
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Rebrand: rename the npm package/CLI to <code>openclaw</code>, add a <code>openclaw</code> compatibility shim, and move extensions to the <code>@openclaw/*</code> scope.</li>
|
||||
<li>Onboarding: strengthen security warning copy for beta + access control expectations.</li>
|
||||
<li>Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.</li>
|
||||
<li>Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames.</li>
|
||||
<li>Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.</li>
|
||||
<li>Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)</li>
|
||||
<li>Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.</li>
|
||||
<li>Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.</li>
|
||||
<li>Browser: route browser control via gateway/node; remove standalone browser control command and control URL config.</li>
|
||||
<li>Browser: route <code>browser.request</code> via node proxies when available; honor proxy timeouts; derive browser ports from <code>gateway.port</code>.</li>
|
||||
<li>Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.</li>
|
||||
<li>Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.</li>
|
||||
<li>Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.</li>
|
||||
<li>Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.</li>
|
||||
<li>Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.</li>
|
||||
<li>Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.</li>
|
||||
<li>Telegram: support quote replies for message tool and inbound context. (#2900) Thanks @aduk059.</li>
|
||||
<li>Telegram: add sticker receive/send with vision caching. (#2629) Thanks @longjos.</li>
|
||||
<li>Telegram: send sticker pixels to vision models. (#2650)</li>
|
||||
<li>Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.</li>
|
||||
<li>Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.</li>
|
||||
<li>Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.</li>
|
||||
<li>Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.</li>
|
||||
<li>Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.</li>
|
||||
<li>Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt.</li>
|
||||
<li>Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.</li>
|
||||
<li>Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.</li>
|
||||
<li>Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)</li>
|
||||
<li>Memory Search: allow extra paths for memory indexing (ignores symlinks). (#3600) Thanks @kira-ariaki.</li>
|
||||
<li>Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.</li>
|
||||
<li>Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.</li>
|
||||
<li>Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev.</li>
|
||||
<li>Routing: add per-account DM session scope and document multi-account isolation. (#3095) Thanks @jarvis-sam.</li>
|
||||
<li>Routing: precompile session key regexes. (#1697) Thanks @Ray0907.</li>
|
||||
<li>CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0.</li>
|
||||
<li>Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.</li>
|
||||
<li>TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.</li>
|
||||
<li>macOS: finish OpenClaw app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3.</li>
|
||||
<li>Branding: update launchd labels, mobile bundle IDs, and logging subsystems to bot.molt (legacy bundle ID migrations). Thanks @thewilloftheshadow.</li>
|
||||
<li>macOS: limit project-local <code>node_modules/.bin</code> PATH preference to debug builds (reduce PATH hijacking risk).</li>
|
||||
<li>macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.</li>
|
||||
<li>macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.</li>
|
||||
<li>Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.</li>
|
||||
<li>Build: bundle A2UI assets during build and stop tracking generated bundles. (#2455) Thanks @0oAstro.</li>
|
||||
<li>CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.</li>
|
||||
<li>Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.</li>
|
||||
<li>Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.</li>
|
||||
<li>Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.</li>
|
||||
<li>Docs: add migration guide for moving to a new machine. (#2381)</li>
|
||||
<li>Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.</li>
|
||||
<li>Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.</li>
|
||||
<li>Docs: add Render deployment guide. (#1975) Thanks @anurag.</li>
|
||||
<li>Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.</li>
|
||||
<li>Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.</li>
|
||||
<li>Docs: add Oracle Cloud (OCI) platform guide + cross-links. (#2333) Thanks @hirefrank.</li>
|
||||
<li>Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.</li>
|
||||
<li>Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.</li>
|
||||
<li>Docs: add LINE channel guide. Thanks @thewilloftheshadow.</li>
|
||||
<li>Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.</li>
|
||||
<li>Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.</li>
|
||||
<li>Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796)</li>
|
||||
<li>Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R.</li>
|
||||
<li>Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.</li>
|
||||
<li>Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.</li>
|
||||
<li>Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.</li>
|
||||
<li>Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.</li>
|
||||
<li>Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.</li>
|
||||
<li>Telegram: include AccountId in native command context for multi-agent routing. (#2942) Thanks @Chloe-VP.</li>
|
||||
<li>Telegram: handle video note attachments in media extraction. (#2905) Thanks @mylukin.</li>
|
||||
<li>TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.</li>
|
||||
<li>macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.</li>
|
||||
<li>Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.</li>
|
||||
<li>Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.</li>
|
||||
<li>Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.</li>
|
||||
<li>Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow.</li>
|
||||
<li>Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb.</li>
|
||||
<li>Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent.</li>
|
||||
<li>Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang.</li>
|
||||
<li>Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui.</li>
|
||||
<li>Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.</li>
|
||||
<li>TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.</li>
|
||||
<li>Security: pin npm overrides to keep tar@7.5.4 for install toolchains.</li>
|
||||
<li>Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.</li>
|
||||
<li>CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo.</li>
|
||||
<li>CLI: avoid prompting for gateway runtime under the spinner. (#2874)</li>
|
||||
<li>BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.</li>
|
||||
<li>Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.</li>
|
||||
<li>CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.</li>
|
||||
<li>Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.</li>
|
||||
<li>Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.</li>
|
||||
<li>Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.</li>
|
||||
<li>Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.</li>
|
||||
<li>Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1.</li>
|
||||
<li>Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.</li>
|
||||
<li>Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.</li>
|
||||
<li>Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.</li>
|
||||
<li>Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.</li>
|
||||
<li>Media: fix text attachment MIME misclassification with CSV/TSV inference and UTF-16 detection; add XML attribute escaping for file output. (#3628) Thanks @frankekn.</li>
|
||||
<li>Build: align memory-core peer dependency with lockfile.</li>
|
||||
<li>Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.</li>
|
||||
<li>Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.</li>
|
||||
<li>Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.</li>
|
||||
<li>Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.</li>
|
||||
<li>Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).</li>
|
||||
<li>Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.</li>
|
||||
<li>Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.1.29/OpenClaw-2026.1.29.zip" length="22458204" type="application/octet-stream" sparkle:edSignature="HqHwZHQyG/CEfBuQnQ/RffJQPKpSbCVrho9C6rgt93S5ek4AH6hUhB3BBKY8sbX1IVFATKK5QZZNE0YPAf7eBw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602010
|
||||
versionName = "2026.2.1"
|
||||
versionCode = 202602030
|
||||
versionName = "2026.2.6"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
final class CalendarService: CalendarServicing {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
])
|
||||
}
|
||||
|
||||
let (start, end) = Self.resolveRange(
|
||||
startISO: params.startISO,
|
||||
endISO: params.endISO)
|
||||
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil)
|
||||
let events = store.events(matching: predicate)
|
||||
let limit = max(1, min(params.limit ?? 50, 500))
|
||||
let selected = Array(events.prefix(limit))
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let payload = selected.map { event in
|
||||
OpenClawCalendarEventPayload(
|
||||
identifier: event.eventIdentifier ?? UUID().uuidString,
|
||||
title: event.title ?? "(untitled)",
|
||||
startISO: formatter.string(from: event.startDate),
|
||||
endISO: formatter.string(from: event.endDate),
|
||||
isAllDay: event.isAllDay,
|
||||
location: event.location,
|
||||
calendarTitle: event.calendar.title)
|
||||
}
|
||||
|
||||
return OpenClawCalendarEventsPayload(events: payload)
|
||||
}
|
||||
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
])
|
||||
}
|
||||
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
throw NSError(domain: "Calendar", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_INVALID: title required",
|
||||
])
|
||||
}
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
guard let start = formatter.date(from: params.startISO) else {
|
||||
throw NSError(domain: "Calendar", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_INVALID: startISO required",
|
||||
])
|
||||
}
|
||||
guard let end = formatter.date(from: params.endISO) else {
|
||||
throw NSError(domain: "Calendar", code: 5, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_INVALID: endISO required",
|
||||
])
|
||||
}
|
||||
|
||||
let event = EKEvent(eventStore: store)
|
||||
event.title = title
|
||||
event.startDate = start
|
||||
event.endDate = end
|
||||
event.isAllDay = params.isAllDay ?? false
|
||||
if let location = params.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty {
|
||||
event.location = location
|
||||
}
|
||||
if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
|
||||
event.notes = notes
|
||||
}
|
||||
event.calendar = try Self.resolveCalendar(
|
||||
store: store,
|
||||
calendarId: params.calendarId,
|
||||
calendarTitle: params.calendarTitle)
|
||||
|
||||
try store.save(event, span: .thisEvent)
|
||||
|
||||
let payload = OpenClawCalendarEventPayload(
|
||||
identifier: event.eventIdentifier ?? UUID().uuidString,
|
||||
title: event.title ?? title,
|
||||
startISO: formatter.string(from: event.startDate),
|
||||
endISO: formatter.string(from: event.endDate),
|
||||
isAllDay: event.isAllDay,
|
||||
location: event.location,
|
||||
calendarTitle: event.calendar.title)
|
||||
|
||||
return OpenClawCalendarAddPayload(event: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
return await withCheckedContinuation { cont in
|
||||
store.requestAccess(to: .event) { granted, _ in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
return await withCheckedContinuation { cont in
|
||||
store.requestAccess(to: .event) { granted, _ in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveCalendar(
|
||||
store: EKEventStore,
|
||||
calendarId: String?,
|
||||
calendarTitle: String?) throws -> EKCalendar
|
||||
{
|
||||
if let id = calendarId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty,
|
||||
let calendar = store.calendar(withIdentifier: id)
|
||||
{
|
||||
return calendar
|
||||
}
|
||||
|
||||
if let title = calendarTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
if let calendar = store.calendars(for: .event).first(where: {
|
||||
$0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
|
||||
}) {
|
||||
return calendar
|
||||
}
|
||||
throw NSError(domain: "Calendar", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no calendar named \(title)",
|
||||
])
|
||||
}
|
||||
|
||||
if let fallback = store.defaultCalendarForNewEvents {
|
||||
return fallback
|
||||
}
|
||||
|
||||
throw NSError(domain: "Calendar", code: 7, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no default calendar",
|
||||
])
|
||||
}
|
||||
|
||||
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let start = startISO.flatMap { formatter.date(from: $0) } ?? Date()
|
||||
let end = endISO.flatMap { formatter.date(from: $0) } ?? start.addingTimeInterval(7 * 24 * 3600)
|
||||
return (start, end)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class NodeCapabilityRouter {
|
||||
enum RouterError: Error {
|
||||
case unknownCommand
|
||||
case handlerUnavailable
|
||||
}
|
||||
|
||||
typealias Handler = (BridgeInvokeRequest) async throws -> BridgeInvokeResponse
|
||||
|
||||
private let handlers: [String: Handler]
|
||||
|
||||
init(handlers: [String: Handler]) {
|
||||
self.handlers = handlers
|
||||
}
|
||||
|
||||
func handle(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
guard let handler = handlers[request.command] else {
|
||||
throw RouterError.unknownCommand
|
||||
}
|
||||
return try await handler(request)
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import Contacts
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
final class ContactsService: ContactsServicing {
|
||||
private static var payloadKeys: [CNKeyDescriptor] {
|
||||
[
|
||||
CNContactIdentifierKey as CNKeyDescriptor,
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
]
|
||||
}
|
||||
|
||||
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
|
||||
let limit = max(1, min(params.limit ?? 25, 200))
|
||||
|
||||
var contacts: [CNContact] = []
|
||||
if let query = params.query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
|
||||
let predicate = CNContact.predicateForContacts(matchingName: query)
|
||||
contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
|
||||
} else {
|
||||
let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys)
|
||||
try store.enumerateContacts(with: request) { contact, stop in
|
||||
contacts.append(contact)
|
||||
if contacts.count >= limit {
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sliced = Array(contacts.prefix(limit))
|
||||
let payload = sliced.map { Self.payload(from: $0) }
|
||||
|
||||
return OpenClawContactsSearchPayload(contacts: payload)
|
||||
}
|
||||
|
||||
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
|
||||
let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let organizationName = params.organizationName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let displayName = params.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let phoneNumbers = Self.normalizeStrings(params.phoneNumbers)
|
||||
let emails = Self.normalizeStrings(params.emails, lowercased: true)
|
||||
|
||||
let hasName = !(givenName ?? "").isEmpty || !(familyName ?? "").isEmpty || !(displayName ?? "").isEmpty
|
||||
let hasOrg = !(organizationName ?? "").isEmpty
|
||||
let hasDetails = !phoneNumbers.isEmpty || !emails.isEmpty
|
||||
guard hasName || hasOrg || hasDetails else {
|
||||
throw NSError(domain: "Contacts", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_INVALID: include a name, organization, phone, or email",
|
||||
])
|
||||
}
|
||||
|
||||
if !phoneNumbers.isEmpty || !emails.isEmpty {
|
||||
if let existing = try Self.findExistingContact(
|
||||
store: store,
|
||||
phoneNumbers: phoneNumbers,
|
||||
emails: emails)
|
||||
{
|
||||
return OpenClawContactsAddPayload(contact: Self.payload(from: existing))
|
||||
}
|
||||
}
|
||||
|
||||
let contact = CNMutableContact()
|
||||
contact.givenName = givenName ?? ""
|
||||
contact.familyName = familyName ?? ""
|
||||
contact.organizationName = organizationName ?? ""
|
||||
if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName {
|
||||
contact.givenName = displayName
|
||||
}
|
||||
contact.phoneNumbers = phoneNumbers.map {
|
||||
CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0))
|
||||
}
|
||||
contact.emailAddresses = emails.map {
|
||||
CNLabeledValue(label: CNLabelHome, value: $0 as NSString)
|
||||
}
|
||||
|
||||
let save = CNSaveRequest()
|
||||
save.add(contact, toContainerWithIdentifier: nil)
|
||||
try store.execute(save)
|
||||
|
||||
let persisted: CNContact
|
||||
if !contact.identifier.isEmpty {
|
||||
persisted = try store.unifiedContact(
|
||||
withIdentifier: contact.identifier,
|
||||
keysToFetch: Self.payloadKeys)
|
||||
} else {
|
||||
persisted = contact
|
||||
}
|
||||
|
||||
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
return true
|
||||
case .notDetermined:
|
||||
return await withCheckedContinuation { cont in
|
||||
store.requestAccess(for: .contacts) { granted, _ in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
|
||||
(values ?? [])
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.map { lowercased ? $0.lowercased() : $0 }
|
||||
}
|
||||
|
||||
private static func findExistingContact(
|
||||
store: CNContactStore,
|
||||
phoneNumbers: [String],
|
||||
emails: [String]) throws -> CNContact?
|
||||
{
|
||||
if phoneNumbers.isEmpty && emails.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
var matches: [CNContact] = []
|
||||
|
||||
for phone in phoneNumbers {
|
||||
let predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: phone))
|
||||
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
|
||||
matches.append(contentsOf: contacts)
|
||||
}
|
||||
|
||||
for email in emails {
|
||||
let predicate = CNContact.predicateForContacts(matchingEmailAddress: email)
|
||||
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
|
||||
matches.append(contentsOf: contacts)
|
||||
}
|
||||
|
||||
return Self.matchContacts(contacts: matches, phoneNumbers: phoneNumbers, emails: emails)
|
||||
}
|
||||
|
||||
private static func matchContacts(
|
||||
contacts: [CNContact],
|
||||
phoneNumbers: [String],
|
||||
emails: [String]) -> CNContact?
|
||||
{
|
||||
let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty })
|
||||
let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
|
||||
var seen = Set<String>()
|
||||
|
||||
for contact in contacts {
|
||||
guard seen.insert(contact.identifier).inserted else { continue }
|
||||
let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) })
|
||||
let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })
|
||||
|
||||
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
|
||||
return contact
|
||||
}
|
||||
if !normalizedEmails.isEmpty, !contactEmails.isDisjoint(with: normalizedEmails) {
|
||||
return contact
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func normalizePhone(_ phone: String) -> String {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let digits = trimmed.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
|
||||
let normalized = String(String.UnicodeScalarView(digits))
|
||||
return normalized.isEmpty ? trimmed : normalized
|
||||
}
|
||||
|
||||
private static func payload(from contact: CNContact) -> OpenClawContactPayload {
|
||||
OpenClawContactPayload(
|
||||
identifier: contact.identifier,
|
||||
displayName: CNContactFormatter.string(from: contact, style: .fullName)
|
||||
?? "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
givenName: contact.givenName,
|
||||
familyName: contact.familyName,
|
||||
organizationName: contact.organizationName,
|
||||
phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue },
|
||||
emails: contact.emailAddresses.map { String($0.value) })
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
|
||||
matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
final class DeviceStatusService: DeviceStatusServicing {
|
||||
private let networkStatus: NetworkStatusService
|
||||
|
||||
init(networkStatus: NetworkStatusService = NetworkStatusService()) {
|
||||
self.networkStatus = networkStatus
|
||||
}
|
||||
|
||||
func status() async throws -> OpenClawDeviceStatusPayload {
|
||||
let battery = self.batteryStatus()
|
||||
let thermal = self.thermalStatus()
|
||||
let storage = self.storageStatus()
|
||||
let network = await self.networkStatus.currentStatus()
|
||||
let uptime = ProcessInfo.processInfo.systemUptime
|
||||
|
||||
return OpenClawDeviceStatusPayload(
|
||||
battery: battery,
|
||||
thermal: thermal,
|
||||
storage: storage,
|
||||
network: network,
|
||||
uptimeSeconds: uptime)
|
||||
}
|
||||
|
||||
func info() -> OpenClawDeviceInfoPayload {
|
||||
let device = UIDevice.current
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
|
||||
let locale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
return OpenClawDeviceInfoPayload(
|
||||
deviceName: device.name,
|
||||
modelIdentifier: Self.modelIdentifier(),
|
||||
systemName: device.systemName,
|
||||
systemVersion: device.systemVersion,
|
||||
appVersion: appVersion,
|
||||
appBuild: appBuild,
|
||||
locale: locale)
|
||||
}
|
||||
|
||||
private func batteryStatus() -> OpenClawBatteryStatusPayload {
|
||||
let device = UIDevice.current
|
||||
device.isBatteryMonitoringEnabled = true
|
||||
let level = device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil
|
||||
let state: OpenClawBatteryState = switch device.batteryState {
|
||||
case .charging: .charging
|
||||
case .full: .full
|
||||
case .unplugged: .unplugged
|
||||
case .unknown: .unknown
|
||||
@unknown default: .unknown
|
||||
}
|
||||
return OpenClawBatteryStatusPayload(
|
||||
level: level,
|
||||
state: state,
|
||||
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled)
|
||||
}
|
||||
|
||||
private func thermalStatus() -> OpenClawThermalStatusPayload {
|
||||
let state: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState {
|
||||
case .nominal: .nominal
|
||||
case .fair: .fair
|
||||
case .serious: .serious
|
||||
case .critical: .critical
|
||||
@unknown default: .nominal
|
||||
}
|
||||
return OpenClawThermalStatusPayload(state: state)
|
||||
}
|
||||
|
||||
private func storageStatus() -> OpenClawStorageStatusPayload {
|
||||
let attrs = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:]
|
||||
let total = (attrs[.systemSize] as? NSNumber)?.int64Value ?? 0
|
||||
let free = (attrs[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
|
||||
let used = max(0, total - free)
|
||||
return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used)
|
||||
}
|
||||
|
||||
private static func modelIdentifier() -> String {
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
||||
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
|
||||
}
|
||||
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "unknown" : trimmed
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OpenClawKit
|
||||
|
||||
final class NetworkStatusService: @unchecked Sendable {
|
||||
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
|
||||
await withCheckedContinuation { cont in
|
||||
let monitor = NWPathMonitor()
|
||||
let queue = DispatchQueue(label: "bot.molt.ios.network-status")
|
||||
let state = NetworkStatusState()
|
||||
|
||||
monitor.pathUpdateHandler = { path in
|
||||
guard state.markCompleted() else { return }
|
||||
monitor.cancel()
|
||||
cont.resume(returning: Self.payload(from: path))
|
||||
}
|
||||
|
||||
monitor.start(queue: queue)
|
||||
|
||||
queue.asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) {
|
||||
guard state.markCompleted() else { return }
|
||||
monitor.cancel()
|
||||
cont.resume(returning: Self.fallbackPayload())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func payload(from path: NWPath) -> OpenClawNetworkStatusPayload {
|
||||
let status: OpenClawNetworkPathStatus = switch path.status {
|
||||
case .satisfied: .satisfied
|
||||
case .requiresConnection: .requiresConnection
|
||||
case .unsatisfied: .unsatisfied
|
||||
@unknown default: .unsatisfied
|
||||
}
|
||||
|
||||
var interfaces: [OpenClawNetworkInterfaceType] = []
|
||||
if path.usesInterfaceType(.wifi) { interfaces.append(.wifi) }
|
||||
if path.usesInterfaceType(.cellular) { interfaces.append(.cellular) }
|
||||
if path.usesInterfaceType(.wiredEthernet) { interfaces.append(.wired) }
|
||||
if interfaces.isEmpty { interfaces.append(.other) }
|
||||
|
||||
return OpenClawNetworkStatusPayload(
|
||||
status: status,
|
||||
isExpensive: path.isExpensive,
|
||||
isConstrained: path.isConstrained,
|
||||
interfaces: interfaces)
|
||||
}
|
||||
|
||||
private static func fallbackPayload() -> OpenClawNetworkStatusPayload {
|
||||
OpenClawNetworkStatusPayload(
|
||||
status: .unsatisfied,
|
||||
isExpensive: false,
|
||||
isConstrained: false,
|
||||
interfaces: [.other])
|
||||
}
|
||||
}
|
||||
|
||||
private final class NetworkStatusState: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var completed = false
|
||||
|
||||
func markCompleted() -> Bool {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
if self.completed { return false }
|
||||
self.completed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum NodeDisplayName {
|
||||
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
|
||||
|
||||
static func isGeneric(_ name: String) -> Bool {
|
||||
Self.genericNames.contains(name)
|
||||
}
|
||||
|
||||
static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String {
|
||||
switch interfaceIdiom {
|
||||
case .phone:
|
||||
return "iPhone Node"
|
||||
case .pad:
|
||||
return "iPad Node"
|
||||
default:
|
||||
return "iOS Node"
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(
|
||||
existing: String?,
|
||||
deviceName: String,
|
||||
interfaceIdiom: UIUserInterfaceIdiom
|
||||
) -> String {
|
||||
let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) {
|
||||
return trimmedExisting
|
||||
}
|
||||
|
||||
let trimmedDevice = deviceName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let normalized = Self.normalizedDeviceName(trimmedDevice) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return Self.defaultValue(for: interfaceIdiom)
|
||||
}
|
||||
|
||||
private static func normalizedDeviceName(_ deviceName: String) -> String? {
|
||||
guard !deviceName.isEmpty else { return nil }
|
||||
let lower = deviceName.lowercased()
|
||||
if lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios") {
|
||||
return deviceName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import AVFoundation
|
||||
import Contacts
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import Photos
|
||||
import ReplayKit
|
||||
import Speech
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@@ -67,11 +60,6 @@ final class GatewayConnectionController {
|
||||
port: port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: tlsParams?.required == true,
|
||||
stableID: gateway.stableID)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
@@ -86,24 +74,13 @@ final class GatewayConnectionController {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host)
|
||||
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
||||
else { return }
|
||||
let stableID = self.manualStableID(host: host, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: host))
|
||||
let stableID = self.manualStableID(host: host, port: port)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: resolvedPort,
|
||||
port: port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: host,
|
||||
port: resolvedPort,
|
||||
useTLS: tlsParams?.required == true,
|
||||
stableID: stableID)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
@@ -113,38 +90,6 @@ final class GatewayConnectionController {
|
||||
password: password)
|
||||
}
|
||||
|
||||
func connectLastKnown() async {
|
||||
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
let resolvedUseTLS = last.useTLS || self.shouldForceTLS(host: last.host)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: last.stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: last.host))
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: last.host,
|
||||
port: last.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
if resolvedUseTLS != last.useTLS {
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: last.host,
|
||||
port: last.port,
|
||||
useTLS: resolvedUseTLS,
|
||||
stableID: last.stableID)
|
||||
}
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: last.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
private func updateFromDiscovery() {
|
||||
let newGateways = self.discovery.gateways
|
||||
self.gateways = newGateways
|
||||
@@ -189,19 +134,11 @@ final class GatewayConnectionController {
|
||||
guard !manualHost.isEmpty else { return }
|
||||
|
||||
let manualPort = defaults.integer(forKey: "gateway.manual.port")
|
||||
let resolvedPort = manualPort > 0 ? manualPort : 18789
|
||||
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
|
||||
let resolvedUseTLS = manualTLS || self.shouldForceTLS(host: manualHost)
|
||||
guard let resolvedPort = self.resolveManualPort(
|
||||
host: manualHost,
|
||||
port: manualPort,
|
||||
useTLS: resolvedUseTLS)
|
||||
else { return }
|
||||
|
||||
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: manualHost))
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
|
||||
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: manualHost,
|
||||
@@ -219,70 +156,30 @@ final class GatewayConnectionController {
|
||||
return
|
||||
}
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: lastKnown.stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: lastKnown.host,
|
||||
port: lastKnown.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: lastKnown.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
|
||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
||||
if let targetStableID = candidates.first(where: { id in
|
||||
guard let targetStableID = candidates.first(where: { id in
|
||||
self.gateways.contains(where: { $0.stableID == id })
|
||||
}) {
|
||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let host = self.resolveGatewayHost(target) else { return }
|
||||
let port = target.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
}) else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let host = self.resolveGatewayHost(target) else { return }
|
||||
let port = target.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
if self.gateways.count == 1, let gateway = self.gateways.first {
|
||||
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||
let port = gateway.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: gateway.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
@@ -308,10 +205,10 @@ final class GatewayConnectionController {
|
||||
password: String?)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
|
||||
let connectOptions = self.makeConnectOptions()
|
||||
|
||||
Task { [weak appModel] in
|
||||
guard let appModel else { return }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
}
|
||||
@@ -340,17 +237,13 @@ final class GatewayConnectionController {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(
|
||||
stableID: String,
|
||||
tlsEnabled: Bool,
|
||||
allowTOFUReset: Bool = false) -> GatewayTLSParams?
|
||||
{
|
||||
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if tlsEnabled || stored != nil {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: stored == nil || allowTOFUReset,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
@@ -358,12 +251,12 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
||||
return tailnet
|
||||
}
|
||||
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
||||
return lanHost
|
||||
}
|
||||
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
||||
return tailnet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -376,69 +269,38 @@ final class GatewayConnectionController {
|
||||
return components.url
|
||||
}
|
||||
|
||||
private func shouldForceTLS(host: String) -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if trimmed.isEmpty { return false }
|
||||
return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.")
|
||||
}
|
||||
|
||||
private func manualStableID(host: String, port: Int) -> String {
|
||||
"manual|\(host.lowercased())|\(port)"
|
||||
}
|
||||
|
||||
private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions {
|
||||
private func makeConnectOptions() -> GatewayConnectOptions {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
|
||||
|
||||
return GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands(),
|
||||
permissions: self.currentPermissions(),
|
||||
clientId: resolvedClientId,
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: displayName)
|
||||
}
|
||||
|
||||
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
|
||||
if let stableID,
|
||||
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) {
|
||||
return override
|
||||
}
|
||||
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if manualClientId?.isEmpty == false {
|
||||
return manualClientId!
|
||||
}
|
||||
return "openclaw-ios"
|
||||
}
|
||||
|
||||
private func resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? {
|
||||
if port > 0 {
|
||||
return port <= 65535 ? port : nil
|
||||
}
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedHost.isEmpty else { return nil }
|
||||
if useTLS && self.shouldForceTLS(host: trimmedHost) {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
}
|
||||
|
||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
let key = "node.displayName"
|
||||
let existingRaw = defaults.string(forKey: key)
|
||||
let resolved = NodeDisplayName.resolve(
|
||||
existing: existingRaw,
|
||||
deviceName: UIDevice.current.name,
|
||||
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
|
||||
let existing = existingRaw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if existing.isEmpty || NodeDisplayName.isGeneric(existing) {
|
||||
defaults.set(resolved, forKey: key)
|
||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !existing.isEmpty, existing != "iOS Node" { return existing }
|
||||
|
||||
let deviceName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let candidate = deviceName.isEmpty ? "iOS Node" : deviceName
|
||||
|
||||
if existing.isEmpty || existing == "iOS Node" {
|
||||
defaults.set(candidate, forKey: key)
|
||||
}
|
||||
return resolved
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
@@ -458,15 +320,6 @@ final class GatewayConnectionController {
|
||||
let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off
|
||||
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
|
||||
|
||||
caps.append(OpenClawCapability.device.rawValue)
|
||||
caps.append(OpenClawCapability.photos.rawValue)
|
||||
caps.append(OpenClawCapability.contacts.rawValue)
|
||||
caps.append(OpenClawCapability.calendar.rawValue)
|
||||
caps.append(OpenClawCapability.reminders.rawValue)
|
||||
if Self.motionAvailable() {
|
||||
caps.append(OpenClawCapability.motion.rawValue)
|
||||
}
|
||||
|
||||
return caps
|
||||
}
|
||||
|
||||
@@ -482,11 +335,10 @@ final class GatewayConnectionController {
|
||||
OpenClawCanvasA2UICommand.reset.rawValue,
|
||||
OpenClawScreenCommand.record.rawValue,
|
||||
OpenClawSystemCommand.notify.rawValue,
|
||||
OpenClawChatCommand.push.rawValue,
|
||||
OpenClawTalkCommand.pttStart.rawValue,
|
||||
OpenClawTalkCommand.pttStop.rawValue,
|
||||
OpenClawTalkCommand.pttCancel.rawValue,
|
||||
OpenClawTalkCommand.pttOnce.rawValue,
|
||||
OpenClawSystemCommand.which.rawValue,
|
||||
OpenClawSystemCommand.run.rawValue,
|
||||
OpenClawSystemCommand.execApprovalsGet.rawValue,
|
||||
OpenClawSystemCommand.execApprovalsSet.rawValue,
|
||||
]
|
||||
|
||||
let caps = Set(self.currentCaps())
|
||||
@@ -498,76 +350,10 @@ final class GatewayConnectionController {
|
||||
if caps.contains(OpenClawCapability.location.rawValue) {
|
||||
commands.append(OpenClawLocationCommand.get.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.device.rawValue) {
|
||||
commands.append(OpenClawDeviceCommand.status.rawValue)
|
||||
commands.append(OpenClawDeviceCommand.info.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.photos.rawValue) {
|
||||
commands.append(OpenClawPhotosCommand.latest.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.contacts.rawValue) {
|
||||
commands.append(OpenClawContactsCommand.search.rawValue)
|
||||
commands.append(OpenClawContactsCommand.add.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.calendar.rawValue) {
|
||||
commands.append(OpenClawCalendarCommand.events.rawValue)
|
||||
commands.append(OpenClawCalendarCommand.add.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.reminders.rawValue) {
|
||||
commands.append(OpenClawRemindersCommand.list.rawValue)
|
||||
commands.append(OpenClawRemindersCommand.add.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.motion.rawValue) {
|
||||
commands.append(OpenClawMotionCommand.activity.rawValue)
|
||||
commands.append(OpenClawMotionCommand.pedometer.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
private func currentPermissions() -> [String: Bool] {
|
||||
var permissions: [String: Bool] = [:]
|
||||
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
permissions["location"] = Self.isLocationAuthorized(
|
||||
status: CLLocationManager().authorizationStatus)
|
||||
&& CLLocationManager.locationServicesEnabled()
|
||||
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
|
||||
|
||||
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
permissions["photos"] = photoStatus == .authorized || photoStatus == .limited
|
||||
let contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
|
||||
permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited
|
||||
|
||||
let calendarStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
permissions["calendar"] =
|
||||
calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly
|
||||
let remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||
permissions["reminders"] =
|
||||
remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly
|
||||
|
||||
let motionStatus = CMMotionActivityManager.authorizationStatus()
|
||||
let pedometerStatus = CMPedometer.authorizationStatus()
|
||||
permissions["motion"] =
|
||||
motionStatus == .authorized || pedometerStatus == .authorized
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func motionAvailable() -> Bool {
|
||||
CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
|
||||
}
|
||||
|
||||
private func platformString() -> String {
|
||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let name = switch UIDevice.current.userInterfaceIdiom {
|
||||
@@ -621,10 +407,6 @@ extension GatewayConnectionController {
|
||||
self.currentCommands()
|
||||
}
|
||||
|
||||
func _test_currentPermissions() -> [String: Bool] {
|
||||
self.currentPermissions()
|
||||
}
|
||||
|
||||
func _test_platformString() -> String {
|
||||
self.platformString()
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class GatewayHealthMonitor {
|
||||
struct Config: Sendable {
|
||||
var intervalSeconds: Double
|
||||
var timeoutSeconds: Double
|
||||
var maxFailures: Int
|
||||
}
|
||||
|
||||
private let config: Config
|
||||
private let sleep: @Sendable (UInt64) async -> Void
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
|
||||
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
}
|
||||
) {
|
||||
self.config = config
|
||||
self.sleep = sleep
|
||||
}
|
||||
|
||||
func start(
|
||||
check: @escaping @Sendable () async throws -> Bool,
|
||||
onFailure: @escaping @Sendable (_ failureCount: Int) async -> Void)
|
||||
{
|
||||
self.stop()
|
||||
let config = self.config
|
||||
let sleep = self.sleep
|
||||
self.task = Task { @MainActor in
|
||||
var failures = 0
|
||||
while !Task.isCancelled {
|
||||
let ok = await Self.runCheck(check: check, timeoutSeconds: config.timeoutSeconds)
|
||||
if ok {
|
||||
failures = 0
|
||||
} else {
|
||||
failures += 1
|
||||
if failures >= max(1, config.maxFailures) {
|
||||
await onFailure(failures)
|
||||
failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
if Task.isCancelled { break }
|
||||
let interval = max(0.0, config.intervalSeconds)
|
||||
let nanos = UInt64(interval * 1_000_000_000)
|
||||
if nanos > 0 {
|
||||
await sleep(nanos)
|
||||
} else {
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
}
|
||||
|
||||
private static func runCheck(
|
||||
check: @escaping @Sendable () async throws -> Bool,
|
||||
timeoutSeconds: Double) async -> Bool
|
||||
{
|
||||
let timeout = max(0.0, timeoutSeconds)
|
||||
if timeout == 0 {
|
||||
return (try? await check()) ?? false
|
||||
}
|
||||
do {
|
||||
let timeoutError = NSError(
|
||||
domain: "GatewayHealthMonitor",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "health check timed out"])
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: timeout,
|
||||
onTimeout: { timeoutError },
|
||||
operation: check)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,7 @@ enum GatewaySettingsStore {
|
||||
private static let manualHostDefaultsKey = "gateway.manual.host"
|
||||
private static let manualPortDefaultsKey = "gateway.manual.port"
|
||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||
private static let manualPasswordDefaultsKey = "gateway.manual.password"
|
||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
|
||||
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
|
||||
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
|
||||
private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID"
|
||||
private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride."
|
||||
|
||||
private static let instanceIdAccount = "instanceId"
|
||||
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
||||
@@ -27,7 +21,6 @@ enum GatewaySettingsStore {
|
||||
self.ensureStableInstanceID()
|
||||
self.ensurePreferredGatewayStableID()
|
||||
self.ensureLastDiscoveredGatewayStableID()
|
||||
self.ensureManualGatewayPassword()
|
||||
}
|
||||
|
||||
static func loadStableInstanceID() -> String? {
|
||||
@@ -114,49 +107,6 @@ enum GatewaySettingsStore {
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
|
||||
let defaults = UserDefaults.standard
|
||||
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
|
||||
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
|
||||
return (host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||
}
|
||||
|
||||
static func loadGatewayClientIdOverride(stableID: String) -> String? {
|
||||
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedID.isEmpty else { return nil }
|
||||
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
|
||||
let value = UserDefaults.standard.string(forKey: key)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
|
||||
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedID.isEmpty else { return }
|
||||
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
|
||||
let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmedClientId.isEmpty {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
} else {
|
||||
UserDefaults.standard.set(trimmedClientId, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
private static func gatewayTokenAccount(instanceId: String) -> String {
|
||||
"gateway-token.\(instanceId)"
|
||||
}
|
||||
@@ -224,23 +174,4 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureManualGatewayPassword() {
|
||||
let defaults = UserDefaults.standard
|
||||
let instanceId = defaults.string(forKey: self.instanceIdDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !instanceId.isEmpty else { return }
|
||||
|
||||
let manualPassword = defaults.string(forKey: self.manualPasswordDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !manualPassword.isEmpty else { return }
|
||||
|
||||
if self.loadGatewayPassword(instanceId: instanceId) == nil {
|
||||
self.saveGatewayPassword(manualPassword, instanceId: instanceId)
|
||||
}
|
||||
|
||||
if self.loadGatewayPassword(instanceId: instanceId) == manualPassword {
|
||||
defaults.removeObject(forKey: self.manualPasswordDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.1</string>
|
||||
<string>2026.2.6</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260201</string>
|
||||
<string>20260202</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import Foundation
|
||||
import Photos
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
final class PhotoLibraryService: PhotosServicing {
|
||||
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload {
|
||||
let status = await Self.ensureAuthorization()
|
||||
guard status == .authorized || status == .limited else {
|
||||
throw NSError(domain: "Photos", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "PHOTOS_PERMISSION_REQUIRED: grant Photos permission",
|
||||
])
|
||||
}
|
||||
|
||||
let limit = max(1, min(params.limit ?? 1, 20))
|
||||
let fetchOptions = PHFetchOptions()
|
||||
fetchOptions.fetchLimit = limit
|
||||
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
let assets = PHAsset.fetchAssets(with: .image, options: fetchOptions)
|
||||
|
||||
var results: [OpenClawPhotoPayload] = []
|
||||
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||
let quality = params.quality.map { max(0.1, min(1.0, $0)) } ?? 0.85
|
||||
let formatter = ISO8601DateFormatter()
|
||||
|
||||
assets.enumerateObjects { asset, _, stop in
|
||||
if results.count >= limit { stop.pointee = true; return }
|
||||
if let payload = try? Self.renderAsset(
|
||||
asset,
|
||||
maxWidth: maxWidth,
|
||||
quality: quality,
|
||||
formatter: formatter)
|
||||
{
|
||||
results.append(payload)
|
||||
}
|
||||
}
|
||||
|
||||
return OpenClawPhotosLatestPayload(photos: results)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization() async -> PHAuthorizationStatus {
|
||||
let current = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
if current == .notDetermined {
|
||||
return await withCheckedContinuation { cont in
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
|
||||
cont.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private static func renderAsset(
|
||||
_ asset: PHAsset,
|
||||
maxWidth: Int,
|
||||
quality: Double,
|
||||
formatter: ISO8601DateFormatter) throws -> OpenClawPhotoPayload
|
||||
{
|
||||
let manager = PHImageManager.default()
|
||||
let options = PHImageRequestOptions()
|
||||
options.isSynchronous = true
|
||||
options.isNetworkAccessAllowed = true
|
||||
options.deliveryMode = .highQualityFormat
|
||||
|
||||
let targetSize: CGSize = {
|
||||
guard maxWidth > 0 else { return PHImageManagerMaximumSize }
|
||||
let aspect = CGFloat(asset.pixelHeight) / CGFloat(max(1, asset.pixelWidth))
|
||||
let width = CGFloat(maxWidth)
|
||||
return CGSize(width: width, height: width * aspect)
|
||||
}()
|
||||
|
||||
var image: UIImage?
|
||||
manager.requestImage(
|
||||
for: asset,
|
||||
targetSize: targetSize,
|
||||
contentMode: .aspectFit,
|
||||
options: options)
|
||||
{ result, _ in
|
||||
image = result
|
||||
}
|
||||
|
||||
guard let image else {
|
||||
throw NSError(domain: "Photos", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "photo load failed",
|
||||
])
|
||||
}
|
||||
|
||||
let jpeg = image.jpegData(compressionQuality: quality)
|
||||
guard let data = jpeg else {
|
||||
throw NSError(domain: "Photos", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "photo encode failed",
|
||||
])
|
||||
}
|
||||
|
||||
let created = asset.creationDate.map { formatter.string(from: $0) }
|
||||
return OpenClawPhotoPayload(
|
||||
format: "jpeg",
|
||||
base64: data.base64EncodedString(),
|
||||
width: Int(image.size.width),
|
||||
height: Int(image.size.height),
|
||||
createdAt: created)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,8 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import Network
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
// Wrap errors without pulling non-Sendable types into async notification paths.
|
||||
private struct NotificationCallError: Error, Sendable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
||||
private var resumed = false
|
||||
|
||||
func setContinuation(_ continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>) {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
self.continuation = continuation
|
||||
}
|
||||
|
||||
func resume(_ response: Result<T, NotificationCallError>) {
|
||||
let cont: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
||||
self.lock.lock()
|
||||
if self.resumed {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.resumed = true
|
||||
cont = self.continuation
|
||||
self.continuation = nil
|
||||
self.lock.unlock()
|
||||
cont?.resume(returning: response)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
@@ -48,9 +15,9 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
var isBackgrounded: Bool = false
|
||||
let screen: ScreenController
|
||||
private let camera: any CameraServicing
|
||||
private let screenRecorder: any ScreenRecordingServicing
|
||||
let screen = ScreenController()
|
||||
let camera = CameraController()
|
||||
private let screenRecorder = ScreenRecordService()
|
||||
var gatewayStatusText: String = "Offline"
|
||||
var gatewayServerName: String?
|
||||
var gatewayRemoteAddress: String?
|
||||
@@ -62,20 +29,10 @@ final class NodeAppModel {
|
||||
private var gatewayTask: Task<Void, Never>?
|
||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||
@ObservationIgnored private lazy var capabilityRouter: NodeCapabilityRouter = self.buildCapabilityRouter()
|
||||
private let gatewayHealthMonitor = GatewayHealthMonitor()
|
||||
private let notificationCenter: NotificationCentering
|
||||
let voiceWake = VoiceWakeManager()
|
||||
let talkMode: TalkModeManager
|
||||
private let locationService: any LocationServicing
|
||||
private let deviceStatusService: any DeviceStatusServicing
|
||||
private let photosService: any PhotosServicing
|
||||
private let contactsService: any ContactsServicing
|
||||
private let calendarService: any CalendarServicing
|
||||
private let remindersService: any RemindersServicing
|
||||
private let motionService: any MotionServicing
|
||||
let talkMode = TalkModeManager()
|
||||
private let locationService = LocationService()
|
||||
private var lastAutoA2uiURL: String?
|
||||
private var pttVoiceWakeSuspended = false
|
||||
|
||||
private var gatewayConnected = false
|
||||
var gatewaySession: GatewayNodeSession { self.gateway }
|
||||
@@ -85,33 +42,7 @@ final class NodeAppModel {
|
||||
var cameraFlashNonce: Int = 0
|
||||
var screenRecordActive: Bool = false
|
||||
|
||||
init(
|
||||
screen: ScreenController = ScreenController(),
|
||||
camera: any CameraServicing = CameraController(),
|
||||
screenRecorder: any ScreenRecordingServicing = ScreenRecordService(),
|
||||
locationService: any LocationServicing = LocationService(),
|
||||
notificationCenter: NotificationCentering = LiveNotificationCenter(),
|
||||
deviceStatusService: any DeviceStatusServicing = DeviceStatusService(),
|
||||
photosService: any PhotosServicing = PhotoLibraryService(),
|
||||
contactsService: any ContactsServicing = ContactsService(),
|
||||
calendarService: any CalendarServicing = CalendarService(),
|
||||
remindersService: any RemindersServicing = RemindersService(),
|
||||
motionService: any MotionServicing = MotionService(),
|
||||
talkMode: TalkModeManager = TalkModeManager())
|
||||
{
|
||||
self.screen = screen
|
||||
self.camera = camera
|
||||
self.screenRecorder = screenRecorder
|
||||
self.locationService = locationService
|
||||
self.notificationCenter = notificationCenter
|
||||
self.deviceStatusService = deviceStatusService
|
||||
self.photosService = photosService
|
||||
self.contactsService = contactsService
|
||||
self.calendarService = calendarService
|
||||
self.remindersService = remindersService
|
||||
self.motionService = motionService
|
||||
self.talkMode = talkMode
|
||||
|
||||
init() {
|
||||
self.voiceWake.configure { [weak self] cmd in
|
||||
guard let self else { return }
|
||||
let sessionKey = await MainActor.run { self.mainSessionKey }
|
||||
@@ -176,10 +107,7 @@ final class NodeAppModel {
|
||||
return raw.isEmpty ? "-" : raw
|
||||
}()
|
||||
|
||||
let host = NodeDisplayName.resolve(
|
||||
existing: UserDefaults.standard.string(forKey: "node.displayName"),
|
||||
deviceName: UIDevice.current.name,
|
||||
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
|
||||
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
||||
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
||||
let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
let sessionKey = self.mainSessionKey
|
||||
@@ -247,12 +175,8 @@ final class NodeAppModel {
|
||||
switch phase {
|
||||
case .background:
|
||||
self.isBackgrounded = true
|
||||
self.stopGatewayHealthMonitor()
|
||||
case .active, .inactive:
|
||||
self.isBackgrounded = false
|
||||
if self.gatewayConnected {
|
||||
self.startGatewayHealthMonitor()
|
||||
}
|
||||
@unknown default:
|
||||
self.isBackgrounded = false
|
||||
}
|
||||
@@ -288,7 +212,6 @@ final class NodeAppModel {
|
||||
connectOptions: GatewayConnectOptions)
|
||||
{
|
||||
self.gatewayTask?.cancel()
|
||||
self.gatewayHealthMonitor.stop()
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -300,9 +223,6 @@ final class NodeAppModel {
|
||||
|
||||
self.gatewayTask = Task {
|
||||
var attempt = 0
|
||||
var currentOptions = connectOptions
|
||||
var didFallbackClientId = false
|
||||
let trimmedStableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while !Task.isCancelled {
|
||||
await MainActor.run {
|
||||
if attempt == 0 {
|
||||
@@ -319,7 +239,7 @@ final class NodeAppModel {
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
connectOptions: currentOptions,
|
||||
connectOptions: connectOptions,
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -327,7 +247,6 @@ final class NodeAppModel {
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
self.gatewayConnected = true
|
||||
self.talkMode.updateGatewayConnected(true)
|
||||
}
|
||||
if let addr = await self.gateway.currentRemoteAddress() {
|
||||
await MainActor.run {
|
||||
@@ -336,7 +255,6 @@ final class NodeAppModel {
|
||||
}
|
||||
await self.refreshBrandingFromGateway()
|
||||
await self.startVoiceWakeSync()
|
||||
await MainActor.run { self.startGatewayHealthMonitor() }
|
||||
await self.showA2UIOnConnectIfNeeded()
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
@@ -345,11 +263,9 @@ final class NodeAppModel {
|
||||
self.gatewayStatusText = "Disconnected"
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
self.gatewayStatusText = "Disconnected: \(reason)"
|
||||
}
|
||||
await MainActor.run { self.stopGatewayHealthMonitor() }
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
guard let self else {
|
||||
@@ -368,30 +284,12 @@ final class NodeAppModel {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
if Task.isCancelled { break }
|
||||
if !didFallbackClientId,
|
||||
let fallbackClientId = self.legacyClientIdFallback(
|
||||
currentClientId: currentOptions.clientId,
|
||||
error: error)
|
||||
{
|
||||
didFallbackClientId = true
|
||||
currentOptions.clientId = fallbackClientId
|
||||
if !trimmedStableID.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayClientIdOverride(
|
||||
stableID: trimmedStableID,
|
||||
clientId: fallbackClientId)
|
||||
}
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Gateway rejected client id. Retrying…"
|
||||
}
|
||||
continue
|
||||
}
|
||||
attempt += 1
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
@@ -405,7 +303,6 @@ final class NodeAppModel {
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.connectedGatewayID = nil
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
@@ -416,29 +313,17 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||
let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard normalizedClientId == "openclaw-ios" else { return nil }
|
||||
let message = error.localizedDescription.lowercased()
|
||||
guard message.contains("invalid connect params"), message.contains("/client/id") else {
|
||||
return nil
|
||||
}
|
||||
return "moltbot-ios"
|
||||
}
|
||||
|
||||
func disconnectGateway() {
|
||||
self.gatewayTask?.cancel()
|
||||
self.gatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
self.gatewayHealthMonitor.stop()
|
||||
Task { await self.gateway.disconnect() }
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.connectedGatewayID = nil
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
@@ -533,30 +418,6 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func startGatewayHealthMonitor() {
|
||||
self.gatewayHealthMonitor.start(
|
||||
check: { [weak self] in
|
||||
guard let self else { return false }
|
||||
do {
|
||||
let data = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: 6)
|
||||
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
|
||||
return false
|
||||
}
|
||||
return decoded.ok ?? false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
onFailure: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
await self.gateway.disconnect()
|
||||
})
|
||||
}
|
||||
|
||||
private func stopGatewayHealthMonitor() {
|
||||
self.gatewayHealthMonitor.stop()
|
||||
}
|
||||
|
||||
private func refreshWakeWordsFromGateway() async {
|
||||
do {
|
||||
let data = try await self.gateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
@@ -662,19 +523,30 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
do {
|
||||
return try await self.capabilityRouter.handle(req)
|
||||
} catch let error as NodeCapabilityRouter.RouterError {
|
||||
switch error {
|
||||
case .unknownCommand:
|
||||
switch command {
|
||||
case OpenClawLocationCommand.get.rawValue:
|
||||
return try await self.handleLocationInvoke(req)
|
||||
case OpenClawCanvasCommand.present.rawValue,
|
||||
OpenClawCanvasCommand.hide.rawValue,
|
||||
OpenClawCanvasCommand.navigate.rawValue,
|
||||
OpenClawCanvasCommand.evalJS.rawValue,
|
||||
OpenClawCanvasCommand.snapshot.rawValue:
|
||||
return try await self.handleCanvasInvoke(req)
|
||||
case OpenClawCanvasA2UICommand.reset.rawValue,
|
||||
OpenClawCanvasA2UICommand.push.rawValue,
|
||||
OpenClawCanvasA2UICommand.pushJSONL.rawValue:
|
||||
return try await self.handleCanvasA2UIInvoke(req)
|
||||
case OpenClawCameraCommand.list.rawValue,
|
||||
OpenClawCameraCommand.snap.rawValue,
|
||||
OpenClawCameraCommand.clip.rawValue:
|
||||
return try await self.handleCameraInvoke(req)
|
||||
case OpenClawScreenCommand.record.rawValue:
|
||||
return try await self.handleScreenRecordInvoke(req)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
case .handlerUnavailable:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .unavailable, message: "node handler unavailable"))
|
||||
}
|
||||
} catch {
|
||||
if command.hasPrefix("camera.") {
|
||||
@@ -689,8 +561,7 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
private func isBackgroundRestricted(_ command: String) -> Bool {
|
||||
command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") ||
|
||||
command.hasPrefix("talk.")
|
||||
command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.")
|
||||
}
|
||||
|
||||
private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
@@ -755,7 +626,6 @@ final class NodeAppModel {
|
||||
private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawCanvasCommand.present.rawValue:
|
||||
// iOS ignores placement hints; canvas always fills the screen.
|
||||
let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawCanvasPresentParams()
|
||||
let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -766,7 +636,6 @@ final class NodeAppModel {
|
||||
}
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
case OpenClawCanvasCommand.hide.rawValue:
|
||||
self.screen.showDefaultCanvas()
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
case OpenClawCanvasCommand.navigate.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON)
|
||||
@@ -990,427 +859,9 @@ final class NodeAppModel {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON)
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title.isEmpty, body.isEmpty {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification"))
|
||||
}
|
||||
|
||||
let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
|
||||
guard finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
|
||||
}
|
||||
|
||||
let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
if #available(iOS 15.0, *) {
|
||||
switch params.priority ?? .active {
|
||||
case .passive:
|
||||
content.interruptionLevel = .passive
|
||||
case .timeSensitive:
|
||||
content.interruptionLevel = .timeSensitive
|
||||
case .active:
|
||||
content.interruptionLevel = .active
|
||||
}
|
||||
}
|
||||
let soundValue = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if let soundValue, ["none", "silent", "off", "false", "0"].contains(soundValue) {
|
||||
content.sound = nil
|
||||
} else {
|
||||
content.sound = .default
|
||||
}
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString,
|
||||
content: content,
|
||||
trigger: nil)
|
||||
try await notificationCenter.add(request)
|
||||
}
|
||||
if case let .failure(error) = addResult {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)"))
|
||||
}
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
}
|
||||
|
||||
private func handleChatPushInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(OpenClawChatPushParams.self, from: req.paramsJSON)
|
||||
let text = params.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty chat.push text"))
|
||||
}
|
||||
|
||||
let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
|
||||
let messageId = UUID().uuidString
|
||||
if finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral {
|
||||
let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "OpenClaw"
|
||||
content.body = text
|
||||
content.sound = .default
|
||||
content.userInfo = ["messageId": messageId]
|
||||
let request = UNNotificationRequest(
|
||||
identifier: messageId,
|
||||
content: content,
|
||||
trigger: nil)
|
||||
try await notificationCenter.add(request)
|
||||
}
|
||||
if case let .failure(error) = addResult {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)"))
|
||||
}
|
||||
}
|
||||
|
||||
if params.speak ?? true {
|
||||
let toSpeak = text
|
||||
Task { @MainActor in
|
||||
try? await TalkSystemSpeechSynthesizer.shared.speak(text: toSpeak)
|
||||
}
|
||||
}
|
||||
|
||||
let payload = OpenClawChatPushPayload(messageId: messageId)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
}
|
||||
|
||||
private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus {
|
||||
let status = await self.notificationAuthorizationStatus()
|
||||
guard status == .notDetermined else { return status }
|
||||
|
||||
// Avoid hanging invoke requests if the permission prompt is never answered.
|
||||
_ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
|
||||
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
}
|
||||
|
||||
return await self.notificationAuthorizationStatus()
|
||||
}
|
||||
|
||||
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in
|
||||
await notificationCenter.authorizationStatus()
|
||||
}
|
||||
switch result {
|
||||
case let .success(status):
|
||||
return status
|
||||
case .failure:
|
||||
return .denied
|
||||
}
|
||||
}
|
||||
|
||||
private func runNotificationCall<T: Sendable>(
|
||||
timeoutSeconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
) async -> Result<T, NotificationCallError> {
|
||||
let latch = NotificationInvokeLatch<T>()
|
||||
var opTask: Task<Void, Never>?
|
||||
var timeoutTask: Task<Void, Never>?
|
||||
defer {
|
||||
opTask?.cancel()
|
||||
timeoutTask?.cancel()
|
||||
}
|
||||
let clamped = max(0.0, timeoutSeconds)
|
||||
return await withCheckedContinuation { (cont: CheckedContinuation<Result<T, NotificationCallError>, Never>) in
|
||||
latch.setContinuation(cont)
|
||||
opTask = Task { @MainActor in
|
||||
do {
|
||||
let value = try await operation()
|
||||
latch.resume(.success(value))
|
||||
} catch {
|
||||
latch.resume(.failure(NotificationCallError(message: error.localizedDescription)))
|
||||
}
|
||||
}
|
||||
timeoutTask = Task.detached {
|
||||
if clamped > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000))
|
||||
}
|
||||
latch.resume(.failure(NotificationCallError(message: "notification request timed out")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawDeviceCommand.status.rawValue:
|
||||
let payload = try await self.deviceStatusService.status()
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
case OpenClawDeviceCommand.info.rawValue:
|
||||
let payload = self.deviceStatusService.info()
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePhotosInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = (try? Self.decodeParams(OpenClawPhotosLatestParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawPhotosLatestParams()
|
||||
let payload = try await self.photosService.latest(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
}
|
||||
|
||||
private func handleContactsInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawContactsCommand.search.rawValue:
|
||||
let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawContactsSearchParams()
|
||||
let payload = try await self.contactsService.search(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
case OpenClawContactsCommand.add.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON)
|
||||
let payload = try await self.contactsService.add(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCalendarInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawCalendarCommand.events.rawValue:
|
||||
let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawCalendarEventsParams()
|
||||
let payload = try await self.calendarService.events(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
case OpenClawCalendarCommand.add.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON)
|
||||
let payload = try await self.calendarService.add(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRemindersInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawRemindersCommand.list.rawValue:
|
||||
let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawRemindersListParams()
|
||||
let payload = try await self.remindersService.list(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
case OpenClawRemindersCommand.add.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON)
|
||||
let payload = try await self.remindersService.add(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMotionInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawMotionCommand.activity.rawValue:
|
||||
let params = (try? Self.decodeParams(OpenClawMotionActivityParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawMotionActivityParams()
|
||||
let payload = try await self.motionService.activities(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
case OpenClawMotionCommand.pedometer.rawValue:
|
||||
let params = (try? Self.decodeParams(OpenClawPedometerParams.self, from: req.paramsJSON)) ??
|
||||
OpenClawPedometerParams()
|
||||
let payload = try await self.motionService.pedometer(params: params)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawTalkCommand.pttStart.rawValue:
|
||||
self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
|
||||
let payload = try await self.talkMode.beginPushToTalk()
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
case OpenClawTalkCommand.pttStop.rawValue:
|
||||
let payload = await self.talkMode.endPushToTalk()
|
||||
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended)
|
||||
self.pttVoiceWakeSuspended = false
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
case OpenClawTalkCommand.pttCancel.rawValue:
|
||||
let payload = await self.talkMode.cancelPushToTalk()
|
||||
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended)
|
||||
self.pttVoiceWakeSuspended = false
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
case OpenClawTalkCommand.pttOnce.rawValue:
|
||||
self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
|
||||
defer {
|
||||
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended)
|
||||
self.pttVoiceWakeSuspended = false
|
||||
}
|
||||
let payload = try await self.talkMode.runPushToTalkOnce()
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension NodeAppModel {
|
||||
// Central registry for node invoke routing to keep commands in one place.
|
||||
func buildCapabilityRouter() -> NodeCapabilityRouter {
|
||||
var handlers: [String: NodeCapabilityRouter.Handler] = [:]
|
||||
|
||||
func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
|
||||
for command in commands {
|
||||
handlers[command] = handler
|
||||
}
|
||||
}
|
||||
|
||||
register([OpenClawLocationCommand.get.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleLocationInvoke(req)
|
||||
}
|
||||
|
||||
register([
|
||||
OpenClawCanvasCommand.present.rawValue,
|
||||
OpenClawCanvasCommand.hide.rawValue,
|
||||
OpenClawCanvasCommand.navigate.rawValue,
|
||||
OpenClawCanvasCommand.evalJS.rawValue,
|
||||
OpenClawCanvasCommand.snapshot.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleCanvasInvoke(req)
|
||||
}
|
||||
|
||||
register([
|
||||
OpenClawCanvasA2UICommand.reset.rawValue,
|
||||
OpenClawCanvasA2UICommand.push.rawValue,
|
||||
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleCanvasA2UIInvoke(req)
|
||||
}
|
||||
|
||||
register([
|
||||
OpenClawCameraCommand.list.rawValue,
|
||||
OpenClawCameraCommand.snap.rawValue,
|
||||
OpenClawCameraCommand.clip.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleCameraInvoke(req)
|
||||
}
|
||||
|
||||
register([OpenClawScreenCommand.record.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleScreenRecordInvoke(req)
|
||||
}
|
||||
|
||||
register([OpenClawSystemCommand.notify.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleSystemNotify(req)
|
||||
}
|
||||
|
||||
register([OpenClawChatCommand.push.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleChatPushInvoke(req)
|
||||
}
|
||||
|
||||
register([
|
||||
OpenClawDeviceCommand.status.rawValue,
|
||||
OpenClawDeviceCommand.info.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleDeviceInvoke(req)
|
||||
}
|
||||
|
||||
register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handlePhotosInvoke(req)
|
||||
}
|
||||
|
||||
register([
|
||||
OpenClawContactsCommand.search.rawValue,
|
||||
OpenClawContactsCommand.add.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleContactsInvoke(req)
|
||||
}
|
||||
|
||||
register([
|
||||
OpenClawCalendarCommand.events.rawValue,
|
||||
OpenClawCalendarCommand.add.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleCalendarInvoke(req)
|
||||
}
|
||||
|
||||
register([
|
||||
OpenClawRemindersCommand.list.rawValue,
|
||||
OpenClawRemindersCommand.add.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleRemindersInvoke(req)
|
||||
}
|
||||
|
||||
register([
|
||||
OpenClawMotionCommand.activity.rawValue,
|
||||
OpenClawMotionCommand.pedometer.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleMotionInvoke(req)
|
||||
}
|
||||
|
||||
register([
|
||||
OpenClawTalkCommand.pttStart.rawValue,
|
||||
OpenClawTalkCommand.pttStop.rawValue,
|
||||
OpenClawTalkCommand.pttCancel.rawValue,
|
||||
OpenClawTalkCommand.pttOnce.rawValue,
|
||||
]) { [weak self] req in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
return try await self.handleTalkInvoke(req)
|
||||
}
|
||||
|
||||
return NodeCapabilityRouter(handlers: handlers)
|
||||
}
|
||||
|
||||
func locationMode() -> OpenClawLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
||||
return OpenClawLocationMode(rawValue: raw) ?? .off
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import CoreMotion
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
final class MotionService: MotionServicing {
|
||||
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload {
|
||||
guard CMMotionActivityManager.isActivityAvailable() else {
|
||||
throw NSError(domain: "Motion", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "MOTION_UNAVAILABLE: activity not supported on this device",
|
||||
])
|
||||
}
|
||||
|
||||
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||
let limit = max(1, min(params.limit ?? 200, 1000))
|
||||
|
||||
let manager = CMMotionActivityManager()
|
||||
let mapped = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawMotionActivityEntry], Error>) in
|
||||
manager.queryActivityStarting(from: start, to: end, to: OperationQueue()) { activity, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let sliced = Array((activity ?? []).suffix(limit))
|
||||
let entries = sliced.map { entry in
|
||||
OpenClawMotionActivityEntry(
|
||||
startISO: formatter.string(from: entry.startDate),
|
||||
endISO: formatter.string(from: end),
|
||||
confidence: Self.confidenceString(entry.confidence),
|
||||
isWalking: entry.walking,
|
||||
isRunning: entry.running,
|
||||
isCycling: entry.cycling,
|
||||
isAutomotive: entry.automotive,
|
||||
isStationary: entry.stationary,
|
||||
isUnknown: entry.unknown)
|
||||
}
|
||||
cont.resume(returning: entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpenClawMotionActivityPayload(activities: mapped)
|
||||
}
|
||||
|
||||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload {
|
||||
guard CMPedometer.isStepCountingAvailable() else {
|
||||
throw NSError(domain: "Motion", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "PEDOMETER_UNAVAILABLE: step counting not supported",
|
||||
])
|
||||
}
|
||||
|
||||
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||
let pedometer = CMPedometer()
|
||||
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<OpenClawPedometerPayload, Error>) in
|
||||
pedometer.queryPedometerData(from: start, to: end) { data, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let payload = OpenClawPedometerPayload(
|
||||
startISO: formatter.string(from: start),
|
||||
endISO: formatter.string(from: end),
|
||||
steps: data?.numberOfSteps.intValue,
|
||||
distanceMeters: data?.distance?.doubleValue,
|
||||
floorsAscended: data?.floorsAscended?.intValue,
|
||||
floorsDescended: data?.floorsDescended?.intValue)
|
||||
cont.resume(returning: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let start = startISO.flatMap { formatter.date(from: $0) } ?? Calendar.current.startOfDay(for: Date())
|
||||
let end = endISO.flatMap { formatter.date(from: $0) } ?? Date()
|
||||
return (start, end)
|
||||
}
|
||||
|
||||
private static func confidenceString(_ confidence: CMMotionActivityConfidence) -> String {
|
||||
switch confidence {
|
||||
case .low: "low"
|
||||
case .medium: "medium"
|
||||
case .high: "high"
|
||||
@unknown default: "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct GatewayOnboardingView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
|
||||
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
||||
@State private var connectStatusText: String?
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var showManualEntry: Bool = false
|
||||
@State private var manualGatewayPortText: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text("Connect to your gateway to get started.")
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
}
|
||||
|
||||
Section("Gateways") {
|
||||
self.gatewayList()
|
||||
}
|
||||
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: self.$showManualEntry) {
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port (optional)", text: self.manualPortBinding)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting...")
|
||||
}
|
||||
} else {
|
||||
Text("Connect manual gateway")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty || !self.manualPortIsValid)
|
||||
|
||||
Button("Paste gateway URL") {
|
||||
self.pasteGatewayURL()
|
||||
}
|
||||
|
||||
Text(
|
||||
"Use this when discovery is blocked. "
|
||||
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} label: {
|
||||
Text("Manual gateway")
|
||||
}
|
||||
}
|
||||
|
||||
if let text = self.connectStatusText {
|
||||
Section {
|
||||
Text(text)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect Gateway")
|
||||
.onAppear {
|
||||
self.syncManualPortText()
|
||||
}
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||
self.connectStatusText = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func gatewayList() -> some View {
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("No gateways found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Make sure you are on the same Wi-Fi as your gateway, or your tailnet DNS is set.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
Button {
|
||||
Task { await self.connectLastKnown() }
|
||||
} label: {
|
||||
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(self.appModel.seamColor)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(self.gatewayController.gateways) { gateway in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(gateway.name)
|
||||
let detailLines = self.gatewayDetailLines(gateway)
|
||||
ForEach(detailLines, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task { await self.connect(gateway) }
|
||||
} label: {
|
||||
if self.connectingGatewayID == gateway.id {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
self.connectingGatewayID = gateway.id
|
||||
self.manualGatewayEnabled = false
|
||||
self.preferredGatewayStableID = gateway.stableID
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
||||
self.lastDiscoveredGatewayStableID = gateway.stableID
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connect(gateway)
|
||||
}
|
||||
|
||||
private func connectLastKnown() async {
|
||||
self.connectingGatewayID = "last-known"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
|
||||
private var manualPortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
set: { newValue in
|
||||
let filtered = newValue.filter(\.isNumber)
|
||||
if self.manualGatewayPortText != filtered {
|
||||
self.manualGatewayPortText = filtered
|
||||
}
|
||||
if filtered.isEmpty {
|
||||
if self.manualGatewayPort != 0 {
|
||||
self.manualGatewayPort = 0
|
||||
}
|
||||
} else if let port = Int(filtered), self.manualGatewayPort != port {
|
||||
self.manualGatewayPort = port
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var manualPortIsValid: Bool {
|
||||
if self.manualGatewayPortText.isEmpty { return true }
|
||||
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||
}
|
||||
|
||||
private func syncManualPortText() {
|
||||
if self.manualGatewayPort > 0 {
|
||||
let next = String(self.manualGatewayPort)
|
||||
if self.manualGatewayPortText != next {
|
||||
self.manualGatewayPortText = next
|
||||
}
|
||||
} else if !self.manualGatewayPortText.isEmpty {
|
||||
self.manualGatewayPortText = ""
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lastKnownButtonLabel(host: String, port: Int) -> some View {
|
||||
if self.connectingGatewayID == "last-known" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting...")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "bolt.horizontal.circle.fill")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Connect last known")
|
||||
Text("\(host):\(port)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.connectStatusText = "Failed: host required"
|
||||
return
|
||||
}
|
||||
guard self.manualPortIsValid else {
|
||||
self.connectStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
|
||||
self.connectingGatewayID = "manual"
|
||||
self.manualGatewayEnabled = true
|
||||
defer { self.connectingGatewayID = nil }
|
||||
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualGatewayPort,
|
||||
useTLS: self.manualGatewayTLS)
|
||||
}
|
||||
|
||||
private func pasteGatewayURL() {
|
||||
guard let text = UIPasteboard.general.string else {
|
||||
self.connectStatusText = "Clipboard is empty."
|
||||
return
|
||||
}
|
||||
if self.applyGatewayInput(text) {
|
||||
self.connectStatusText = nil
|
||||
self.showManualEntry = true
|
||||
} else {
|
||||
self.connectStatusText = "Could not parse gateway URL."
|
||||
}
|
||||
}
|
||||
|
||||
private func applyGatewayInput(_ text: String) -> Bool {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
|
||||
if let components = URLComponents(string: trimmed),
|
||||
let host = components.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!host.isEmpty
|
||||
{
|
||||
let scheme = components.scheme?.lowercased()
|
||||
let defaultPort: Int = {
|
||||
let hostLower = host.lowercased()
|
||||
if (scheme == "wss" || scheme == "https"), hostLower.hasSuffix(".ts.net") {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
}()
|
||||
let port = components.port ?? defaultPort
|
||||
if scheme == "wss" || scheme == "https" {
|
||||
self.manualGatewayTLS = true
|
||||
} else if scheme == "ws" || scheme == "http" {
|
||||
self.manualGatewayTLS = false
|
||||
}
|
||||
self.manualGatewayHost = host
|
||||
self.manualGatewayPort = port
|
||||
self.manualGatewayPortText = String(port)
|
||||
return true
|
||||
}
|
||||
|
||||
if let hostPort = SettingsNetworkingHelpers.parseHostPort(from: trimmed) {
|
||||
self.manualGatewayHost = hostPort.host
|
||||
self.manualGatewayPort = hostPort.port
|
||||
self.manualGatewayPortText = String(hostPort.port)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||
|
||||
let gatewayPort = gateway.gatewayPort
|
||||
let canvasPort = gateway.canvasPort
|
||||
if gatewayPort != nil || canvasPort != nil {
|
||||
let gw = gatewayPort.map(String.init) ?? "-"
|
||||
let canvas = canvasPort.map(String.init) ?? "-"
|
||||
lines.append("Ports: gateway \(gw) / canvas \(canvas)")
|
||||
}
|
||||
|
||||
if lines.isEmpty {
|
||||
lines.append(gateway.debugID)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ struct OpenClawApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
RootCanvas()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
final class RemindersService: RemindersServicing {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
])
|
||||
}
|
||||
|
||||
let limit = max(1, min(params.limit ?? 50, 500))
|
||||
let statusFilter = params.status ?? .incomplete
|
||||
|
||||
let predicate = store.predicateForReminders(in: nil)
|
||||
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawReminderPayload], Error>) in
|
||||
store.fetchReminders(matching: predicate) { items in
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let filtered = (items ?? []).filter { reminder in
|
||||
switch statusFilter {
|
||||
case .all:
|
||||
return true
|
||||
case .completed:
|
||||
return reminder.isCompleted
|
||||
case .incomplete:
|
||||
return !reminder.isCompleted
|
||||
}
|
||||
}
|
||||
let selected = Array(filtered.prefix(limit))
|
||||
let payload = selected.map { reminder in
|
||||
let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) }
|
||||
return OpenClawReminderPayload(
|
||||
identifier: reminder.calendarItemIdentifier,
|
||||
title: reminder.title,
|
||||
dueISO: due.map { formatter.string(from: $0) },
|
||||
completed: reminder.isCompleted,
|
||||
listName: reminder.calendar.title)
|
||||
}
|
||||
cont.resume(returning: payload)
|
||||
}
|
||||
}
|
||||
|
||||
return OpenClawRemindersListPayload(reminders: payload)
|
||||
}
|
||||
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
])
|
||||
}
|
||||
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
throw NSError(domain: "Reminders", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_INVALID: title required",
|
||||
])
|
||||
}
|
||||
|
||||
let reminder = EKReminder(eventStore: store)
|
||||
reminder.title = title
|
||||
if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
|
||||
reminder.notes = notes
|
||||
}
|
||||
reminder.calendar = try Self.resolveList(
|
||||
store: store,
|
||||
listId: params.listId,
|
||||
listName: params.listName)
|
||||
|
||||
if let dueISO = params.dueISO?.trimmingCharacters(in: .whitespacesAndNewlines), !dueISO.isEmpty {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
guard let dueDate = formatter.date(from: dueISO) else {
|
||||
throw NSError(domain: "Reminders", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_INVALID: dueISO must be ISO-8601",
|
||||
])
|
||||
}
|
||||
reminder.dueDateComponents = Calendar.current.dateComponents(
|
||||
[.year, .month, .day, .hour, .minute, .second],
|
||||
from: dueDate)
|
||||
}
|
||||
|
||||
try store.save(reminder, commit: true)
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) }
|
||||
let payload = OpenClawReminderPayload(
|
||||
identifier: reminder.calendarItemIdentifier,
|
||||
title: reminder.title,
|
||||
dueISO: due.map { formatter.string(from: $0) },
|
||||
completed: reminder.isCompleted,
|
||||
listName: reminder.calendar.title)
|
||||
|
||||
return OpenClawRemindersAddPayload(reminder: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
return await withCheckedContinuation { cont in
|
||||
store.requestAccess(to: .reminder) { granted, _ in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
return await withCheckedContinuation { cont in
|
||||
store.requestAccess(to: .reminder) { granted, _ in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveList(
|
||||
store: EKEventStore,
|
||||
listId: String?,
|
||||
listName: String?) throws -> EKCalendar
|
||||
{
|
||||
if let id = listId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty,
|
||||
let calendar = store.calendar(withIdentifier: id)
|
||||
{
|
||||
return calendar
|
||||
}
|
||||
|
||||
if let title = listName?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
if let calendar = store.calendars(for: .reminder).first(where: {
|
||||
$0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
|
||||
}) {
|
||||
return calendar
|
||||
}
|
||||
throw NSError(domain: "Reminders", code: 5, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no list named \(title)",
|
||||
])
|
||||
}
|
||||
|
||||
if let fallback = store.defaultCalendarForNewReminders() {
|
||||
return fallback
|
||||
}
|
||||
|
||||
throw NSError(domain: "Reminders", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no default list",
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if self.shouldShowOnboarding {
|
||||
GatewayOnboardingView()
|
||||
} else {
|
||||
RootCanvas()
|
||||
}
|
||||
}
|
||||
.onAppear { self.bootstrapOnboardingIfNeeded() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.onboardingComplete = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShowOnboarding: Bool {
|
||||
if self.appModel.gatewayServerName != nil { return false }
|
||||
if self.onboardingComplete { return false }
|
||||
if self.hasExistingGatewayConfig { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private var hasExistingGatewayConfig: Bool {
|
||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
|
||||
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !preferred.isEmpty { return true }
|
||||
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return self.manualGatewayEnabled && !manualHost.isEmpty
|
||||
}
|
||||
|
||||
private func bootstrapOnboardingIfNeeded() {
|
||||
if !self.onboardingComplete, self.hasExistingGatewayConfig {
|
||||
self.onboardingComplete = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
protocol CameraServicing: Sendable {
|
||||
func listDevices() async -> [CameraController.CameraDeviceInfo]
|
||||
func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int)
|
||||
func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool)
|
||||
}
|
||||
|
||||
protocol ScreenRecordingServicing: Sendable {
|
||||
func record(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol LocationServicing: Sendable {
|
||||
func authorizationStatus() -> CLAuthorizationStatus
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization
|
||||
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus
|
||||
func currentLocation(
|
||||
params: OpenClawLocationGetParams,
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
}
|
||||
|
||||
protocol DeviceStatusServicing: Sendable {
|
||||
func status() async throws -> OpenClawDeviceStatusPayload
|
||||
func info() -> OpenClawDeviceInfoPayload
|
||||
}
|
||||
|
||||
protocol PhotosServicing: Sendable {
|
||||
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload
|
||||
}
|
||||
|
||||
protocol ContactsServicing: Sendable {
|
||||
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload
|
||||
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload
|
||||
}
|
||||
|
||||
protocol CalendarServicing: Sendable {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload
|
||||
}
|
||||
|
||||
protocol RemindersServicing: Sendable {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload
|
||||
}
|
||||
|
||||
protocol MotionServicing: Sendable {
|
||||
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload
|
||||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
||||
}
|
||||
|
||||
extension CameraController: CameraServicing {}
|
||||
extension ScreenRecordService: ScreenRecordingServicing {}
|
||||
extension LocationService: LocationServicing {}
|
||||
@@ -1,58 +0,0 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
enum NotificationAuthorizationStatus: Sendable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case authorized
|
||||
case provisional
|
||||
case ephemeral
|
||||
}
|
||||
|
||||
protocol NotificationCentering: Sendable {
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
|
||||
func add(_ request: UNNotificationRequest) async throws
|
||||
}
|
||||
|
||||
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
private let center: UNUserNotificationCenter
|
||||
|
||||
init(center: UNUserNotificationCenter = .current()) {
|
||||
self.center = center
|
||||
}
|
||||
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
let settings = await self.center.notificationSettings()
|
||||
return switch settings.authorizationStatus {
|
||||
case .authorized:
|
||||
.authorized
|
||||
case .provisional:
|
||||
.provisional
|
||||
case .ephemeral:
|
||||
.ephemeral
|
||||
case .denied:
|
||||
.denied
|
||||
case .notDetermined:
|
||||
.notDetermined
|
||||
@unknown default:
|
||||
.denied
|
||||
}
|
||||
}
|
||||
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
|
||||
try await self.center.requestAuthorization(options: options)
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
self.center.add(request) { error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
cont.resume(returning: ())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,7 @@ struct SettingsTab: View {
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("node.displayName") private var displayName: String = NodeDisplayName.defaultValue(
|
||||
for: UIDevice.current.userInterfaceIdiom)
|
||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@@ -41,7 +40,6 @@ struct SettingsTab: View {
|
||||
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@State private var manualGatewayPortText: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -122,7 +120,7 @@ struct SettingsTab: View {
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port (optional)", text: self.manualPortBinding)
|
||||
TextField("Port", value: self.$manualGatewayPort, format: .number)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
@@ -142,11 +140,11 @@ struct SettingsTab: View {
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty || !self.manualPortIsValid)
|
||||
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
|
||||
|
||||
Text(
|
||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||
+ "The gateway WebSocket listens on port 18789 by default.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -234,7 +232,6 @@ struct SettingsTab: View {
|
||||
.onAppear {
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
@@ -258,9 +255,6 @@ struct SettingsTab: View {
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||
self.connectStatus.text = nil
|
||||
}
|
||||
@@ -284,24 +278,8 @@ struct SettingsTab: View {
|
||||
@ViewBuilder
|
||||
private func gatewayList(showing: GatewayListMode) -> some View {
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("No gateways found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("If your gateway is on another network, connect it and ensure DNS is working.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
Button {
|
||||
Task { await self.connectLastKnown() }
|
||||
} label: {
|
||||
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(self.appModel.seamColor)
|
||||
}
|
||||
}
|
||||
Text("No gateways found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
let connectedID = self.appModel.connectedGatewayID
|
||||
let rows = self.gatewayController.gateways.filter { gateway in
|
||||
@@ -399,77 +377,13 @@ struct SettingsTab: View {
|
||||
await self.gatewayController.connect(gateway)
|
||||
}
|
||||
|
||||
private func connectLastKnown() async {
|
||||
self.connectingGatewayID = "last-known"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lastKnownButtonLabel(host: String, port: Int) -> some View {
|
||||
if self.connectingGatewayID == "last-known" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "bolt.horizontal.circle.fill")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Connect last known")
|
||||
Text("\(host):\(port)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var manualPortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
set: { newValue in
|
||||
let filtered = newValue.filter(\.isNumber)
|
||||
if self.manualGatewayPortText != filtered {
|
||||
self.manualGatewayPortText = filtered
|
||||
}
|
||||
if filtered.isEmpty {
|
||||
if self.manualGatewayPort != 0 {
|
||||
self.manualGatewayPort = 0
|
||||
}
|
||||
} else if let port = Int(filtered), self.manualGatewayPort != port {
|
||||
self.manualGatewayPort = port
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var manualPortIsValid: Bool {
|
||||
if self.manualGatewayPortText.isEmpty { return true }
|
||||
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||
}
|
||||
|
||||
private func syncManualPortText() {
|
||||
if self.manualGatewayPort > 0 {
|
||||
let next = String(self.manualGatewayPort)
|
||||
if self.manualGatewayPortText != next {
|
||||
self.manualGatewayPortText = next
|
||||
}
|
||||
} else if !self.manualGatewayPortText.isEmpty {
|
||||
self.manualGatewayPortText = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.connectStatus.text = "Failed: host required"
|
||||
return
|
||||
}
|
||||
guard self.manualPortIsValid else {
|
||||
guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
|
||||
self.connectStatus.text = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,6 +72,12 @@ struct StatusPill: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
} else {
|
||||
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
@@ -104,7 +110,7 @@ struct StatusPill: View {
|
||||
if let activity {
|
||||
return "\(self.gateway.title), \(activity.title)"
|
||||
}
|
||||
return self.gateway.title
|
||||
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
}
|
||||
|
||||
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import AVFAudio
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import Speech
|
||||
import SwabbleKit
|
||||
|
||||
@@ -160,18 +159,14 @@ final class VoiceWakeManager: NSObject {
|
||||
|
||||
let micOk = await Self.requestMicrophonePermission()
|
||||
guard micOk else {
|
||||
self.statusText = Self.permissionMessage(
|
||||
kind: "Microphone",
|
||||
status: AVAudioSession.sharedInstance().recordPermission)
|
||||
self.statusText = "Microphone permission denied"
|
||||
self.isListening = false
|
||||
return
|
||||
}
|
||||
|
||||
let speechOk = await Self.requestSpeechPermission()
|
||||
guard speechOk else {
|
||||
self.statusText = Self.permissionMessage(
|
||||
kind: "Speech recognition",
|
||||
status: SFSpeechRecognizer.authorizationStatus())
|
||||
self.statusText = "Speech recognition permission denied"
|
||||
self.isListening = false
|
||||
return
|
||||
}
|
||||
@@ -369,101 +364,20 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
|
||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
switch session.recordPermission {
|
||||
case .granted:
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .undetermined:
|
||||
break
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { ok in
|
||||
completion(ok)
|
||||
await withCheckedContinuation(isolation: nil) { cont in
|
||||
AVAudioApplication.requestRecordPermission { ok in
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||
let status = SFSpeechRecognizer.authorizationStatus()
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .notDetermined:
|
||||
break
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
SFSpeechRecognizer.requestAuthorization { authStatus in
|
||||
completion(authStatus == .authorized)
|
||||
await withCheckedContinuation(isolation: nil) { cont in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
cont.resume(returning: status == .authorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestPermissionWithTimeout(
|
||||
_ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool
|
||||
{
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: 8,
|
||||
onTimeout: { NSError(domain: "VoiceWake", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "permission request timed out",
|
||||
]) },
|
||||
operation: {
|
||||
await withCheckedContinuation(isolation: nil) { cont in
|
||||
Task { @MainActor in
|
||||
operation { ok in
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionMessage(
|
||||
kind: String,
|
||||
status: AVAudioSession.RecordPermission) -> String
|
||||
{
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .undetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .granted:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionMessage(
|
||||
kind: String,
|
||||
status: SFSpeechRecognizerAuthorizationStatus) -> String
|
||||
{
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .restricted:
|
||||
return "\(kind) permission restricted"
|
||||
case .notDetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .authorized:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -2,32 +2,20 @@ Sources/Gateway/GatewayConnectionController.swift
|
||||
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
|
||||
Sources/Gateway/GatewayDiscoveryModel.swift
|
||||
Sources/Gateway/GatewaySettingsStore.swift
|
||||
Sources/Gateway/GatewayHealthMonitor.swift
|
||||
Sources/Gateway/KeychainStore.swift
|
||||
Sources/Capabilities/NodeCapabilityRouter.swift
|
||||
Sources/Camera/CameraController.swift
|
||||
Sources/Chat/ChatSheet.swift
|
||||
Sources/Chat/IOSGatewayChatTransport.swift
|
||||
Sources/Contacts/ContactsService.swift
|
||||
Sources/Device/DeviceStatusService.swift
|
||||
Sources/Device/NodeDisplayName.swift
|
||||
Sources/Device/NetworkStatusService.swift
|
||||
Sources/OpenClawApp.swift
|
||||
Sources/Location/LocationService.swift
|
||||
Sources/Media/PhotoLibraryService.swift
|
||||
Sources/Motion/MotionService.swift
|
||||
Sources/Model/NodeAppModel.swift
|
||||
Sources/RootCanvas.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/Services/NodeServiceProtocols.swift
|
||||
Sources/Services/NotificationService.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
Sources/Screen/ScreenTab.swift
|
||||
Sources/Screen/ScreenWebView.swift
|
||||
Sources/SessionKey.swift
|
||||
Sources/Calendar/CalendarService.swift
|
||||
Sources/Reminders/RemindersService.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/SettingsTab.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
@@ -52,7 +40,6 @@ Sources/Voice/VoiceWakePreferences.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/BonjourTypes.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/CameraCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UICommands.swift
|
||||
@@ -60,20 +47,13 @@ Sources/Voice/VoiceWakePreferences.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/CanvasCommandParams.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/CanvasCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/StoragePaths.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift
|
||||
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
|
||||
Sources/Voice/TalkModeManager.swift
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import Contacts
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct ContactsServiceTests {
|
||||
@Test func matchesPhoneOrEmailForDedupe() {
|
||||
let contact = CNMutableContact()
|
||||
contact.givenName = "Test"
|
||||
contact.phoneNumbers = [
|
||||
CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: "+1 (555) 000-0000")),
|
||||
]
|
||||
contact.emailAddresses = [
|
||||
CNLabeledValue(label: CNLabelHome, value: "test@example.com" as NSString),
|
||||
]
|
||||
|
||||
#expect(ContactsService._test_matches(contact: contact, phoneNumbers: ["15550000000"], emails: []))
|
||||
#expect(ContactsService._test_matches(contact: contact, phoneNumbers: [], emails: ["TEST@example.com"]))
|
||||
#expect(!ContactsService._test_matches(contact: contact, phoneNumbers: ["999"], emails: ["nope@example.com"]))
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
|
||||
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
|
||||
#expect(!resolved.isEmpty)
|
||||
#expect(resolved != "iOS Node")
|
||||
#expect(defaults.string(forKey: displayKey) == resolved)
|
||||
}
|
||||
}
|
||||
@@ -62,11 +61,6 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
#expect(caps.contains(OpenClawCapability.camera.rawValue))
|
||||
#expect(caps.contains(OpenClawCapability.location.rawValue))
|
||||
#expect(caps.contains(OpenClawCapability.voiceWake.rawValue))
|
||||
#expect(caps.contains(OpenClawCapability.device.rawValue))
|
||||
#expect(caps.contains(OpenClawCapability.photos.rawValue))
|
||||
#expect(caps.contains(OpenClawCapability.contacts.rawValue))
|
||||
#expect(caps.contains(OpenClawCapability.calendar.rawValue))
|
||||
#expect(caps.contains(OpenClawCapability.reminders.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,48 +76,4 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func currentCommandsExcludeShellAndIncludeNotifyAndDevice() {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let commands = Set(controller._test_currentCommands())
|
||||
|
||||
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
|
||||
#expect(commands.contains(OpenClawChatCommand.push.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue))
|
||||
|
||||
#expect(commands.contains(OpenClawDeviceCommand.status.rawValue))
|
||||
#expect(commands.contains(OpenClawDeviceCommand.info.rawValue))
|
||||
#expect(commands.contains(OpenClawContactsCommand.add.rawValue))
|
||||
#expect(commands.contains(OpenClawCalendarCommand.add.rawValue))
|
||||
#expect(commands.contains(OpenClawRemindersCommand.add.rawValue))
|
||||
#expect(commands.contains(OpenClawTalkCommand.pttStart.rawValue))
|
||||
#expect(commands.contains(OpenClawTalkCommand.pttStop.rawValue))
|
||||
#expect(commands.contains(OpenClawTalkCommand.pttCancel.rawValue))
|
||||
#expect(commands.contains(OpenClawTalkCommand.pttOnce.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func currentPermissionsIncludeExpectedKeys() {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let permissions = controller._test_currentPermissions()
|
||||
let keys = Set(permissions.keys)
|
||||
|
||||
#expect(keys.contains("camera"))
|
||||
#expect(keys.contains("microphone"))
|
||||
#expect(keys.contains("location"))
|
||||
#expect(keys.contains("screenRecording"))
|
||||
#expect(keys.contains("photos"))
|
||||
#expect(keys.contains("contacts"))
|
||||
#expect(keys.contains("calendar"))
|
||||
#expect(keys.contains("reminders"))
|
||||
#expect(keys.contains("motion"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
private actor Counter {
|
||||
private var value = 0
|
||||
|
||||
func increment() {
|
||||
value += 1
|
||||
}
|
||||
|
||||
func get() -> Int {
|
||||
value
|
||||
}
|
||||
|
||||
func set(_ newValue: Int) {
|
||||
value = newValue
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct GatewayHealthMonitorTests {
|
||||
@Test @MainActor func triggersFailureAfterThreshold() async {
|
||||
let failureCount = Counter()
|
||||
let monitor = GatewayHealthMonitor(
|
||||
config: .init(intervalSeconds: 0.001, timeoutSeconds: 0.0, maxFailures: 2))
|
||||
|
||||
monitor.start(
|
||||
check: { false },
|
||||
onFailure: { _ in
|
||||
await failureCount.increment()
|
||||
await monitor.stop()
|
||||
})
|
||||
|
||||
try? await Task.sleep(nanoseconds: 60_000_000)
|
||||
#expect(await failureCount.get() == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func resetsFailuresAfterSuccess() async {
|
||||
let failureCount = Counter()
|
||||
let calls = Counter()
|
||||
let monitor = GatewayHealthMonitor(
|
||||
config: .init(intervalSeconds: 0.001, timeoutSeconds: 0.0, maxFailures: 2))
|
||||
|
||||
monitor.start(
|
||||
check: {
|
||||
await calls.increment()
|
||||
let callCount = await calls.get()
|
||||
if callCount >= 6 {
|
||||
await monitor.stop()
|
||||
}
|
||||
return callCount % 2 == 0
|
||||
},
|
||||
onFailure: { _ in
|
||||
await failureCount.increment()
|
||||
})
|
||||
|
||||
try? await Task.sleep(nanoseconds: 60_000_000)
|
||||
#expect(await failureCount.get() == 0)
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,11 @@ private struct KeychainEntry: Hashable {
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let gatewayService = "ai.openclaw.gateway"
|
||||
private let nodeService = "ai.openclaw.node"
|
||||
private let gatewayService = "bot.molt.gateway"
|
||||
private let nodeService = "bot.molt.node"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
private func gatewayPasswordEntry(instanceId: String) -> KeychainEntry {
|
||||
KeychainEntry(service: gatewayService, account: "gateway-password.\(instanceId)")
|
||||
}
|
||||
|
||||
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -127,33 +124,4 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
|
||||
}
|
||||
|
||||
@Test func bootstrapCopiesManualPasswordToKeychainWhenMissing() {
|
||||
let instanceId = "node-test"
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"gateway.manual.password",
|
||||
]
|
||||
let passwordEntry = gatewayPasswordEntry(instanceId: instanceId)
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain([passwordEntry, instanceIdEntry])
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
}
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": instanceId,
|
||||
"gateway.manual.password": "manual-secret",
|
||||
])
|
||||
applyKeychain([
|
||||
passwordEntry: nil,
|
||||
instanceIdEntry: nil,
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: passwordEntry.account) == "manual-secret")
|
||||
#expect(UserDefaults.standard.string(forKey: "gateway.manual.password") != "manual-secret")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.1</string>
|
||||
<string>2026.2.6</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260201</string>
|
||||
<string>20260202</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
@testable import OpenClaw
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
@@ -31,210 +29,6 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
return try body()
|
||||
}
|
||||
|
||||
private final class TestNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
private(set) var requestAuthorizationCalls = 0
|
||||
private(set) var addedRequests: [UNNotificationRequest] = []
|
||||
private var status: NotificationAuthorizationStatus
|
||||
|
||||
init(status: NotificationAuthorizationStatus) {
|
||||
self.status = status
|
||||
}
|
||||
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
status
|
||||
}
|
||||
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
|
||||
requestAuthorizationCalls += 1
|
||||
status = .authorized
|
||||
return true
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
addedRequests.append(request)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestCameraService: CameraServicing {
|
||||
func listDevices() async -> [CameraController.CameraDeviceInfo] { [] }
|
||||
func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int) {
|
||||
("jpeg", "dGVzdA==", 1, 1)
|
||||
}
|
||||
func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool) {
|
||||
("mp4", "dGVzdA==", 1000, true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestScreenRecorder: ScreenRecordingServicing {
|
||||
func record(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> String
|
||||
{
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("openclaw-screen-test.mp4")
|
||||
FileManager.default.createFile(atPath: url.path, contents: Data())
|
||||
return url.path
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct TestLocationService: LocationServicing {
|
||||
func authorizationStatus() -> CLAuthorizationStatus { .authorizedWhenInUse }
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization { .fullAccuracy }
|
||||
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus { .authorizedWhenInUse }
|
||||
func currentLocation(
|
||||
params: OpenClawLocationGetParams,
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
CLLocation(latitude: 37.3349, longitude: -122.0090)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestDeviceStatusService: DeviceStatusServicing {
|
||||
let statusPayload: OpenClawDeviceStatusPayload
|
||||
let infoPayload: OpenClawDeviceInfoPayload
|
||||
|
||||
func status() async throws -> OpenClawDeviceStatusPayload { statusPayload }
|
||||
func info() -> OpenClawDeviceInfoPayload { infoPayload }
|
||||
}
|
||||
|
||||
private struct TestPhotosService: PhotosServicing {
|
||||
let payload: OpenClawPhotosLatestPayload
|
||||
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload { payload }
|
||||
}
|
||||
|
||||
private struct TestContactsService: ContactsServicing {
|
||||
let searchPayload: OpenClawContactsSearchPayload
|
||||
let addPayload: OpenClawContactsAddPayload
|
||||
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload { searchPayload }
|
||||
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload { addPayload }
|
||||
}
|
||||
|
||||
private struct TestCalendarService: CalendarServicing {
|
||||
let eventsPayload: OpenClawCalendarEventsPayload
|
||||
let addPayload: OpenClawCalendarAddPayload
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload { eventsPayload }
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload { addPayload }
|
||||
}
|
||||
|
||||
private struct TestRemindersService: RemindersServicing {
|
||||
let listPayload: OpenClawRemindersListPayload
|
||||
let addPayload: OpenClawRemindersAddPayload
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload { listPayload }
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload { addPayload }
|
||||
}
|
||||
|
||||
private struct TestMotionService: MotionServicing {
|
||||
let activityPayload: OpenClawMotionActivityPayload
|
||||
let pedometerPayload: OpenClawPedometerPayload
|
||||
|
||||
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload {
|
||||
activityPayload
|
||||
}
|
||||
|
||||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload {
|
||||
pedometerPayload
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeTestAppModel(
|
||||
notificationCenter: NotificationCentering = TestNotificationCenter(status: .authorized),
|
||||
deviceStatusService: DeviceStatusServicing,
|
||||
photosService: PhotosServicing,
|
||||
contactsService: ContactsServicing,
|
||||
calendarService: CalendarServicing,
|
||||
remindersService: RemindersServicing,
|
||||
motionService: MotionServicing,
|
||||
talkMode: TalkModeManager = TalkModeManager(allowSimulatorCapture: true)) -> NodeAppModel
|
||||
{
|
||||
NodeAppModel(
|
||||
screen: ScreenController(),
|
||||
camera: TestCameraService(),
|
||||
screenRecorder: TestScreenRecorder(),
|
||||
locationService: TestLocationService(),
|
||||
notificationCenter: notificationCenter,
|
||||
deviceStatusService: deviceStatusService,
|
||||
photosService: photosService,
|
||||
contactsService: contactsService,
|
||||
calendarService: calendarService,
|
||||
remindersService: remindersService,
|
||||
motionService: motionService,
|
||||
talkMode: talkMode)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeTalkTestAppModel(talkMode: TalkModeManager) -> NodeAppModel {
|
||||
makeTestAppModel(
|
||||
deviceStatusService: TestDeviceStatusService(
|
||||
statusPayload: OpenClawDeviceStatusPayload(
|
||||
battery: OpenClawBatteryStatusPayload(level: 0.5, state: .unplugged, lowPowerModeEnabled: false),
|
||||
thermal: OpenClawThermalStatusPayload(state: .nominal),
|
||||
storage: OpenClawStorageStatusPayload(totalBytes: 10, freeBytes: 5, usedBytes: 5),
|
||||
network: OpenClawNetworkStatusPayload(
|
||||
status: .satisfied,
|
||||
isExpensive: false,
|
||||
isConstrained: false,
|
||||
interfaces: [.wifi]),
|
||||
uptimeSeconds: 1),
|
||||
infoPayload: OpenClawDeviceInfoPayload(
|
||||
deviceName: "Test",
|
||||
modelIdentifier: "Test1,1",
|
||||
systemName: "iOS",
|
||||
systemVersion: "1.0",
|
||||
appVersion: "dev",
|
||||
appBuild: "0",
|
||||
locale: "en-US")),
|
||||
photosService: TestPhotosService(payload: OpenClawPhotosLatestPayload(photos: [])),
|
||||
contactsService: TestContactsService(
|
||||
searchPayload: OpenClawContactsSearchPayload(contacts: []),
|
||||
addPayload: OpenClawContactsAddPayload(contact: OpenClawContactPayload(
|
||||
identifier: "c0",
|
||||
displayName: "",
|
||||
givenName: "",
|
||||
familyName: "",
|
||||
organizationName: "",
|
||||
phoneNumbers: [],
|
||||
emails: []))),
|
||||
calendarService: TestCalendarService(
|
||||
eventsPayload: OpenClawCalendarEventsPayload(events: []),
|
||||
addPayload: OpenClawCalendarAddPayload(event: OpenClawCalendarEventPayload(
|
||||
identifier: "e0",
|
||||
title: "Test",
|
||||
startISO: "2024-01-01T00:00:00Z",
|
||||
endISO: "2024-01-01T00:10:00Z",
|
||||
isAllDay: false,
|
||||
location: nil,
|
||||
calendarTitle: nil))),
|
||||
remindersService: TestRemindersService(
|
||||
listPayload: OpenClawRemindersListPayload(reminders: []),
|
||||
addPayload: OpenClawRemindersAddPayload(reminder: OpenClawReminderPayload(
|
||||
identifier: "r0",
|
||||
title: "Test",
|
||||
dueISO: nil,
|
||||
completed: false,
|
||||
listName: nil))),
|
||||
motionService: TestMotionService(
|
||||
activityPayload: OpenClawMotionActivityPayload(activities: []),
|
||||
pedometerPayload: OpenClawPedometerPayload(
|
||||
startISO: "2024-01-01T00:00:00Z",
|
||||
endISO: "2024-01-01T01:00:00Z",
|
||||
steps: nil,
|
||||
distanceMeters: nil,
|
||||
floorsAscended: nil,
|
||||
floorsDescended: nil)),
|
||||
talkMode: talkMode)
|
||||
}
|
||||
|
||||
private func decodePayload<T: Decodable>(_ json: String?, as type: T.Type) throws -> T {
|
||||
let data = try #require(json?.data(using: .utf8))
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct NodeAppModelInvokeTests {
|
||||
@Test @MainActor func decodeParamsFailsWithoutJSON() {
|
||||
#expect(throws: Error.self) {
|
||||
@@ -330,11 +124,6 @@ private func decodePayload<T: Decodable>(_ json: String?, as type: T.Type) throw
|
||||
let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8))
|
||||
let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||
#expect(payload?["result"] as? String == "2")
|
||||
|
||||
let hide = BridgeInvokeRequest(id: "hide", command: OpenClawCanvasCommand.hide.rawValue)
|
||||
let hideRes = await appModel._test_handleInvoke(hide)
|
||||
#expect(hideRes.ok == true)
|
||||
#expect(appModel.screen.urlString.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
|
||||
@@ -366,470 +155,6 @@ private func decodePayload<T: Decodable>(_ json: String?, as type: T.Type) throw
|
||||
#expect(res.error?.code == .invalidRequest)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeSystemNotifyCreatesNotificationRequest() async throws {
|
||||
let notifier = TestNotificationCenter(status: .notDetermined)
|
||||
let deviceStatus = TestDeviceStatusService(
|
||||
statusPayload: OpenClawDeviceStatusPayload(
|
||||
battery: OpenClawBatteryStatusPayload(level: 0.5, state: .charging, lowPowerModeEnabled: false),
|
||||
thermal: OpenClawThermalStatusPayload(state: .nominal),
|
||||
storage: OpenClawStorageStatusPayload(totalBytes: 100, freeBytes: 50, usedBytes: 50),
|
||||
network: OpenClawNetworkStatusPayload(
|
||||
status: .satisfied,
|
||||
isExpensive: false,
|
||||
isConstrained: false,
|
||||
interfaces: [.wifi]),
|
||||
uptimeSeconds: 10),
|
||||
infoPayload: OpenClawDeviceInfoPayload(
|
||||
deviceName: "Test",
|
||||
modelIdentifier: "Test1,1",
|
||||
systemName: "iOS",
|
||||
systemVersion: "1.0",
|
||||
appVersion: "dev",
|
||||
appBuild: "0",
|
||||
locale: "en-US"))
|
||||
let emptyContact = OpenClawContactPayload(
|
||||
identifier: "c0",
|
||||
displayName: "",
|
||||
givenName: "",
|
||||
familyName: "",
|
||||
organizationName: "",
|
||||
phoneNumbers: [],
|
||||
emails: [])
|
||||
let emptyEvent = OpenClawCalendarEventPayload(
|
||||
identifier: "e0",
|
||||
title: "Test",
|
||||
startISO: "2024-01-01T00:00:00Z",
|
||||
endISO: "2024-01-01T00:30:00Z",
|
||||
isAllDay: false,
|
||||
location: nil,
|
||||
calendarTitle: nil)
|
||||
let emptyReminder = OpenClawReminderPayload(
|
||||
identifier: "r0",
|
||||
title: "Test",
|
||||
dueISO: nil,
|
||||
completed: false,
|
||||
listName: nil)
|
||||
let appModel = makeTestAppModel(
|
||||
notificationCenter: notifier,
|
||||
deviceStatusService: deviceStatus,
|
||||
photosService: TestPhotosService(payload: OpenClawPhotosLatestPayload(photos: [])),
|
||||
contactsService: TestContactsService(
|
||||
searchPayload: OpenClawContactsSearchPayload(contacts: []),
|
||||
addPayload: OpenClawContactsAddPayload(contact: emptyContact)),
|
||||
calendarService: TestCalendarService(
|
||||
eventsPayload: OpenClawCalendarEventsPayload(events: []),
|
||||
addPayload: OpenClawCalendarAddPayload(event: emptyEvent)),
|
||||
remindersService: TestRemindersService(
|
||||
listPayload: OpenClawRemindersListPayload(reminders: []),
|
||||
addPayload: OpenClawRemindersAddPayload(reminder: emptyReminder)),
|
||||
motionService: TestMotionService(
|
||||
activityPayload: OpenClawMotionActivityPayload(activities: []),
|
||||
pedometerPayload: OpenClawPedometerPayload(
|
||||
startISO: "2024-01-01T00:00:00Z",
|
||||
endISO: "2024-01-01T01:00:00Z",
|
||||
steps: nil,
|
||||
distanceMeters: nil,
|
||||
floorsAscended: nil,
|
||||
floorsDescended: nil)))
|
||||
|
||||
let params = OpenClawSystemNotifyParams(title: "Hello", body: "World")
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(decoding: data, as: UTF8.self)
|
||||
let req = BridgeInvokeRequest(
|
||||
id: "notify",
|
||||
command: OpenClawSystemCommand.notify.rawValue,
|
||||
paramsJSON: json)
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
#expect(res.ok == true)
|
||||
#expect(notifier.requestAuthorizationCalls == 1)
|
||||
#expect(notifier.addedRequests.count == 1)
|
||||
let request = try #require(notifier.addedRequests.first)
|
||||
#expect(request.content.title == "Hello")
|
||||
#expect(request.content.body == "World")
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeChatPushCreatesNotification() async throws {
|
||||
let notifier = TestNotificationCenter(status: .authorized)
|
||||
let deviceStatus = TestDeviceStatusService(
|
||||
statusPayload: OpenClawDeviceStatusPayload(
|
||||
battery: OpenClawBatteryStatusPayload(level: 0.5, state: .charging, lowPowerModeEnabled: false),
|
||||
thermal: OpenClawThermalStatusPayload(state: .nominal),
|
||||
storage: OpenClawStorageStatusPayload(totalBytes: 100, freeBytes: 50, usedBytes: 50),
|
||||
network: OpenClawNetworkStatusPayload(
|
||||
status: .satisfied,
|
||||
isExpensive: false,
|
||||
isConstrained: false,
|
||||
interfaces: [.wifi]),
|
||||
uptimeSeconds: 10),
|
||||
infoPayload: OpenClawDeviceInfoPayload(
|
||||
deviceName: "Test",
|
||||
modelIdentifier: "Test1,1",
|
||||
systemName: "iOS",
|
||||
systemVersion: "1.0",
|
||||
appVersion: "dev",
|
||||
appBuild: "0",
|
||||
locale: "en-US"))
|
||||
let emptyContact = OpenClawContactPayload(
|
||||
identifier: "c0",
|
||||
displayName: "",
|
||||
givenName: "",
|
||||
familyName: "",
|
||||
organizationName: "",
|
||||
phoneNumbers: [],
|
||||
emails: [])
|
||||
let emptyEvent = OpenClawCalendarEventPayload(
|
||||
identifier: "e0",
|
||||
title: "Test",
|
||||
startISO: "2024-01-01T00:00:00Z",
|
||||
endISO: "2024-01-01T00:30:00Z",
|
||||
isAllDay: false,
|
||||
location: nil,
|
||||
calendarTitle: nil)
|
||||
let emptyReminder = OpenClawReminderPayload(
|
||||
identifier: "r0",
|
||||
title: "Test",
|
||||
dueISO: nil,
|
||||
completed: false,
|
||||
listName: nil)
|
||||
let appModel = makeTestAppModel(
|
||||
notificationCenter: notifier,
|
||||
deviceStatusService: deviceStatus,
|
||||
photosService: TestPhotosService(payload: OpenClawPhotosLatestPayload(photos: [])),
|
||||
contactsService: TestContactsService(
|
||||
searchPayload: OpenClawContactsSearchPayload(contacts: []),
|
||||
addPayload: OpenClawContactsAddPayload(contact: emptyContact)),
|
||||
calendarService: TestCalendarService(
|
||||
eventsPayload: OpenClawCalendarEventsPayload(events: []),
|
||||
addPayload: OpenClawCalendarAddPayload(event: emptyEvent)),
|
||||
remindersService: TestRemindersService(
|
||||
listPayload: OpenClawRemindersListPayload(reminders: []),
|
||||
addPayload: OpenClawRemindersAddPayload(reminder: emptyReminder)),
|
||||
motionService: TestMotionService(
|
||||
activityPayload: OpenClawMotionActivityPayload(activities: []),
|
||||
pedometerPayload: OpenClawPedometerPayload(
|
||||
startISO: "2024-01-01T00:00:00Z",
|
||||
endISO: "2024-01-01T01:00:00Z",
|
||||
steps: nil,
|
||||
distanceMeters: nil,
|
||||
floorsAscended: nil,
|
||||
floorsDescended: nil)))
|
||||
|
||||
let params = OpenClawChatPushParams(text: "Ping", speak: false)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(decoding: data, as: UTF8.self)
|
||||
let req = BridgeInvokeRequest(
|
||||
id: "chat-push",
|
||||
command: OpenClawChatCommand.push.rawValue,
|
||||
paramsJSON: json)
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
#expect(res.ok == true)
|
||||
#expect(notifier.addedRequests.count == 1)
|
||||
let request = try #require(notifier.addedRequests.first)
|
||||
#expect(request.content.title == "OpenClaw")
|
||||
#expect(request.content.body == "Ping")
|
||||
let payloadJSON = try #require(res.payloadJSON)
|
||||
let decoded = try JSONDecoder().decode(OpenClawChatPushPayload.self, from: Data(payloadJSON.utf8))
|
||||
#expect((decoded.messageId ?? "").isEmpty == false)
|
||||
#expect(request.identifier == decoded.messageId)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeDeviceAndDataCommandsReturnPayloads() async throws {
|
||||
let deviceStatusPayload = OpenClawDeviceStatusPayload(
|
||||
battery: OpenClawBatteryStatusPayload(level: 0.25, state: .unplugged, lowPowerModeEnabled: false),
|
||||
thermal: OpenClawThermalStatusPayload(state: .fair),
|
||||
storage: OpenClawStorageStatusPayload(totalBytes: 200, freeBytes: 80, usedBytes: 120),
|
||||
network: OpenClawNetworkStatusPayload(
|
||||
status: .satisfied,
|
||||
isExpensive: true,
|
||||
isConstrained: false,
|
||||
interfaces: [.cellular]),
|
||||
uptimeSeconds: 42)
|
||||
let deviceInfoPayload = OpenClawDeviceInfoPayload(
|
||||
deviceName: "TestPhone",
|
||||
modelIdentifier: "Test2,1",
|
||||
systemName: "iOS",
|
||||
systemVersion: "2.0",
|
||||
appVersion: "dev",
|
||||
appBuild: "1",
|
||||
locale: "en-US")
|
||||
let photosPayload = OpenClawPhotosLatestPayload(
|
||||
photos: [
|
||||
OpenClawPhotoPayload(format: "jpeg", base64: "dGVzdA==", width: 1, height: 1, createdAt: nil),
|
||||
])
|
||||
let contactsPayload = OpenClawContactsSearchPayload(
|
||||
contacts: [
|
||||
OpenClawContactPayload(
|
||||
identifier: "c1",
|
||||
displayName: "Jane Doe",
|
||||
givenName: "Jane",
|
||||
familyName: "Doe",
|
||||
organizationName: "",
|
||||
phoneNumbers: ["+1"],
|
||||
emails: ["jane@example.com"]),
|
||||
])
|
||||
let contactsAddPayload = OpenClawContactsAddPayload(
|
||||
contact: OpenClawContactPayload(
|
||||
identifier: "c2",
|
||||
displayName: "Added",
|
||||
givenName: "Added",
|
||||
familyName: "",
|
||||
organizationName: "",
|
||||
phoneNumbers: ["+2"],
|
||||
emails: ["add@example.com"]))
|
||||
let calendarPayload = OpenClawCalendarEventsPayload(
|
||||
events: [
|
||||
OpenClawCalendarEventPayload(
|
||||
identifier: "e1",
|
||||
title: "Standup",
|
||||
startISO: "2024-01-01T00:00:00Z",
|
||||
endISO: "2024-01-01T00:30:00Z",
|
||||
isAllDay: false,
|
||||
location: nil,
|
||||
calendarTitle: "Work"),
|
||||
])
|
||||
let calendarAddPayload = OpenClawCalendarAddPayload(
|
||||
event: OpenClawCalendarEventPayload(
|
||||
identifier: "e2",
|
||||
title: "Added Event",
|
||||
startISO: "2024-01-02T00:00:00Z",
|
||||
endISO: "2024-01-02T01:00:00Z",
|
||||
isAllDay: false,
|
||||
location: "HQ",
|
||||
calendarTitle: "Work"))
|
||||
let remindersPayload = OpenClawRemindersListPayload(
|
||||
reminders: [
|
||||
OpenClawReminderPayload(
|
||||
identifier: "r1",
|
||||
title: "Ship build",
|
||||
dueISO: "2024-01-02T00:00:00Z",
|
||||
completed: false,
|
||||
listName: "Inbox"),
|
||||
])
|
||||
let remindersAddPayload = OpenClawRemindersAddPayload(
|
||||
reminder: OpenClawReminderPayload(
|
||||
identifier: "r2",
|
||||
title: "Added Reminder",
|
||||
dueISO: "2024-01-03T00:00:00Z",
|
||||
completed: false,
|
||||
listName: "Inbox"))
|
||||
let motionPayload = OpenClawMotionActivityPayload(
|
||||
activities: [
|
||||
OpenClawMotionActivityEntry(
|
||||
startISO: "2024-01-01T00:00:00Z",
|
||||
endISO: "2024-01-01T00:10:00Z",
|
||||
confidence: "high",
|
||||
isWalking: true,
|
||||
isRunning: false,
|
||||
isCycling: false,
|
||||
isAutomotive: false,
|
||||
isStationary: false,
|
||||
isUnknown: false),
|
||||
])
|
||||
let pedometerPayload = OpenClawPedometerPayload(
|
||||
startISO: "2024-01-01T00:00:00Z",
|
||||
endISO: "2024-01-01T01:00:00Z",
|
||||
steps: 123,
|
||||
distanceMeters: 456,
|
||||
floorsAscended: 1,
|
||||
floorsDescended: 2)
|
||||
|
||||
let appModel = makeTestAppModel(
|
||||
deviceStatusService: TestDeviceStatusService(
|
||||
statusPayload: deviceStatusPayload,
|
||||
infoPayload: deviceInfoPayload),
|
||||
photosService: TestPhotosService(payload: photosPayload),
|
||||
contactsService: TestContactsService(
|
||||
searchPayload: contactsPayload,
|
||||
addPayload: contactsAddPayload),
|
||||
calendarService: TestCalendarService(
|
||||
eventsPayload: calendarPayload,
|
||||
addPayload: calendarAddPayload),
|
||||
remindersService: TestRemindersService(
|
||||
listPayload: remindersPayload,
|
||||
addPayload: remindersAddPayload),
|
||||
motionService: TestMotionService(
|
||||
activityPayload: motionPayload,
|
||||
pedometerPayload: pedometerPayload))
|
||||
|
||||
let deviceStatusReq = BridgeInvokeRequest(id: "device", command: OpenClawDeviceCommand.status.rawValue)
|
||||
let deviceStatusRes = await appModel._test_handleInvoke(deviceStatusReq)
|
||||
#expect(deviceStatusRes.ok == true)
|
||||
let decodedDeviceStatus = try decodePayload(deviceStatusRes.payloadJSON, as: OpenClawDeviceStatusPayload.self)
|
||||
#expect(decodedDeviceStatus == deviceStatusPayload)
|
||||
|
||||
let deviceInfoReq = BridgeInvokeRequest(id: "device-info", command: OpenClawDeviceCommand.info.rawValue)
|
||||
let deviceInfoRes = await appModel._test_handleInvoke(deviceInfoReq)
|
||||
#expect(deviceInfoRes.ok == true)
|
||||
let decodedDeviceInfo = try decodePayload(deviceInfoRes.payloadJSON, as: OpenClawDeviceInfoPayload.self)
|
||||
#expect(decodedDeviceInfo == deviceInfoPayload)
|
||||
|
||||
let photosReq = BridgeInvokeRequest(id: "photos", command: OpenClawPhotosCommand.latest.rawValue)
|
||||
let photosRes = await appModel._test_handleInvoke(photosReq)
|
||||
#expect(photosRes.ok == true)
|
||||
let decodedPhotos = try decodePayload(photosRes.payloadJSON, as: OpenClawPhotosLatestPayload.self)
|
||||
#expect(decodedPhotos == photosPayload)
|
||||
|
||||
let contactsReq = BridgeInvokeRequest(id: "contacts", command: OpenClawContactsCommand.search.rawValue)
|
||||
let contactsRes = await appModel._test_handleInvoke(contactsReq)
|
||||
#expect(contactsRes.ok == true)
|
||||
let decodedContacts = try decodePayload(contactsRes.payloadJSON, as: OpenClawContactsSearchPayload.self)
|
||||
#expect(decodedContacts == contactsPayload)
|
||||
|
||||
let contactsAddParams = OpenClawContactsAddParams(
|
||||
givenName: "Added",
|
||||
phoneNumbers: ["+2"],
|
||||
emails: ["add@example.com"])
|
||||
let contactsAddData = try JSONEncoder().encode(contactsAddParams)
|
||||
let contactsAddReq = BridgeInvokeRequest(
|
||||
id: "contacts-add",
|
||||
command: OpenClawContactsCommand.add.rawValue,
|
||||
paramsJSON: String(decoding: contactsAddData, as: UTF8.self))
|
||||
let contactsAddRes = await appModel._test_handleInvoke(contactsAddReq)
|
||||
#expect(contactsAddRes.ok == true)
|
||||
let decodedContactsAdd = try decodePayload(contactsAddRes.payloadJSON, as: OpenClawContactsAddPayload.self)
|
||||
#expect(decodedContactsAdd == contactsAddPayload)
|
||||
|
||||
let calendarReq = BridgeInvokeRequest(id: "calendar", command: OpenClawCalendarCommand.events.rawValue)
|
||||
let calendarRes = await appModel._test_handleInvoke(calendarReq)
|
||||
#expect(calendarRes.ok == true)
|
||||
let decodedCalendar = try decodePayload(calendarRes.payloadJSON, as: OpenClawCalendarEventsPayload.self)
|
||||
#expect(decodedCalendar == calendarPayload)
|
||||
|
||||
let calendarAddParams = OpenClawCalendarAddParams(
|
||||
title: "Added Event",
|
||||
startISO: "2024-01-02T00:00:00Z",
|
||||
endISO: "2024-01-02T01:00:00Z",
|
||||
location: "HQ",
|
||||
calendarTitle: "Work")
|
||||
let calendarAddData = try JSONEncoder().encode(calendarAddParams)
|
||||
let calendarAddReq = BridgeInvokeRequest(
|
||||
id: "calendar-add",
|
||||
command: OpenClawCalendarCommand.add.rawValue,
|
||||
paramsJSON: String(decoding: calendarAddData, as: UTF8.self))
|
||||
let calendarAddRes = await appModel._test_handleInvoke(calendarAddReq)
|
||||
#expect(calendarAddRes.ok == true)
|
||||
let decodedCalendarAdd = try decodePayload(calendarAddRes.payloadJSON, as: OpenClawCalendarAddPayload.self)
|
||||
#expect(decodedCalendarAdd == calendarAddPayload)
|
||||
|
||||
let remindersReq = BridgeInvokeRequest(id: "reminders", command: OpenClawRemindersCommand.list.rawValue)
|
||||
let remindersRes = await appModel._test_handleInvoke(remindersReq)
|
||||
#expect(remindersRes.ok == true)
|
||||
let decodedReminders = try decodePayload(remindersRes.payloadJSON, as: OpenClawRemindersListPayload.self)
|
||||
#expect(decodedReminders == remindersPayload)
|
||||
|
||||
let remindersAddParams = OpenClawRemindersAddParams(
|
||||
title: "Added Reminder",
|
||||
dueISO: "2024-01-03T00:00:00Z",
|
||||
listName: "Inbox")
|
||||
let remindersAddData = try JSONEncoder().encode(remindersAddParams)
|
||||
let remindersAddReq = BridgeInvokeRequest(
|
||||
id: "reminders-add",
|
||||
command: OpenClawRemindersCommand.add.rawValue,
|
||||
paramsJSON: String(decoding: remindersAddData, as: UTF8.self))
|
||||
let remindersAddRes = await appModel._test_handleInvoke(remindersAddReq)
|
||||
#expect(remindersAddRes.ok == true)
|
||||
let decodedRemindersAdd = try decodePayload(remindersAddRes.payloadJSON, as: OpenClawRemindersAddPayload.self)
|
||||
#expect(decodedRemindersAdd == remindersAddPayload)
|
||||
|
||||
let motionReq = BridgeInvokeRequest(id: "motion", command: OpenClawMotionCommand.activity.rawValue)
|
||||
let motionRes = await appModel._test_handleInvoke(motionReq)
|
||||
#expect(motionRes.ok == true)
|
||||
let decodedMotion = try decodePayload(motionRes.payloadJSON, as: OpenClawMotionActivityPayload.self)
|
||||
#expect(decodedMotion == motionPayload)
|
||||
|
||||
let pedometerReq = BridgeInvokeRequest(id: "pedometer", command: OpenClawMotionCommand.pedometer.rawValue)
|
||||
let pedometerRes = await appModel._test_handleInvoke(pedometerReq)
|
||||
#expect(pedometerRes.ok == true)
|
||||
let decodedPedometer = try decodePayload(pedometerRes.payloadJSON, as: OpenClawPedometerPayload.self)
|
||||
#expect(decodedPedometer == pedometerPayload)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokePushToTalkReturnsTranscriptStatus() async throws {
|
||||
let talkMode = TalkModeManager(allowSimulatorCapture: true)
|
||||
talkMode.updateGatewayConnected(false)
|
||||
let appModel = makeTalkTestAppModel(talkMode: talkMode)
|
||||
|
||||
let startReq = BridgeInvokeRequest(id: "ptt-start", command: OpenClawTalkCommand.pttStart.rawValue)
|
||||
let startRes = await appModel._test_handleInvoke(startReq)
|
||||
#expect(startRes.ok == true)
|
||||
let startPayload = try decodePayload(startRes.payloadJSON, as: OpenClawTalkPTTStartPayload.self)
|
||||
#expect(!startPayload.captureId.isEmpty)
|
||||
|
||||
talkMode._test_seedTranscript("Hello from PTT")
|
||||
|
||||
let stopReq = BridgeInvokeRequest(id: "ptt-stop", command: OpenClawTalkCommand.pttStop.rawValue)
|
||||
let stopRes = await appModel._test_handleInvoke(stopReq)
|
||||
#expect(stopRes.ok == true)
|
||||
let stopPayload = try decodePayload(stopRes.payloadJSON, as: OpenClawTalkPTTStopPayload.self)
|
||||
#expect(stopPayload.captureId == startPayload.captureId)
|
||||
#expect(stopPayload.transcript == "Hello from PTT")
|
||||
#expect(stopPayload.status == "offline")
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokePushToTalkCancelStopsSession() async throws {
|
||||
let talkMode = TalkModeManager(allowSimulatorCapture: true)
|
||||
talkMode.updateGatewayConnected(false)
|
||||
let appModel = makeTalkTestAppModel(talkMode: talkMode)
|
||||
|
||||
let startReq = BridgeInvokeRequest(id: "ptt-start", command: OpenClawTalkCommand.pttStart.rawValue)
|
||||
let startRes = await appModel._test_handleInvoke(startReq)
|
||||
#expect(startRes.ok == true)
|
||||
let startPayload = try decodePayload(startRes.payloadJSON, as: OpenClawTalkPTTStartPayload.self)
|
||||
|
||||
let cancelReq = BridgeInvokeRequest(id: "ptt-cancel", command: OpenClawTalkCommand.pttCancel.rawValue)
|
||||
let cancelRes = await appModel._test_handleInvoke(cancelReq)
|
||||
#expect(cancelRes.ok == true)
|
||||
let cancelPayload = try decodePayload(cancelRes.payloadJSON, as: OpenClawTalkPTTStopPayload.self)
|
||||
#expect(cancelPayload.captureId == startPayload.captureId)
|
||||
#expect(cancelPayload.status == "cancelled")
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokePushToTalkOnceAutoStopsAfterSilence() async throws {
|
||||
let talkMode = TalkModeManager(allowSimulatorCapture: true)
|
||||
talkMode.updateGatewayConnected(false)
|
||||
let appModel = makeTalkTestAppModel(talkMode: talkMode)
|
||||
|
||||
let onceReq = BridgeInvokeRequest(id: "ptt-once", command: OpenClawTalkCommand.pttOnce.rawValue)
|
||||
let onceTask = Task { await appModel._test_handleInvoke(onceReq) }
|
||||
|
||||
for _ in 0..<5 where !talkMode.isPushToTalkActive {
|
||||
await Task.yield()
|
||||
}
|
||||
#expect(talkMode.isPushToTalkActive == true)
|
||||
|
||||
talkMode._test_seedTranscript("Hello from PTT once")
|
||||
talkMode._test_backdateLastHeard(seconds: 1.0)
|
||||
await talkMode._test_runSilenceCheck()
|
||||
|
||||
let onceRes = await onceTask.value
|
||||
#expect(onceRes.ok == true)
|
||||
let oncePayload = try decodePayload(onceRes.payloadJSON, as: OpenClawTalkPTTStopPayload.self)
|
||||
#expect(oncePayload.transcript == "Hello from PTT once")
|
||||
#expect(oncePayload.status == "offline")
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokePushToTalkOnceStopsOnFinalTranscript() async throws {
|
||||
let talkMode = TalkModeManager(allowSimulatorCapture: true)
|
||||
talkMode.updateGatewayConnected(false)
|
||||
let appModel = makeTalkTestAppModel(talkMode: talkMode)
|
||||
|
||||
let onceReq = BridgeInvokeRequest(id: "ptt-once-final", command: OpenClawTalkCommand.pttOnce.rawValue)
|
||||
let onceTask = Task { await appModel._test_handleInvoke(onceReq) }
|
||||
|
||||
for _ in 0..<5 where !talkMode.isPushToTalkActive {
|
||||
await Task.yield()
|
||||
}
|
||||
#expect(talkMode.isPushToTalkActive == true)
|
||||
|
||||
await talkMode._test_handleTranscript("Hello final", isFinal: true)
|
||||
|
||||
let onceRes = await onceTask.value
|
||||
#expect(onceRes.ok == true)
|
||||
let oncePayload = try decodePayload(onceRes.payloadJSON, as: OpenClawTalkPTTStopPayload.self)
|
||||
#expect(oncePayload.transcript == "Hello final")
|
||||
#expect(oncePayload.status == "offline")
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async {
|
||||
let appModel = NodeAppModel()
|
||||
let url = URL(string: "openclaw://agent?message=hello")!
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct NodeDisplayNameTests {
|
||||
@Test func keepsCustomName() {
|
||||
let resolved = NodeDisplayName.resolve(
|
||||
existing: "Razor Phone",
|
||||
deviceName: "iPhone",
|
||||
interfaceIdiom: .phone)
|
||||
#expect(resolved == "Razor Phone")
|
||||
}
|
||||
|
||||
@Test func usesDeviceNameWhenMatchesIphone() {
|
||||
let resolved = NodeDisplayName.resolve(
|
||||
existing: "iOS Node",
|
||||
deviceName: "iPhone 17 Pro",
|
||||
interfaceIdiom: .phone)
|
||||
#expect(resolved == "iPhone 17 Pro")
|
||||
}
|
||||
|
||||
@Test func usesDefaultWhenDeviceNameIsGeneric() {
|
||||
let resolved = NodeDisplayName.resolve(
|
||||
existing: nil,
|
||||
deviceName: "Work Phone",
|
||||
interfaceIdiom: .phone)
|
||||
#expect(NodeDisplayName.isGeneric(resolved))
|
||||
}
|
||||
|
||||
@Test func identifiesGenericValues() {
|
||||
#expect(NodeDisplayName.isGeneric("iOS Node"))
|
||||
#expect(NodeDisplayName.isGeneric("iPhone Node"))
|
||||
#expect(NodeDisplayName.isGeneric("iPad Node"))
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct TalkModeIncrementalTests {
|
||||
@Test @MainActor func incrementalSpeechSplitsOnBoundary() {
|
||||
let talkMode = TalkModeManager(allowSimulatorCapture: true)
|
||||
talkMode._test_incrementalReset()
|
||||
let segments = talkMode._test_incrementalIngest("Hello world. Next", isFinal: false)
|
||||
#expect(segments.count == 1)
|
||||
#expect(segments.first == "Hello world.")
|
||||
}
|
||||
|
||||
@Test @MainActor func incrementalSpeechSkipsDirectiveLine() {
|
||||
let talkMode = TalkModeManager(allowSimulatorCapture: true)
|
||||
talkMode._test_incrementalReset()
|
||||
let segments = talkMode._test_incrementalIngest("{\"voice\":\"abc\"}\nHello.", isFinal: false)
|
||||
#expect(segments.count == 1)
|
||||
#expect(segments.first == "Hello.")
|
||||
}
|
||||
|
||||
@Test @MainActor func incrementalSpeechIgnoresCodeBlocks() {
|
||||
let talkMode = TalkModeManager(allowSimulatorCapture: true)
|
||||
talkMode._test_incrementalReset()
|
||||
let text = "Here is code:\n```js\nx=1\n```\nDone."
|
||||
let segments = talkMode._test_incrementalIngest(text, isFinal: true)
|
||||
#expect(segments.count == 1)
|
||||
let value = segments.first ?? ""
|
||||
#expect(value.contains("x=1") == false)
|
||||
#expect(value.contains("Here is code") == true)
|
||||
#expect(value.contains("Done.") == true)
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.2.1"
|
||||
CFBundleVersion: "20260201"
|
||||
CFBundleShortVersionString: "2026.2.6"
|
||||
CFBundleVersion: "20260202"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.1"
|
||||
CFBundleVersion: "20260201"
|
||||
CFBundleShortVersionString: "2026.2.6"
|
||||
CFBundleVersion: "20260202"
|
||||
|
||||
@@ -20,9 +20,11 @@ extension CronJobEditor {
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
switch job.schedule {
|
||||
case let .at(atMs):
|
||||
case let .at(at):
|
||||
self.scheduleKind = .at
|
||||
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
if let date = CronSchedule.parseAtDate(at) {
|
||||
self.atDate = date
|
||||
}
|
||||
case let .every(everyMs, _):
|
||||
self.scheduleKind = .every
|
||||
self.everyText = self.formatDuration(ms: everyMs)
|
||||
@@ -36,19 +38,22 @@ extension CronJobEditor {
|
||||
case let .systemEvent(text):
|
||||
self.payloadKind = .systemEvent
|
||||
self.systemEventText = text
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = message
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.channel = trimmed.isEmpty ? "last" : trimmed
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
|
||||
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
|
||||
if let delivery = job.delivery {
|
||||
self.deliveryMode = delivery.mode == .announce ? .announce : .none
|
||||
let trimmed = (delivery.channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.channel = trimmed.isEmpty ? "last" : trimmed
|
||||
self.to = delivery.to ?? ""
|
||||
self.bestEffortDeliver = delivery.bestEffort ?? false
|
||||
} else if self.sessionTarget == .isolated {
|
||||
self.deliveryMode = .announce
|
||||
}
|
||||
}
|
||||
|
||||
func save() {
|
||||
@@ -88,15 +93,29 @@ extension CronJobEditor {
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
root["delivery"] = self.buildDelivery()
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func buildDelivery() -> [String: Any] {
|
||||
let mode = self.deliveryMode == .announce ? "announce" : "none"
|
||||
var delivery: [String: Any] = ["mode": mode]
|
||||
if self.deliveryMode == .announce {
|
||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
delivery["channel"] = trimmed.isEmpty ? "last" : trimmed
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { delivery["to"] = to }
|
||||
if self.bestEffortDeliver {
|
||||
delivery["bestEffort"] = true
|
||||
} else if self.job?.delivery?.bestEffort == true {
|
||||
delivery["bestEffort"] = false
|
||||
}
|
||||
}
|
||||
return delivery
|
||||
}
|
||||
|
||||
func trimmed(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
@@ -115,7 +134,7 @@ extension CronJobEditor {
|
||||
func buildSchedule() throws -> [String: Any] {
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
return ["kind": "at", "at": CronSchedule.formatIsoDate(self.atDate)]
|
||||
case .every:
|
||||
guard let ms = Self.parseDurationMs(self.everyText) else {
|
||||
throw NSError(
|
||||
@@ -209,14 +228,6 @@ extension CronJobEditor {
|
||||
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !thinking.isEmpty { payload["thinking"] = thinking }
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,12 @@ extension CronJobEditor {
|
||||
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = "Run diagnostic"
|
||||
self.deliver = true
|
||||
self.deliveryMode = .announce
|
||||
self.channel = "last"
|
||||
self.to = "+15551230000"
|
||||
self.thinking = "low"
|
||||
self.timeoutSeconds = "90"
|
||||
self.bestEffortDeliver = true
|
||||
self.postPrefix = "Cron"
|
||||
|
||||
_ = self.buildAgentTurnPayload()
|
||||
_ = try? self.buildPayload()
|
||||
|
||||
@@ -16,23 +16,20 @@ struct CronJobEditor: View {
|
||||
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||
static let sessionTargetNote =
|
||||
"Main jobs post a system event into the current main session. "
|
||||
+ "Isolated jobs run OpenClaw in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
|
||||
+ "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel."
|
||||
static let scheduleKindNote =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
static let isolatedPayloadNote =
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
|
||||
+ "and a short summary is posted back to your main chat."
|
||||
"Isolated jobs always run an agent turn. Announce sends a short summary to a channel."
|
||||
static let mainPayloadNote =
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
static let mainSummaryNote =
|
||||
"Controls the label used when posting the completion summary back to the main session."
|
||||
|
||||
@State var name: String = ""
|
||||
@State var description: String = ""
|
||||
@State var agentId: String = ""
|
||||
@State var enabled: Bool = true
|
||||
@State var sessionTarget: CronSessionTarget = .main
|
||||
@State var wakeMode: CronWakeMode = .nextHeartbeat
|
||||
@State var wakeMode: CronWakeMode = .now
|
||||
@State var deleteAfterRun: Bool = false
|
||||
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||
@@ -46,13 +43,13 @@ struct CronJobEditor: View {
|
||||
@State var payloadKind: PayloadKind = .systemEvent
|
||||
@State var systemEventText: String = ""
|
||||
@State var agentMessage: String = ""
|
||||
@State var deliver: Bool = false
|
||||
enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String { rawValue } }
|
||||
@State var deliveryMode: DeliveryChoice = .announce
|
||||
@State var channel: String = "last"
|
||||
@State var to: String = ""
|
||||
@State var thinking: String = ""
|
||||
@State var timeoutSeconds: String = ""
|
||||
@State var bestEffortDeliver: Bool = false
|
||||
@State var postPrefix: String = "Cron"
|
||||
|
||||
var channelOptions: [String] {
|
||||
let ordered = self.channelsStore.orderedChannelIds()
|
||||
@@ -122,8 +119,8 @@ struct CronJobEditor: View {
|
||||
GridRow {
|
||||
self.gridLabel("Wake mode")
|
||||
Picker("", selection: self.$wakeMode) {
|
||||
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
|
||||
Text("now").tag(CronWakeMode.now)
|
||||
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
@@ -248,27 +245,6 @@ struct CronJobEditor: View {
|
||||
}
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
GroupBox("Main session summary") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Prefix")
|
||||
TextField("Cron", text: self.$postPrefix)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
Self.mainSummaryNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 2)
|
||||
@@ -340,13 +316,17 @@ struct CronJobEditor: View {
|
||||
.frame(width: 180, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Deliver")
|
||||
Toggle("Deliver result to a channel", isOn: self.$deliver)
|
||||
.toggleStyle(.switch)
|
||||
self.gridLabel("Delivery")
|
||||
Picker("", selection: self.$deliveryMode) {
|
||||
Text("Announce summary").tag(DeliveryChoice.announce)
|
||||
Text("None").tag(DeliveryChoice.none)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
|
||||
if self.deliver {
|
||||
if self.deliveryMode == .announce {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Channel")
|
||||
@@ -367,7 +347,7 @@ struct CronJobEditor: View {
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Best-effort")
|
||||
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
|
||||
Toggle("Do not fail the job if announce fails", isOn: self.$bestEffortDeliver)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,26 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
|
||||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
|
||||
case none
|
||||
case announce
|
||||
|
||||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
struct CronDelivery: Codable, Equatable {
|
||||
var mode: CronDeliveryMode
|
||||
var channel: String?
|
||||
var to: String?
|
||||
var bestEffort: Bool?
|
||||
}
|
||||
|
||||
enum CronSchedule: Codable, Equatable {
|
||||
case at(atMs: Int)
|
||||
case at(at: String)
|
||||
case every(everyMs: Int, anchorMs: Int?)
|
||||
case cron(expr: String, tz: String?)
|
||||
|
||||
enum CodingKeys: String, CodingKey { case kind, atMs, everyMs, anchorMs, expr, tz }
|
||||
enum CodingKeys: String, CodingKey { case kind, at, atMs, everyMs, anchorMs, expr, tz }
|
||||
|
||||
var kind: String {
|
||||
switch self {
|
||||
@@ -34,7 +48,21 @@ enum CronSchedule: Codable, Equatable {
|
||||
let kind = try container.decode(String.self, forKey: .kind)
|
||||
switch kind {
|
||||
case "at":
|
||||
self = try .at(atMs: container.decode(Int.self, forKey: .atMs))
|
||||
if let at = try container.decodeIfPresent(String.self, forKey: .at),
|
||||
!at.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
self = .at(at: at)
|
||||
return
|
||||
}
|
||||
if let atMs = try container.decodeIfPresent(Int.self, forKey: .atMs) {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
self = .at(at: Self.formatIsoDate(date))
|
||||
return
|
||||
}
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .at,
|
||||
in: container,
|
||||
debugDescription: "Missing schedule.at")
|
||||
case "every":
|
||||
self = try .every(
|
||||
everyMs: container.decode(Int.self, forKey: .everyMs),
|
||||
@@ -55,8 +83,8 @@ enum CronSchedule: Codable, Equatable {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.kind, forKey: .kind)
|
||||
switch self {
|
||||
case let .at(atMs):
|
||||
try container.encode(atMs, forKey: .atMs)
|
||||
case let .at(at):
|
||||
try container.encode(at, forKey: .at)
|
||||
case let .every(everyMs, anchorMs):
|
||||
try container.encode(everyMs, forKey: .everyMs)
|
||||
try container.encodeIfPresent(anchorMs, forKey: .anchorMs)
|
||||
@@ -65,6 +93,25 @@ enum CronSchedule: Codable, Equatable {
|
||||
try container.encodeIfPresent(tz, forKey: .tz)
|
||||
}
|
||||
}
|
||||
|
||||
static func parseAtDate(_ value: String) -> Date? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if let date = makeIsoFormatter(withFractional: true).date(from: trimmed) { return date }
|
||||
return makeIsoFormatter(withFractional: false).date(from: trimmed)
|
||||
}
|
||||
|
||||
static func formatIsoDate(_ date: Date) -> String {
|
||||
makeIsoFormatter(withFractional: false).string(from: date)
|
||||
}
|
||||
|
||||
private static func makeIsoFormatter(withFractional: Bool) -> ISO8601DateFormatter {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = withFractional
|
||||
? [.withInternetDateTime, .withFractionalSeconds]
|
||||
: [.withInternetDateTime]
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
|
||||
enum CronPayload: Codable, Equatable {
|
||||
@@ -131,10 +178,6 @@ enum CronPayload: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
struct CronIsolation: Codable, Equatable {
|
||||
var postToMainPrefix: String?
|
||||
}
|
||||
|
||||
struct CronJobState: Codable, Equatable {
|
||||
var nextRunAtMs: Int?
|
||||
var runningAtMs: Int?
|
||||
@@ -157,7 +200,7 @@ struct CronJob: Identifiable, Codable, Equatable {
|
||||
let sessionTarget: CronSessionTarget
|
||||
let wakeMode: CronWakeMode
|
||||
let payload: CronPayload
|
||||
let isolation: CronIsolation?
|
||||
let delivery: CronDelivery?
|
||||
let state: CronJobState
|
||||
|
||||
var displayName: String {
|
||||
|
||||
@@ -17,9 +17,11 @@ extension CronSettings {
|
||||
|
||||
func scheduleSummary(_ schedule: CronSchedule) -> String {
|
||||
switch schedule {
|
||||
case let .at(atMs):
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
return "at \(date.formatted(date: .abbreviated, time: .standard))"
|
||||
case let .at(at):
|
||||
if let date = CronSchedule.parseAtDate(at) {
|
||||
return "at \(date.formatted(date: .abbreviated, time: .standard))"
|
||||
}
|
||||
return "at \(at)"
|
||||
case let .every(everyMs, _):
|
||||
return "every \(self.formatDuration(ms: everyMs))"
|
||||
case let .cron(expr, tz):
|
||||
|
||||
@@ -128,7 +128,7 @@ extension CronSettings {
|
||||
.foregroundStyle(.orange)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
self.payloadSummary(job.payload)
|
||||
self.payloadSummary(job)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
@@ -205,8 +205,9 @@ extension CronSettings {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
func payloadSummary(_ payload: CronPayload) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
func payloadSummary(_ job: CronJob) -> some View {
|
||||
let payload = job.payload
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Payload")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -215,7 +216,7 @@ extension CronSettings {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
@@ -223,10 +224,19 @@ extension CronSettings {
|
||||
HStack(spacing: 8) {
|
||||
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
|
||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||
if deliver ?? false {
|
||||
StatusPill(text: "deliver", tint: .secondary)
|
||||
if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
|
||||
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||
if job.sessionTarget == .isolated {
|
||||
let delivery = job.delivery
|
||||
if let delivery {
|
||||
if delivery.mode == .announce {
|
||||
StatusPill(text: "announce", tint: .secondary)
|
||||
if let channel = delivery.channel, !channel.isEmpty {
|
||||
StatusPill(text: channel, tint: .secondary)
|
||||
}
|
||||
if let to = delivery.to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||
} else {
|
||||
StatusPill(text: "no delivery", tint: .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
message: "Summarize inbox",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 600,
|
||||
deliver: true,
|
||||
channel: "last",
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "last", to: nil, bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
|
||||
runningAtMs: nil,
|
||||
@@ -75,11 +75,11 @@ extension CronSettings {
|
||||
message: "Summarize",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_200_000,
|
||||
runningAtMs: nil,
|
||||
@@ -111,7 +111,7 @@ extension CronSettings {
|
||||
_ = view.detailCard(job)
|
||||
_ = view.runHistoryCard(job)
|
||||
_ = view.runRow(run)
|
||||
_ = view.payloadSummary(job.payload)
|
||||
_ = view.payloadSummary(job)
|
||||
_ = view.scheduleSummary(job.schedule)
|
||||
_ = view.statusTint(job.state.lastStatus)
|
||||
_ = view.nextRunLabel(Date())
|
||||
|
||||
@@ -360,12 +360,11 @@ actor GatewayConnection {
|
||||
await client.shutdown()
|
||||
}
|
||||
self.lastSnapshot = nil
|
||||
let resolvedSessionBox = self.sessionBox ?? Self.buildSessionBox(url: url)
|
||||
self.client = GatewayChannelActor(
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
session: resolvedSessionBox,
|
||||
session: self.sessionBox,
|
||||
pushHandler: { [weak self] push in
|
||||
await self?.handle(push: push)
|
||||
})
|
||||
@@ -381,21 +380,6 @@ actor GatewayConnection {
|
||||
private static func defaultConfigProvider() async throws -> Config {
|
||||
try await GatewayEndpointStore.shared.requireConfig()
|
||||
}
|
||||
|
||||
private static func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let host = url.host ?? "gateway"
|
||||
let port = url.port ?? 443
|
||||
let stableID = "\(host):\(port)"
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let params = GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
let session = GatewayTLSPinningSession(params: params)
|
||||
return WebSocketSessionBox(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Typed gateway API
|
||||
|
||||
@@ -335,7 +335,7 @@ extension OnboardingView {
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("OpenClaw supports any model — we strongly recommend Opus 4.5 for the best experience.")
|
||||
Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.1</string>
|
||||
<string>2026.2.6</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602010</string>
|
||||
<string>202602020</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -169,7 +169,7 @@ extension SessionRow {
|
||||
systemSent: true,
|
||||
abortedLastRun: true,
|
||||
tokens: SessionTokenStats(input: 5000, output: 1200, total: 6200, contextTokens: 200_000),
|
||||
model: "claude-opus-4-5"),
|
||||
model: "claude-opus-4-6"),
|
||||
SessionRow(
|
||||
id: "global",
|
||||
key: "global",
|
||||
@@ -242,7 +242,7 @@ struct SessionStoreSnapshot {
|
||||
|
||||
@MainActor
|
||||
enum SessionLoader {
|
||||
static let fallbackModel = "claude-opus-4-5"
|
||||
static let fallbackModel = "claude-opus-4-6"
|
||||
static let fallbackContextTokens = 200_000
|
||||
|
||||
static let defaultStorePath = standardize(
|
||||
|
||||
@@ -589,20 +589,24 @@ public struct AgentIdentityResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String?
|
||||
public let avatar: String?
|
||||
public let emoji: String?
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String?,
|
||||
avatar: String?
|
||||
avatar: String?,
|
||||
emoji: String?
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.avatar = avatar
|
||||
self.emoji = emoji
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case avatar
|
||||
case emoji
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsUsageParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let startdate: String?
|
||||
public let enddate: String?
|
||||
public let limit: Int?
|
||||
public let includecontextweight: Bool?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
startdate: String?,
|
||||
enddate: String?,
|
||||
limit: Int?,
|
||||
includecontextweight: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.startdate = startdate
|
||||
self.enddate = enddate
|
||||
self.limit = limit
|
||||
self.includecontextweight = includecontextweight
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case startdate = "startDate"
|
||||
case enddate = "endDate"
|
||||
case limit
|
||||
case includecontextweight = "includeContextWeight"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigGetParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
@@ -1556,6 +1589,157 @@ public struct AgentSummary: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFileEntry: Codable, Sendable {
|
||||
public let name: String
|
||||
public let path: String
|
||||
public let missing: Bool
|
||||
public let size: Int?
|
||||
public let updatedatms: Int?
|
||||
public let content: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
path: String,
|
||||
missing: Bool,
|
||||
size: Int?,
|
||||
updatedatms: Int?,
|
||||
content: String?
|
||||
) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.missing = missing
|
||||
self.size = size
|
||||
self.updatedatms = updatedatms
|
||||
self.content = content
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case path
|
||||
case missing
|
||||
case size
|
||||
case updatedatms = "updatedAtMs"
|
||||
case content
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesListParams: Codable, Sendable {
|
||||
public let agentid: String
|
||||
|
||||
public init(
|
||||
agentid: String
|
||||
) {
|
||||
self.agentid = agentid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesListResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let workspace: String
|
||||
public let files: [AgentsFileEntry]
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
workspace: String,
|
||||
files: [AgentsFileEntry]
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.workspace = workspace
|
||||
self.files = files
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case workspace
|
||||
case files
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesGetParams: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesGetResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let workspace: String
|
||||
public let file: AgentsFileEntry
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
workspace: String,
|
||||
file: AgentsFileEntry
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.workspace = workspace
|
||||
self.file = file
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case workspace
|
||||
case file
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesSetParams: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String
|
||||
public let content: String
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String,
|
||||
content: String
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.content = content
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case content
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesSetResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let agentid: String
|
||||
public let workspace: String
|
||||
public let file: AgentsFileEntry
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
agentid: String,
|
||||
workspace: String,
|
||||
file: AgentsFileEntry
|
||||
) {
|
||||
self.ok = ok
|
||||
self.agentid = agentid
|
||||
self.workspace = workspace
|
||||
self.file = file
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case agentid = "agentId"
|
||||
case workspace
|
||||
case file
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
@@ -1630,6 +1814,16 @@ public struct ModelsListResult: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct SkillsStatusParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
agentid: String?
|
||||
) {
|
||||
self.agentid = agentid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsBinsParams: Codable, Sendable {
|
||||
@@ -1707,7 +1901,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
public let delivery: [String: AnyCodable]?
|
||||
public let state: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -1723,7 +1917,7 @@ public struct CronJob: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?,
|
||||
delivery: [String: AnyCodable]?,
|
||||
state: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
@@ -1738,7 +1932,7 @@ public struct CronJob: Codable, Sendable {
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
self.delivery = delivery
|
||||
self.state = state
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -1754,7 +1948,7 @@ public struct CronJob: Codable, Sendable {
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
case delivery
|
||||
case state
|
||||
}
|
||||
}
|
||||
@@ -1785,7 +1979,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
public let delivery: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
@@ -1797,7 +1991,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?
|
||||
delivery: [String: AnyCodable]?
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
@@ -1808,7 +2002,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
self.delivery = delivery
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
@@ -1820,7 +2014,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
case delivery
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1831,6 +2025,8 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let status: AnyCodable?
|
||||
public let error: String?
|
||||
public let summary: String?
|
||||
public let sessionid: String?
|
||||
public let sessionkey: String?
|
||||
public let runatms: Int?
|
||||
public let durationms: Int?
|
||||
public let nextrunatms: Int?
|
||||
@@ -1842,6 +2038,8 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
status: AnyCodable?,
|
||||
error: String?,
|
||||
summary: String?,
|
||||
sessionid: String?,
|
||||
sessionkey: String?,
|
||||
runatms: Int?,
|
||||
durationms: Int?,
|
||||
nextrunatms: Int?
|
||||
@@ -1852,6 +2050,8 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.summary = summary
|
||||
self.sessionid = sessionid
|
||||
self.sessionkey = sessionkey
|
||||
self.runatms = runatms
|
||||
self.durationms = durationms
|
||||
self.nextrunatms = nextrunatms
|
||||
@@ -1863,6 +2063,8 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
case status
|
||||
case error
|
||||
case summary
|
||||
case sessionid = "sessionId"
|
||||
case sessionkey = "sessionKey"
|
||||
case runatms = "runAtMs"
|
||||
case durationms = "durationMs"
|
||||
case nextrunatms = "nextRunAtMs"
|
||||
|
||||
@@ -40,11 +40,11 @@ struct CronJobEditorSmokeTests {
|
||||
message: "Summarize the last day",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_100_000,
|
||||
runningAtMs: nil,
|
||||
|
||||
@@ -5,12 +5,24 @@ import Testing
|
||||
@Suite
|
||||
struct CronModelsTests {
|
||||
@Test func scheduleAtEncodesAndDecodes() throws {
|
||||
let schedule = CronSchedule.at(atMs: 123)
|
||||
let schedule = CronSchedule.at(at: "2026-02-03T18:00:00Z")
|
||||
let data = try JSONEncoder().encode(schedule)
|
||||
let decoded = try JSONDecoder().decode(CronSchedule.self, from: data)
|
||||
#expect(decoded == schedule)
|
||||
}
|
||||
|
||||
@Test func scheduleAtDecodesLegacyAtMs() throws {
|
||||
let json = """
|
||||
{"kind":"at","atMs":1700000000000}
|
||||
"""
|
||||
let decoded = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
|
||||
if case let .at(at) = decoded {
|
||||
#expect(at.hasPrefix("2023-"))
|
||||
} else {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func scheduleEveryEncodesAndDecodesWithAnchor() throws {
|
||||
let schedule = CronSchedule.every(everyMs: 5000, anchorMs: 10000)
|
||||
let data = try JSONEncoder().encode(schedule)
|
||||
@@ -49,11 +61,11 @@ struct CronModelsTests {
|
||||
deleteAfterRun: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 1_700_000_000_000),
|
||||
schedule: .at(at: "2026-02-03T18:00:00Z"),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "ping"),
|
||||
isolation: nil,
|
||||
delivery: nil,
|
||||
state: CronJobState())
|
||||
let data = try JSONEncoder().encode(job)
|
||||
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
|
||||
@@ -62,7 +74,7 @@ struct CronModelsTests {
|
||||
|
||||
@Test func scheduleDecodeRejectsUnknownKind() {
|
||||
let json = """
|
||||
{"kind":"wat","atMs":1}
|
||||
{"kind":"wat","at":"2026-02-03T18:00:00Z"}
|
||||
"""
|
||||
#expect(throws: DecodingError.self) {
|
||||
_ = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
|
||||
@@ -88,11 +100,11 @@ struct CronModelsTests {
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
schedule: .at(at: "2026-02-03T18:00:00Z"),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "hi"),
|
||||
isolation: nil,
|
||||
delivery: nil,
|
||||
state: CronJobState())
|
||||
#expect(base.displayName == "hello")
|
||||
|
||||
@@ -111,11 +123,11 @@ struct CronModelsTests {
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
schedule: .at(at: "2026-02-03T18:00:00Z"),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "hi"),
|
||||
isolation: nil,
|
||||
delivery: nil,
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_000_000,
|
||||
runningAtMs: nil,
|
||||
|
||||
@@ -23,7 +23,7 @@ struct MenuSessionsInjectorTests {
|
||||
let injector = MenuSessionsInjector()
|
||||
injector.setTestingControlChannelConnected(true)
|
||||
|
||||
let defaults = SessionDefaults(model: "anthropic/claude-opus-4-5", contextTokens: 200_000)
|
||||
let defaults = SessionDefaults(model: "anthropic/claude-opus-4-6", contextTokens: 200_000)
|
||||
let rows = [
|
||||
SessionRow(
|
||||
id: "main",
|
||||
@@ -41,7 +41,7 @@ struct MenuSessionsInjectorTests {
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000),
|
||||
model: "claude-opus-4-5"),
|
||||
model: "claude-opus-4-6"),
|
||||
SessionRow(
|
||||
id: "discord:group:alpha",
|
||||
key: "discord:group:alpha",
|
||||
@@ -58,7 +58,7 @@ struct MenuSessionsInjectorTests {
|
||||
systemSent: true,
|
||||
abortedLastRun: true,
|
||||
tokens: SessionTokenStats(input: 50, output: 50, total: 100, contextTokens: 200_000),
|
||||
model: "claude-opus-4-5"),
|
||||
model: "claude-opus-4-6"),
|
||||
]
|
||||
let snapshot = SessionStoreSnapshot(
|
||||
storePath: "/tmp/sessions.json",
|
||||
|
||||
@@ -23,7 +23,7 @@ struct SettingsViewSmokeTests {
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "ping"),
|
||||
isolation: nil,
|
||||
delivery: nil,
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_200_000,
|
||||
runningAtMs: nil,
|
||||
@@ -48,11 +48,11 @@ struct SettingsViewSmokeTests {
|
||||
message: "hello",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 30,
|
||||
deliver: true,
|
||||
channel: "sms",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "sms", to: "+15551234567", bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: nil,
|
||||
runningAtMs: nil,
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawCalendarCommand: String, Codable, Sendable {
|
||||
case events = "calendar.events"
|
||||
case add = "calendar.add"
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable {
|
||||
public var startISO: String?
|
||||
public var endISO: String?
|
||||
public var limit: Int?
|
||||
|
||||
public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable {
|
||||
public var title: String
|
||||
public var startISO: String
|
||||
public var endISO: String
|
||||
public var isAllDay: Bool?
|
||||
public var location: String?
|
||||
public var notes: String?
|
||||
public var calendarId: String?
|
||||
public var calendarTitle: String?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
startISO: String,
|
||||
endISO: String,
|
||||
isAllDay: Bool? = nil,
|
||||
location: String? = nil,
|
||||
notes: String? = nil,
|
||||
calendarId: String? = nil,
|
||||
calendarTitle: String? = nil)
|
||||
{
|
||||
self.title = title
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.isAllDay = isAllDay
|
||||
self.location = location
|
||||
self.notes = notes
|
||||
self.calendarId = calendarId
|
||||
self.calendarTitle = calendarTitle
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarEventPayload: Codable, Sendable, Equatable {
|
||||
public var identifier: String
|
||||
public var title: String
|
||||
public var startISO: String
|
||||
public var endISO: String
|
||||
public var isAllDay: Bool
|
||||
public var location: String?
|
||||
public var calendarTitle: String?
|
||||
|
||||
public init(
|
||||
identifier: String,
|
||||
title: String,
|
||||
startISO: String,
|
||||
endISO: String,
|
||||
isAllDay: Bool,
|
||||
location: String? = nil,
|
||||
calendarTitle: String? = nil)
|
||||
{
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.isAllDay = isAllDay
|
||||
self.location = location
|
||||
self.calendarTitle = calendarTitle
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarEventsPayload: Codable, Sendable, Equatable {
|
||||
public var events: [OpenClawCalendarEventPayload]
|
||||
|
||||
public init(events: [OpenClawCalendarEventPayload]) {
|
||||
self.events = events
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarAddPayload: Codable, Sendable, Equatable {
|
||||
public var event: OpenClawCalendarEventPayload
|
||||
|
||||
public init(event: OpenClawCalendarEventPayload) {
|
||||
self.event = event
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,4 @@ public enum OpenClawCapability: String, Codable, Sendable {
|
||||
case screen
|
||||
case voiceWake
|
||||
case location
|
||||
case device
|
||||
case photos
|
||||
case contacts
|
||||
case calendar
|
||||
case reminders
|
||||
case motion
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawChatCommand: String, Codable, Sendable {
|
||||
case push = "chat.push"
|
||||
}
|
||||
|
||||
public struct OpenClawChatPushParams: Codable, Sendable, Equatable {
|
||||
public var text: String
|
||||
public var speak: Bool?
|
||||
|
||||
public init(text: String, speak: Bool? = nil) {
|
||||
self.text = text
|
||||
self.speak = speak
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatPushPayload: Codable, Sendable, Equatable {
|
||||
public var messageId: String?
|
||||
|
||||
public init(messageId: String? = nil) {
|
||||
self.messageId = messageId
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawContactsCommand: String, Codable, Sendable {
|
||||
case search = "contacts.search"
|
||||
case add = "contacts.add"
|
||||
}
|
||||
|
||||
public struct OpenClawContactsSearchParams: Codable, Sendable, Equatable {
|
||||
public var query: String?
|
||||
public var limit: Int?
|
||||
|
||||
public init(query: String? = nil, limit: Int? = nil) {
|
||||
self.query = query
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawContactsAddParams: Codable, Sendable, Equatable {
|
||||
public var givenName: String?
|
||||
public var familyName: String?
|
||||
public var organizationName: String?
|
||||
public var displayName: String?
|
||||
public var phoneNumbers: [String]?
|
||||
public var emails: [String]?
|
||||
|
||||
public init(
|
||||
givenName: String? = nil,
|
||||
familyName: String? = nil,
|
||||
organizationName: String? = nil,
|
||||
displayName: String? = nil,
|
||||
phoneNumbers: [String]? = nil,
|
||||
emails: [String]? = nil)
|
||||
{
|
||||
self.givenName = givenName
|
||||
self.familyName = familyName
|
||||
self.organizationName = organizationName
|
||||
self.displayName = displayName
|
||||
self.phoneNumbers = phoneNumbers
|
||||
self.emails = emails
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawContactPayload: Codable, Sendable, Equatable {
|
||||
public var identifier: String
|
||||
public var displayName: String
|
||||
public var givenName: String
|
||||
public var familyName: String
|
||||
public var organizationName: String
|
||||
public var phoneNumbers: [String]
|
||||
public var emails: [String]
|
||||
|
||||
public init(
|
||||
identifier: String,
|
||||
displayName: String,
|
||||
givenName: String,
|
||||
familyName: String,
|
||||
organizationName: String,
|
||||
phoneNumbers: [String],
|
||||
emails: [String])
|
||||
{
|
||||
self.identifier = identifier
|
||||
self.displayName = displayName
|
||||
self.givenName = givenName
|
||||
self.familyName = familyName
|
||||
self.organizationName = organizationName
|
||||
self.phoneNumbers = phoneNumbers
|
||||
self.emails = emails
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawContactsSearchPayload: Codable, Sendable, Equatable {
|
||||
public var contacts: [OpenClawContactPayload]
|
||||
|
||||
public init(contacts: [OpenClawContactPayload]) {
|
||||
self.contacts = contacts
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawContactsAddPayload: Codable, Sendable, Equatable {
|
||||
public var contact: OpenClawContactPayload
|
||||
|
||||
public init(contact: OpenClawContactPayload) {
|
||||
self.contact = contact
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawDeviceCommand: String, Codable, Sendable {
|
||||
case status = "device.status"
|
||||
case info = "device.info"
|
||||
}
|
||||
|
||||
public enum OpenClawBatteryState: String, Codable, Sendable {
|
||||
case unknown
|
||||
case unplugged
|
||||
case charging
|
||||
case full
|
||||
}
|
||||
|
||||
public enum OpenClawThermalState: String, Codable, Sendable {
|
||||
case nominal
|
||||
case fair
|
||||
case serious
|
||||
case critical
|
||||
}
|
||||
|
||||
public enum OpenClawNetworkPathStatus: String, Codable, Sendable {
|
||||
case satisfied
|
||||
case unsatisfied
|
||||
case requiresConnection
|
||||
}
|
||||
|
||||
public enum OpenClawNetworkInterfaceType: String, Codable, Sendable {
|
||||
case wifi
|
||||
case cellular
|
||||
case wired
|
||||
case other
|
||||
}
|
||||
|
||||
public struct OpenClawBatteryStatusPayload: Codable, Sendable, Equatable {
|
||||
public var level: Double?
|
||||
public var state: OpenClawBatteryState
|
||||
public var lowPowerModeEnabled: Bool
|
||||
|
||||
public init(level: Double?, state: OpenClawBatteryState, lowPowerModeEnabled: Bool) {
|
||||
self.level = level
|
||||
self.state = state
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawThermalStatusPayload: Codable, Sendable, Equatable {
|
||||
public var state: OpenClawThermalState
|
||||
|
||||
public init(state: OpenClawThermalState) {
|
||||
self.state = state
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawStorageStatusPayload: Codable, Sendable, Equatable {
|
||||
public var totalBytes: Int64
|
||||
public var freeBytes: Int64
|
||||
public var usedBytes: Int64
|
||||
|
||||
public init(totalBytes: Int64, freeBytes: Int64, usedBytes: Int64) {
|
||||
self.totalBytes = totalBytes
|
||||
self.freeBytes = freeBytes
|
||||
self.usedBytes = usedBytes
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawNetworkStatusPayload: Codable, Sendable, Equatable {
|
||||
public var status: OpenClawNetworkPathStatus
|
||||
public var isExpensive: Bool
|
||||
public var isConstrained: Bool
|
||||
public var interfaces: [OpenClawNetworkInterfaceType]
|
||||
|
||||
public init(
|
||||
status: OpenClawNetworkPathStatus,
|
||||
isExpensive: Bool,
|
||||
isConstrained: Bool,
|
||||
interfaces: [OpenClawNetworkInterfaceType])
|
||||
{
|
||||
self.status = status
|
||||
self.isExpensive = isExpensive
|
||||
self.isConstrained = isConstrained
|
||||
self.interfaces = interfaces
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawDeviceStatusPayload: Codable, Sendable, Equatable {
|
||||
public var battery: OpenClawBatteryStatusPayload
|
||||
public var thermal: OpenClawThermalStatusPayload
|
||||
public var storage: OpenClawStorageStatusPayload
|
||||
public var network: OpenClawNetworkStatusPayload
|
||||
public var uptimeSeconds: Double
|
||||
|
||||
public init(
|
||||
battery: OpenClawBatteryStatusPayload,
|
||||
thermal: OpenClawThermalStatusPayload,
|
||||
storage: OpenClawStorageStatusPayload,
|
||||
network: OpenClawNetworkStatusPayload,
|
||||
uptimeSeconds: Double)
|
||||
{
|
||||
self.battery = battery
|
||||
self.thermal = thermal
|
||||
self.storage = storage
|
||||
self.network = network
|
||||
self.uptimeSeconds = uptimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawDeviceInfoPayload: Codable, Sendable, Equatable {
|
||||
public var deviceName: String
|
||||
public var modelIdentifier: String
|
||||
public var systemName: String
|
||||
public var systemVersion: String
|
||||
public var appVersion: String
|
||||
public var appBuild: String
|
||||
public var locale: String
|
||||
|
||||
public init(
|
||||
deviceName: String,
|
||||
modelIdentifier: String,
|
||||
systemName: String,
|
||||
systemVersion: String,
|
||||
appVersion: String,
|
||||
appBuild: String,
|
||||
locale: String)
|
||||
{
|
||||
self.deviceName = deviceName
|
||||
self.modelIdentifier = modelIdentifier
|
||||
self.systemName = systemName
|
||||
self.systemVersion = systemVersion
|
||||
self.appVersion = appVersion
|
||||
self.appBuild = appBuild
|
||||
self.locale = locale
|
||||
}
|
||||
}
|
||||
@@ -110,13 +110,7 @@ private enum ConnectChallengeError: Error {
|
||||
|
||||
public actor GatewayChannelActor {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
|
||||
#if DEBUG
|
||||
private var debugEventLogCount = 0
|
||||
private var debugMessageLogCount = 0
|
||||
private var debugListenLogCount = 0
|
||||
#endif
|
||||
private var task: WebSocketTaskBox?
|
||||
private var listenTask: Task<Void, Never>?
|
||||
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
|
||||
private var connected = false
|
||||
private var isConnecting = false
|
||||
@@ -175,9 +169,6 @@ public actor GatewayChannelActor {
|
||||
self.tickTask?.cancel()
|
||||
self.tickTask = nil
|
||||
|
||||
self.listenTask?.cancel()
|
||||
self.listenTask = nil
|
||||
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = nil
|
||||
|
||||
@@ -230,8 +221,6 @@ public actor GatewayChannelActor {
|
||||
self.isConnecting = true
|
||||
defer { self.isConnecting = false }
|
||||
|
||||
self.listenTask?.cancel()
|
||||
self.listenTask = nil
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = self.session.makeWebSocketTask(url: self.url)
|
||||
self.task?.resume()
|
||||
@@ -259,7 +248,6 @@ public actor GatewayChannelActor {
|
||||
throw wrapped
|
||||
}
|
||||
self.listen()
|
||||
self.logger.info("gateway ws listen registered")
|
||||
self.connected = true
|
||||
self.backoffMs = 500
|
||||
self.lastSeq = nil
|
||||
@@ -428,48 +416,30 @@ public actor GatewayChannelActor {
|
||||
guard let self else { return }
|
||||
await self.watchTicks()
|
||||
}
|
||||
await self.pushHandler?(.snapshot(ok))
|
||||
if let pushHandler = self.pushHandler {
|
||||
Task { await pushHandler(.snapshot(ok)) }
|
||||
}
|
||||
}
|
||||
|
||||
private func listen() {
|
||||
#if DEBUG
|
||||
if self.debugListenLogCount < 3 {
|
||||
self.debugListenLogCount += 1
|
||||
self.logger.info("gateway ws listen start")
|
||||
}
|
||||
#endif
|
||||
self.listenTask?.cancel()
|
||||
self.listenTask = Task { [weak self] in
|
||||
self.task?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
defer { Task { await self.clearListenTask() } }
|
||||
while !Task.isCancelled {
|
||||
guard let task = await self.currentTask() else { return }
|
||||
do {
|
||||
let msg = try await task.receive()
|
||||
switch result {
|
||||
case let .failure(err):
|
||||
Task { await self.handleReceiveFailure(err) }
|
||||
case let .success(msg):
|
||||
Task {
|
||||
await self.handle(msg)
|
||||
} catch {
|
||||
if Task.isCancelled { return }
|
||||
await self.handleReceiveFailure(error)
|
||||
return
|
||||
await self.listen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearListenTask() {
|
||||
self.listenTask = nil
|
||||
}
|
||||
|
||||
private func currentTask() -> WebSocketTaskBox? {
|
||||
self.task
|
||||
}
|
||||
|
||||
private func handleReceiveFailure(_ err: Error) async {
|
||||
let wrapped = self.wrap(err, context: "gateway receive")
|
||||
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
self.connected = false
|
||||
self.listenTask?.cancel()
|
||||
self.listenTask = nil
|
||||
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
|
||||
await self.failPending(wrapped)
|
||||
await self.scheduleReconnect()
|
||||
@@ -481,13 +451,6 @@ public actor GatewayChannelActor {
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
#if DEBUG
|
||||
if self.debugMessageLogCount < 8 {
|
||||
self.debugMessageLogCount += 1
|
||||
let size = data?.count ?? 0
|
||||
self.logger.info("gateway ws message received size=\(size, privacy: .public)")
|
||||
}
|
||||
#endif
|
||||
guard let data else { return }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
|
||||
self.logger.error("gateway decode failed")
|
||||
@@ -501,13 +464,6 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
case let .event(evt):
|
||||
if evt.event == "connect.challenge" { return }
|
||||
#if DEBUG
|
||||
if self.debugEventLogCount < 12 {
|
||||
self.debugEventLogCount += 1
|
||||
self.logger.info(
|
||||
"gateway event received event=\(evt.event, privacy: .public) payload=\(evt.payload != nil, privacy: .public)")
|
||||
}
|
||||
#endif
|
||||
if let seq = evt.seq {
|
||||
if let last = lastSeq, seq > last + 1 {
|
||||
await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
|
||||
|
||||
@@ -16,6 +16,7 @@ public actor GatewayNodeSession {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private static let defaultInvokeTimeoutMs = 30_000
|
||||
private var channel: GatewayChannelActor?
|
||||
private var activeURL: URL?
|
||||
private var activeToken: String?
|
||||
@@ -35,8 +36,8 @@ public actor GatewayNodeSession {
|
||||
) async -> BridgeInvokeResponse {
|
||||
let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||
let timeout: Int = {
|
||||
guard let timeoutMs else { return 0 }
|
||||
return max(0, timeoutMs)
|
||||
if let timeoutMs { return max(0, timeoutMs) }
|
||||
return Self.defaultInvokeTimeoutMs
|
||||
}()
|
||||
guard timeout > 0 else {
|
||||
return await onInvoke(request)
|
||||
@@ -153,10 +154,8 @@ public actor GatewayNodeSession {
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
let snapshotReady = await self.waitForSnapshot(timeoutMs: 500)
|
||||
if snapshotReady {
|
||||
await self.notifyConnectedIfNeeded()
|
||||
}
|
||||
_ = await self.waitForSnapshot(timeoutMs: 500)
|
||||
await self.notifyConnectedIfNeeded()
|
||||
} catch {
|
||||
await onDisconnected(error.localizedDescription)
|
||||
throw error
|
||||
|
||||
@@ -73,11 +73,6 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
if let expected {
|
||||
if fingerprint == expected {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else if params.allowTOFU {
|
||||
if let storeKey = params.storeKey {
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||
}
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawMotionCommand: String, Codable, Sendable {
|
||||
case activity = "motion.activity"
|
||||
case pedometer = "motion.pedometer"
|
||||
}
|
||||
|
||||
public struct OpenClawMotionActivityParams: Codable, Sendable, Equatable {
|
||||
public var startISO: String?
|
||||
public var endISO: String?
|
||||
public var limit: Int?
|
||||
|
||||
public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawMotionActivityEntry: Codable, Sendable, Equatable {
|
||||
public var startISO: String
|
||||
public var endISO: String
|
||||
public var confidence: String
|
||||
public var isWalking: Bool
|
||||
public var isRunning: Bool
|
||||
public var isCycling: Bool
|
||||
public var isAutomotive: Bool
|
||||
public var isStationary: Bool
|
||||
public var isUnknown: Bool
|
||||
|
||||
public init(
|
||||
startISO: String,
|
||||
endISO: String,
|
||||
confidence: String,
|
||||
isWalking: Bool,
|
||||
isRunning: Bool,
|
||||
isCycling: Bool,
|
||||
isAutomotive: Bool,
|
||||
isStationary: Bool,
|
||||
isUnknown: Bool)
|
||||
{
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.confidence = confidence
|
||||
self.isWalking = isWalking
|
||||
self.isRunning = isRunning
|
||||
self.isCycling = isCycling
|
||||
self.isAutomotive = isAutomotive
|
||||
self.isStationary = isStationary
|
||||
self.isUnknown = isUnknown
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawMotionActivityPayload: Codable, Sendable, Equatable {
|
||||
public var activities: [OpenClawMotionActivityEntry]
|
||||
|
||||
public init(activities: [OpenClawMotionActivityEntry]) {
|
||||
self.activities = activities
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawPedometerParams: Codable, Sendable, Equatable {
|
||||
public var startISO: String?
|
||||
public var endISO: String?
|
||||
|
||||
public init(startISO: String? = nil, endISO: String? = nil) {
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawPedometerPayload: Codable, Sendable, Equatable {
|
||||
public var startISO: String
|
||||
public var endISO: String
|
||||
public var steps: Int?
|
||||
public var distanceMeters: Double?
|
||||
public var floorsAscended: Int?
|
||||
public var floorsDescended: Int?
|
||||
|
||||
public init(
|
||||
startISO: String,
|
||||
endISO: String,
|
||||
steps: Int?,
|
||||
distanceMeters: Double?,
|
||||
floorsAscended: Int?,
|
||||
floorsDescended: Int?)
|
||||
{
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.steps = steps
|
||||
self.distanceMeters = distanceMeters
|
||||
self.floorsAscended = floorsAscended
|
||||
self.floorsDescended = floorsDescended
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawPhotosCommand: String, Codable, Sendable {
|
||||
case latest = "photos.latest"
|
||||
}
|
||||
|
||||
public struct OpenClawPhotosLatestParams: Codable, Sendable, Equatable {
|
||||
public var limit: Int?
|
||||
public var maxWidth: Int?
|
||||
public var quality: Double?
|
||||
|
||||
public init(limit: Int? = nil, maxWidth: Int? = nil, quality: Double? = nil) {
|
||||
self.limit = limit
|
||||
self.maxWidth = maxWidth
|
||||
self.quality = quality
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawPhotoPayload: Codable, Sendable, Equatable {
|
||||
public var format: String
|
||||
public var base64: String
|
||||
public var width: Int
|
||||
public var height: Int
|
||||
public var createdAt: String?
|
||||
|
||||
public init(format: String, base64: String, width: Int, height: Int, createdAt: String? = nil) {
|
||||
self.format = format
|
||||
self.base64 = base64
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawPhotosLatestPayload: Codable, Sendable, Equatable {
|
||||
public var photos: [OpenClawPhotoPayload]
|
||||
|
||||
public init(photos: [OpenClawPhotoPayload]) {
|
||||
self.photos = photos
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawRemindersCommand: String, Codable, Sendable {
|
||||
case list = "reminders.list"
|
||||
case add = "reminders.add"
|
||||
}
|
||||
|
||||
public enum OpenClawReminderStatusFilter: String, Codable, Sendable {
|
||||
case incomplete
|
||||
case completed
|
||||
case all
|
||||
}
|
||||
|
||||
public struct OpenClawRemindersListParams: Codable, Sendable, Equatable {
|
||||
public var status: OpenClawReminderStatusFilter?
|
||||
public var limit: Int?
|
||||
|
||||
public init(status: OpenClawReminderStatusFilter? = nil, limit: Int? = nil) {
|
||||
self.status = status
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawRemindersAddParams: Codable, Sendable, Equatable {
|
||||
public var title: String
|
||||
public var dueISO: String?
|
||||
public var notes: String?
|
||||
public var listId: String?
|
||||
public var listName: String?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
dueISO: String? = nil,
|
||||
notes: String? = nil,
|
||||
listId: String? = nil,
|
||||
listName: String? = nil)
|
||||
{
|
||||
self.title = title
|
||||
self.dueISO = dueISO
|
||||
self.notes = notes
|
||||
self.listId = listId
|
||||
self.listName = listName
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawReminderPayload: Codable, Sendable, Equatable {
|
||||
public var identifier: String
|
||||
public var title: String
|
||||
public var dueISO: String?
|
||||
public var completed: Bool
|
||||
public var listName: String?
|
||||
|
||||
public init(
|
||||
identifier: String,
|
||||
title: String,
|
||||
dueISO: String? = nil,
|
||||
completed: Bool,
|
||||
listName: String? = nil)
|
||||
{
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
self.dueISO = dueISO
|
||||
self.completed = completed
|
||||
self.listName = listName
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawRemindersListPayload: Codable, Sendable, Equatable {
|
||||
public var reminders: [OpenClawReminderPayload]
|
||||
|
||||
public init(reminders: [OpenClawReminderPayload]) {
|
||||
self.reminders = reminders
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawRemindersAddPayload: Codable, Sendable, Equatable {
|
||||
public var reminder: OpenClawReminderPayload
|
||||
|
||||
public init(reminder: OpenClawReminderPayload) {
|
||||
self.reminder = reminder
|
||||
}
|
||||
}
|
||||
@@ -123,10 +123,6 @@
|
||||
"screen_record": {
|
||||
"label": "screen record",
|
||||
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
|
||||
},
|
||||
"invoke": {
|
||||
"label": "invoke",
|
||||
"detailKeys": ["node", "nodeId", "invokeCommand"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawTalkCommand: String, Codable, Sendable {
|
||||
case pttStart = "talk.ptt.start"
|
||||
case pttStop = "talk.ptt.stop"
|
||||
case pttCancel = "talk.ptt.cancel"
|
||||
case pttOnce = "talk.ptt.once"
|
||||
}
|
||||
|
||||
public struct OpenClawTalkPTTStartPayload: Codable, Sendable, Equatable {
|
||||
public var captureId: String
|
||||
|
||||
public init(captureId: String) {
|
||||
self.captureId = captureId
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawTalkPTTStopPayload: Codable, Sendable, Equatable {
|
||||
public var captureId: String
|
||||
public var transcript: String?
|
||||
public var status: String
|
||||
|
||||
public init(captureId: String, transcript: String?, status: String) {
|
||||
self.captureId = captureId
|
||||
self.transcript = transcript
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
@@ -589,20 +589,24 @@ public struct AgentIdentityResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String?
|
||||
public let avatar: String?
|
||||
public let emoji: String?
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String?,
|
||||
avatar: String?
|
||||
avatar: String?,
|
||||
emoji: String?
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.avatar = avatar
|
||||
self.emoji = emoji
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case avatar
|
||||
case emoji
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsUsageParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let startdate: String?
|
||||
public let enddate: String?
|
||||
public let limit: Int?
|
||||
public let includecontextweight: Bool?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
startdate: String?,
|
||||
enddate: String?,
|
||||
limit: Int?,
|
||||
includecontextweight: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.startdate = startdate
|
||||
self.enddate = enddate
|
||||
self.limit = limit
|
||||
self.includecontextweight = includecontextweight
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case startdate = "startDate"
|
||||
case enddate = "endDate"
|
||||
case limit
|
||||
case includecontextweight = "includeContextWeight"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigGetParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
@@ -1556,6 +1589,157 @@ public struct AgentSummary: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFileEntry: Codable, Sendable {
|
||||
public let name: String
|
||||
public let path: String
|
||||
public let missing: Bool
|
||||
public let size: Int?
|
||||
public let updatedatms: Int?
|
||||
public let content: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
path: String,
|
||||
missing: Bool,
|
||||
size: Int?,
|
||||
updatedatms: Int?,
|
||||
content: String?
|
||||
) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.missing = missing
|
||||
self.size = size
|
||||
self.updatedatms = updatedatms
|
||||
self.content = content
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case path
|
||||
case missing
|
||||
case size
|
||||
case updatedatms = "updatedAtMs"
|
||||
case content
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesListParams: Codable, Sendable {
|
||||
public let agentid: String
|
||||
|
||||
public init(
|
||||
agentid: String
|
||||
) {
|
||||
self.agentid = agentid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesListResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let workspace: String
|
||||
public let files: [AgentsFileEntry]
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
workspace: String,
|
||||
files: [AgentsFileEntry]
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.workspace = workspace
|
||||
self.files = files
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case workspace
|
||||
case files
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesGetParams: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesGetResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let workspace: String
|
||||
public let file: AgentsFileEntry
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
workspace: String,
|
||||
file: AgentsFileEntry
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.workspace = workspace
|
||||
self.file = file
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case workspace
|
||||
case file
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesSetParams: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String
|
||||
public let content: String
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String,
|
||||
content: String
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.content = content
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case content
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFilesSetResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let agentid: String
|
||||
public let workspace: String
|
||||
public let file: AgentsFileEntry
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
agentid: String,
|
||||
workspace: String,
|
||||
file: AgentsFileEntry
|
||||
) {
|
||||
self.ok = ok
|
||||
self.agentid = agentid
|
||||
self.workspace = workspace
|
||||
self.file = file
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case agentid = "agentId"
|
||||
case workspace
|
||||
case file
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
@@ -1630,6 +1814,16 @@ public struct ModelsListResult: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct SkillsStatusParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
agentid: String?
|
||||
) {
|
||||
self.agentid = agentid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsBinsParams: Codable, Sendable {
|
||||
@@ -1707,7 +1901,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
public let delivery: [String: AnyCodable]?
|
||||
public let state: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -1723,7 +1917,7 @@ public struct CronJob: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?,
|
||||
delivery: [String: AnyCodable]?,
|
||||
state: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
@@ -1738,7 +1932,7 @@ public struct CronJob: Codable, Sendable {
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
self.delivery = delivery
|
||||
self.state = state
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -1754,7 +1948,7 @@ public struct CronJob: Codable, Sendable {
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
case delivery
|
||||
case state
|
||||
}
|
||||
}
|
||||
@@ -1785,7 +1979,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
public let delivery: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
@@ -1797,7 +1991,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?
|
||||
delivery: [String: AnyCodable]?
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
@@ -1808,7 +2002,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
self.delivery = delivery
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
@@ -1820,7 +2014,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
case delivery
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1831,6 +2025,8 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let status: AnyCodable?
|
||||
public let error: String?
|
||||
public let summary: String?
|
||||
public let sessionid: String?
|
||||
public let sessionkey: String?
|
||||
public let runatms: Int?
|
||||
public let durationms: Int?
|
||||
public let nextrunatms: Int?
|
||||
@@ -1842,6 +2038,8 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
status: AnyCodable?,
|
||||
error: String?,
|
||||
summary: String?,
|
||||
sessionid: String?,
|
||||
sessionkey: String?,
|
||||
runatms: Int?,
|
||||
durationms: Int?,
|
||||
nextrunatms: Int?
|
||||
@@ -1852,6 +2050,8 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.summary = summary
|
||||
self.sessionid = sessionid
|
||||
self.sessionkey = sessionkey
|
||||
self.runatms = runatms
|
||||
self.durationms = durationms
|
||||
self.nextrunatms = nextrunatms
|
||||
@@ -1863,6 +2063,8 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
case status
|
||||
case error
|
||||
case summary
|
||||
case sessionid = "sessionId"
|
||||
case sessionkey = "sessionKey"
|
||||
case runatms = "runAtMs"
|
||||
case durationms = "durationMs"
|
||||
case nextrunatms = "nextRunAtMs"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user