Compare commits

..

8 Commits

Author SHA1 Message Date
Mariano Belinky
8d4b640499 chore: sync remote PR branch history without force-push 2026-02-16 13:50:58 +00:00
Mariano Belinky
f83476364d plugin-sdk/device-pair: keep mainline exports and behavior 2026-02-16 13:48:37 +00:00
Mariano Belinky
551efb9fc9 CLI: add qr setup-code flow with --remote support 2026-02-16 13:47:18 +00:00
Mariano Belinky
5ef811771a CLI: prefer gateway.remote auth for qr --remote 2026-02-16 13:47:18 +00:00
Mariano Belinky
fa90b3c92b CLI: restore qr --remote 2026-02-16 13:47:18 +00:00
Mariano Belinky
6e8ed7af3a CLI: add qr setup-code flow with --remote support 2026-02-16 13:03:50 +00:00
Mariano Belinky
5ccabe9e63 CLI: prefer gateway.remote auth for qr --remote 2026-02-16 12:56:50 +00:00
Mariano Belinky
aab3c4d2f0 CLI: restore qr --remote 2026-02-16 12:55:08 +00:00
4448 changed files with 131203 additions and 379554 deletions

View File

@@ -0,0 +1,181 @@
# PR Workflow for Maintainers
Please read this in full and do not skip sections.
This is the single source of truth for the maintainer PR workflow.
## Triage order
Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain.
## Working rule
Skills execute workflow. Maintainers provide judgment.
Always pause between skills to evaluate technical direction, not just command success.
These three skills must be used in order:
1. `review-pr` — review only, produce findings
2. `prepare-pr` — rebase, fix, gate, push to PR head branch
3. `merge-pr` — squash-merge, verify MERGED state, clean up
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
Treat PRs as reports first, code second.
If submitted code is low quality, ignore it and implement the best solution for the problem.
Do not continue if you cannot verify the problem is real or test the fix.
## Coding Agent
Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary.
## PR quality bar
- Do not trust PR code by default.
- Do not merge changes you cannot validate with a reproducible problem and a tested fix.
- Keep types strict. Do not use `any` in implementation code.
- Keep external-input boundaries typed and validated, including CLI input, environment variables, network payloads, and tool output.
- Keep implementations properly scoped. Fix root causes, not local symptoms.
- Identify and reuse canonical sources of truth so behavior does not drift across the codebase.
- Harden changes. Always evaluate security impact and abuse paths.
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
## Rebase and conflict resolution
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates.
- If conflicts are complex or touch areas you do not understand, stop and escalate.
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
## Commit and changelog rules
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- During `prepare-pr`, use this commit subject format: `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`.
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
## Co-contributor and clawtributors
- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer.
- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too.
- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic.
- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report.
- When merging a PR from a new contributor: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
## Review mode vs landing mode
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this!
## Pre-review safety checks
- Before starting a review when a GH Issue/PR is pasted: use an isolated `.worktrees/pr-<PR>` checkout from `origin/main`. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
## Unified workflow
Entry criteria:
- PR URL/number is known.
- Problem statement is clear enough to attempt reproduction.
- A realistic verification path exists (tests, integration checks, or explicit manual validation).
### 1) `review-pr`
Purpose:
- Review only: correctness, value, security risk, tests, docs, and changelog impact.
- Produce structured findings and a recommendation.
Expected output:
- Recommendation: ready, needs work, needs discussion, or close.
- `.local/review.md` with actionable findings.
Maintainer checkpoint before `prepare-pr`:
```
What problem are they trying to solve?
What is the most optimal implementation?
Can we fix up everything?
Do we have any questions?
```
Stop and escalate instead of continuing if:
- The problem cannot be reproduced or confirmed.
- The proposed PR scope does not match the stated problem.
- The design introduces unresolved security or trust-boundary concerns.
### 2) `prepare-pr`
Purpose:
- Make the PR merge-ready on its head branch.
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`).
Expected output:
- Updated code and tests on the PR head branch.
- `.local/prep.md` with changes, verification, and current HEAD SHA.
- Final status: `PR is ready for /mergepr`.
Maintainer checkpoint before `merge-pr`:
```
Is this the most optimal implementation?
Is the code properly scoped?
Is the code properly reusing existing logic in the codebase?
Is the code properly typed?
Is the code hardened?
Do we have enough tests?
Do we need regression tests?
Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
Do not add performative tests, ensure tests are real and there are no regressions.
Do you see any follow-up refactors we should do?
Take your time, fix it properly, refactor if necessary.
Did any changes introduce any potential security vulnerabilities?
```
Stop and escalate instead of continuing if:
- You cannot verify behavior changes with meaningful tests or validation.
- Fixing findings requires broad architecture changes outside safe PR scope.
- Security hardening requirements remain unresolved.
### 3) `merge-pr`
Purpose:
- Merge only after review and prep artifacts are present and checks are green.
- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state.
- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation.
Go or no-go checklist before merge:
- All BLOCKER and IMPORTANT findings are resolved.
- Verification is meaningful and regression risk is acceptably low.
- Docs and changelog are updated when required.
- Required CI checks are green and the branch is not behind `main`.
Expected output:
- Successful merge commit and recorded merge SHA.
- Worktree cleanup after successful merge.
- Comment on PR indicating merge was successful.
Maintainer checkpoint after merge:
- Were any refactors intentionally deferred and now need follow-up issue(s)?
- Did this reveal broader architecture or test gaps we should address?
- Run `bun scripts/update-clawtributors.ts` if the contributor is new.

View File

@@ -0,0 +1,304 @@
---
name: merge-pr
description: Merge a GitHub PR via squash after /prepare-pr. 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 deterministic squash merge (`--match-head-commit` + explicit co-author trailer), then clean up the worktree after success.
## Inputs
- Ask for PR number or URL.
- If missing, use `.local/prep.env` from the worktree if present.
- 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 use `gh pr merge --auto` for maintainer landings.
- 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. Move to the repo root and retry.
- Read `.local/review.md`, `.local/prep.md`, and `.local/prep.env` in the worktree. Do not skip.
- Always merge with `--match-head-commit "$PREP_HEAD_SHA"` to prevent racing stale or changed heads.
- Clean up `.worktrees/pr-<PR>` only after confirmed `MERGED`.
## Completion Criteria
- Ensure `gh pr merge` succeeds.
- Ensure PR state is `MERGED`, never `CLOSED`.
- Record the merge SHA.
- Leave a PR comment with merge SHA and prepared head SHA, and capture the comment URL.
- 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
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
gh auth status
WORKTREE_DIR=".worktrees/pr-<PR>"
cd "$WORKTREE_DIR"
```
Run all commands inside the worktree directory.
## Load Local Artifacts (Mandatory)
Expect these files from earlier steps:
- `.local/review.md` from `/review-pr`
- `.local/prep.md` from `/prepare-pr`
- `.local/prep.env` from `/prepare-pr`
```sh
ls -la .local || true
for required in .local/review.md .local/prep.md .local/prep.env; do
if [ ! -f "$required" ]; then
echo "Missing $required. Stop and run /review-pr then /prepare-pr."
exit 1
fi
done
sed -n '1,120p' .local/review.md
sed -n '1,120p' .local/prep.md
source .local/prep.env
```
## Steps
1. Identify PR meta and verify prepared SHA still matches
```sh
pr_meta_json=$(gh pr view <PR> --json number,title,state,isDraft,author,headRefName,headRefOid,baseRefName,headRepository,body)
printf '%s\n' "$pr_meta_json" | jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,headSha:.headRefOid,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title)
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
pr_head_sha=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft)
if [ "$is_draft" = "true" ]; then
echo "ERROR: PR is draft. Stop and run /prepare-pr after draft is cleared."
exit 1
fi
if [ "$pr_head_sha" != "$PREP_HEAD_SHA" ]; then
echo "ERROR: PR head changed after /prepare-pr (expected $PREP_HEAD_SHA, got $pr_head_sha). Re-run /prepare-pr."
exit 1
fi
```
2. Run sanity checks
Stop if any are true:
- PR is a draft.
- Required checks are failing.
- Branch is behind main.
If checks are pending, wait for completion before merging. Do not use `--auto`.
If no required checks are configured, continue.
```sh
gh pr checks <PR> --required --watch --fail-fast || true
checks_json=$(gh pr checks <PR> --required --json name,bucket,state 2>/tmp/gh-checks.err || true)
if [ -z "$checks_json" ]; then
checks_json='[]'
fi
required_count=$(printf '%s\n' "$checks_json" | jq 'length')
if [ "$required_count" -eq 0 ]; then
echo "No required checks configured for this PR."
fi
printf '%s\n' "$checks_json" | jq -r '.[] | "\(.bucket)\t\(.name)\t\(.state)"'
failed_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="fail")] | length')
pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length')
if [ "$failed_required" -gt 0 ]; then
echo "Required checks are failing, run /prepare-pr."
exit 1
fi
if [ "$pending_required" -gt 0 ]; then
echo "Required checks are still pending, retry /merge-pr when green."
exit 1
fi
git fetch origin main
git fetch origin pull/<PR>/head:pr-<PR> --force
git merge-base --is-ancestor origin/main pr-<PR> || (echo "PR branch is behind main, run /prepare-pr" && exit 1)
```
If anything is failing or behind, stop and say to run `/prepare-pr`.
3. Merge PR with explicit attribution metadata
```sh
reviewer=$(gh api user --jq .login)
reviewer_id=$(gh api user --jq .id)
coauthor_email=${COAUTHOR_EMAIL:-"$contrib@users.noreply.github.com"}
if [ -z "$coauthor_email" ] || [ "$coauthor_email" = "null" ]; then
contrib_id=$(gh api users/$contrib --jq .id)
coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
fi
gh_email=$(gh api user --jq '.email // ""' || true)
git_email=$(git config user.email || true)
mapfile -t reviewer_email_candidates < <(
printf '%s\n' \
"$gh_email" \
"$git_email" \
"${reviewer_id}+${reviewer}@users.noreply.github.com" \
"${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++'
)
[ "${#reviewer_email_candidates[@]}" -gt 0 ] || { echo "ERROR: could not resolve reviewer author email"; exit 1; }
reviewer_email="${reviewer_email_candidates[0]}"
cat > .local/merge-body.txt <<EOF
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: $PREP_HEAD_SHA
Co-authored-by: $contrib <$coauthor_email>
Co-authored-by: $reviewer <$reviewer_email>
Reviewed-by: @$reviewer
EOF
run_merge() {
local email="$1"
local stderr_file
stderr_file=$(mktemp)
if gh pr merge <PR> \
--squash \
--delete-branch \
--match-head-commit "$PREP_HEAD_SHA" \
--author-email "$email" \
--subject "$pr_title (#$pr_number)" \
--body-file .local/merge-body.txt \
2> >(tee "$stderr_file" >&2)
then
rm -f "$stderr_file"
return 0
fi
merge_err=$(cat "$stderr_file")
rm -f "$stderr_file"
return 1
}
merge_err=""
selected_merge_author_email="$reviewer_email"
if ! run_merge "$selected_merge_author_email"; then
if printf '%s\n' "$merge_err" | rg -qi 'author.?email|email.*associated|associated.*email|invalid.*email' && [ "${#reviewer_email_candidates[@]}" -ge 2 ]; then
selected_merge_author_email="${reviewer_email_candidates[1]}"
echo "Retrying once with fallback author email: $selected_merge_author_email"
run_merge "$selected_merge_author_email" || { echo "ERROR: merge failed after fallback retry"; exit 1; }
else
echo "ERROR: merge failed"
exit 1
fi
fi
```
Retry is allowed exactly once when the error is clearly author-email validation.
4. Verify PR state and capture merge SHA
```sh
state=$(gh pr view <PR> --json state --jq .state)
if [ "$state" != "MERGED" ]; then
echo "Merge not finalized yet (state=$state), waiting up to 15 minutes..."
for _ in $(seq 1 90); do
sleep 10
state=$(gh pr view <PR> --json state --jq .state)
if [ "$state" = "MERGED" ]; then
break
fi
done
fi
if [ "$state" != "MERGED" ]; then
echo "ERROR: PR state is $state after waiting. Leave worktree and retry /merge-pr later."
exit 1
fi
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
if [ -z "$merge_sha" ] || [ "$merge_sha" = "null" ]; then
echo "ERROR: merge commit SHA missing."
exit 1
fi
commit_body=$(gh api repos/:owner/:repo/commits/$merge_sha --jq .commit.message)
contrib=${contrib:-$(gh pr view <PR> --json author --jq .author.login)}
reviewer=${reviewer:-$(gh api user --jq .login)}
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "ERROR: missing PR author co-author trailer"; exit 1; }
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "ERROR: missing reviewer co-author trailer"; exit 1; }
echo "merge_sha=$merge_sha"
```
5. PR comment
Use a multiline heredoc with interpolation enabled.
```sh
ok=0
comment_output=""
for _ in 1 2 3; do
if comment_output=$(gh pr comment <PR> -F - <<EOF
Merged via squash.
- Prepared head SHA: $PREP_HEAD_SHA
- Merge commit: $merge_sha
Thanks @$contrib!
EOF
); then
ok=1
break
fi
sleep 2
done
[ "$ok" -eq 1 ] || { echo "ERROR: failed to post PR comment after retries"; exit 1; }
comment_url=$(printf '%s\n' "$comment_output" | rg -o 'https://github.com/[^ ]+/pull/[0-9]+#issuecomment-[0-9]+' -m1 || true)
[ -n "$comment_url" ] || comment_url="unresolved"
echo "comment_url=$comment_url"
```
6. Clean up worktree only on success
Run cleanup only if step 4 returned `MERGED`.
```sh
cd "$repo_root"
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
git branch -D pr-<PR>-prep 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.

View 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."

View File

@@ -0,0 +1,336 @@
---
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 /review-pr. Never merge or push to main.
---
# Prepare PR
## Overview
Prepare a PR head branch for merge with review fixes, green gates, and deterministic merge handoff artifacts.
## Inputs
- Ask for PR number or URL.
- If missing, use `.local/pr-meta.env` from the PR worktree if present.
- If ambiguous, ask.
## Safety
- Never push to `main` or `origin/main`. Push only to the PR head branch.
- Never run `git push` without explicit remote and branch. 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 .`.
## Execution Rule
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs.
## Completion Criteria
- Rebase PR commits onto `origin/main`.
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
- Commit prep changes with required subject format.
- Run required gates and pass (`pnpm test` may be skipped only for high-confidence docs-only changes).
- Push the updated HEAD back to the PR head branch.
- Write `.local/prep.md` and `.local/prep.env`.
- 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
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
gh auth status
WORKTREE_DIR=".worktrees/pr-<PR>"
if [ ! -d "$WORKTREE_DIR" ]; then
git fetch origin main
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
fi
cd "$WORKTREE_DIR"
mkdir -p .local
```
Run all commands inside the worktree directory.
## Load Review Artifacts (Mandatory)
```sh
if [ ! -f .local/review.md ]; then
echo "Missing .local/review.md. Run /review-pr first and save findings."
exit 1
fi
if [ ! -f .local/pr-meta.env ]; then
echo "Missing .local/pr-meta.env. Run /review-pr first and save metadata."
exit 1
fi
sed -n '1,220p' .local/review.md
source .local/pr-meta.env
```
## Steps
1. Identify PR meta with one API call
```sh
pr_meta_json=$(gh pr view <PR> --json number,title,author,headRefName,headRefOid,baseRefName,headRepository,headRepositoryOwner,body)
printf '%s\n' "$pr_meta_json" | jq '{number,title,author:.author.login,head:.headRefName,headSha:.headRefOid,base:.baseRefName,headRepo:.headRepository.nameWithOwner,headRepoOwner:.headRepositoryOwner.login,headRepoName:.headRepository.name,body}'
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
head=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefName)
pr_head_sha_before=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
head_owner=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepositoryOwner.login // empty')
head_repo_name=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepository.name // empty')
head_repo_url=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepository.url // empty')
if [ -n "${PR_HEAD:-}" ] && [ "$head" != "$PR_HEAD" ]; then
echo "ERROR: PR head branch changed from $PR_HEAD to $head. Re-run /review-pr."
exit 1
fi
```
2. Fetch PR head and rebase on latest `origin/main`
```sh
git fetch origin pull/<PR>/head:pr-<PR> --force
git checkout -B pr-<PR>-prep pr-<PR>
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.
3. 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.
4. Optional quick feedback tests before full gates
Targeted tests are optional quick feedback, not a substitute for full gates.
If running targeted tests in a fresh worktree:
```sh
if [ ! -x node_modules/.bin/vitest ]; then
pnpm install --frozen-lockfile
fi
```
5. Commit prep fixes with required subject format
Use `scripts/committer` with explicit file paths.
Required subject format:
- `fix: <summary> (openclaw#<PR>) thanks @<author>`
```sh
commit_msg="fix: <summary> (openclaw#$pr_number) thanks @$contrib"
scripts/committer "$commit_msg" <changed file 1> <changed file 2> ...
```
If there are no local changes, do not create a no-op commit.
Post-commit validation (mandatory):
```sh
subject=$(git log -1 --pretty=%s)
echo "$subject" | rg -q "openclaw#$pr_number" || { echo "ERROR: commit subject missing openclaw#$pr_number"; exit 1; }
echo "$subject" | rg -q "thanks @$contrib" || { echo "ERROR: commit subject missing thanks @$contrib"; exit 1; }
```
6. Decide verification mode and run required gates before pushing
If you are highly confident the change is docs-only, you may skip `pnpm test`.
High-confidence docs-only criteria (all must be true):
- Every changed file is documentation-only (`docs/**`, `README*.md`, `CHANGELOG.md`, `*.md`, `*.mdx`, `mintlify.json`, `docs.json`).
- No code, runtime, test, dependency, or build config files changed (`src/**`, `extensions/**`, `apps/**`, `package.json`, lockfiles, TS/JS config, test files, scripts).
- `.local/review.md` does not call for non-doc behavior fixes.
Suggested check:
```sh
changed_files=$(git diff --name-only origin/main...HEAD)
non_docs=$(printf "%s\n" "$changed_files" | grep -Ev '^(docs/|README.*\.md$|CHANGELOG\.md$|.*\.md$|.*\.mdx$|mintlify\.json$|docs\.json$)' || true)
docs_only=false
if [ -n "$changed_files" ] && [ -z "$non_docs" ]; then
docs_only=true
fi
echo "docs_only=$docs_only"
```
Bootstrap dependencies in a fresh worktree before gates:
```sh
if [ ! -d node_modules ]; then
pnpm install --frozen-lockfile
fi
```
Run required gates:
```sh
pnpm build
pnpm check
if [ "$docs_only" = "true" ]; then
echo "Docs-only change detected with high confidence; skipping pnpm test." | tee -a .local/prep.md
else
pnpm test
fi
```
Require all required gates to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix-and-rerun cycles.
7. Push safely to the PR head branch
Build `prhead` from owner/name first, then validate remote branch SHA before push.
```sh
if [ -n "$head_owner" ] && [ -n "$head_repo_name" ]; then
head_repo_push_url="https://github.com/$head_owner/$head_repo_name.git"
elif [ -n "$head_repo_url" ] && [ "$head_repo_url" != "null" ]; then
case "$head_repo_url" in
*.git) head_repo_push_url="$head_repo_url" ;;
*) head_repo_push_url="$head_repo_url.git" ;;
esac
else
echo "ERROR: unable to determine PR head repo push URL"
exit 1
fi
git remote add prhead "$head_repo_push_url" 2>/dev/null || git remote set-url prhead "$head_repo_push_url"
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
remote_sha=$(git ls-remote prhead "refs/heads/$head" | awk '{print $1}')
if [ -z "$remote_sha" ]; then
echo "ERROR: remote branch refs/heads/$head not found on prhead"
exit 1
fi
if [ "$remote_sha" != "$pr_head_sha_before" ]; then
echo "ERROR: expected remote SHA $pr_head_sha_before, got $remote_sha. Re-fetch metadata and rebase first."
exit 1
fi
git push --force-with-lease=refs/heads/$head:$pr_head_sha_before prhead HEAD:$head || push_failed=1
```
If lease push fails because head moved, perform one automatic retry:
```sh
if [ "${push_failed:-0}" = "1" ]; then
echo "Lease push failed, retrying once with fresh PR head..."
pr_head_sha_before=$(gh pr view <PR> --json headRefOid --jq .headRefOid)
git fetch origin pull/<PR>/head:pr-<PR>-latest --force
git rebase pr-<PR>-latest
pnpm build
pnpm check
if [ "$docs_only" != "true" ]; then
pnpm test
fi
git push --force-with-lease=refs/heads/$head:$pr_head_sha_before prhead HEAD:$head
fi
```
8. Verify PR head and base relation (Mandatory)
```sh
prep_head_sha=$(git rev-parse HEAD)
pr_head_sha_after=$(gh pr view <PR> --json headRefOid --jq .headRefOid)
if [ "$prep_head_sha" != "$pr_head_sha_after" ]; then
echo "ERROR: pushed head SHA does not match PR head SHA."
exit 1
fi
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" && exit 1)
git branch -D pr-<PR>-verify 2>/dev/null || true
```
9. Write prep summary artifacts (Mandatory)
Write `.local/prep.md` and `.local/prep.env` for merge handoff.
```sh
contrib_id=$(gh api users/$contrib --jq .id)
coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
cat > .local/prep.env <<EOF_ENV
PR_NUMBER=$pr_number
PR_AUTHOR=$contrib
PR_HEAD=$head
PR_HEAD_SHA_BEFORE=$pr_head_sha_before
PREP_HEAD_SHA=$prep_head_sha
COAUTHOR_EMAIL=$coauthor_email
EOF_ENV
ls -la .local/prep.md .local/prep.env
wc -l .local/prep.md .local/prep.env
```
10. 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 required gates before pushing. `pnpm test` may be skipped only for high-confidence docs-only changes, and the skip must be explicitly recorded in `.local/prep.md`.

View 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."

View File

@@ -0,0 +1,253 @@
---
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 /prepare-pr.
## 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. Move to the repository root and retry.
- 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.
- Save PR metadata handoff to `.local/pr-meta.env` 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
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
gh auth status
WORKTREE_DIR=".worktrees/pr-<PR>"
git fetch origin main
# Reuse existing worktree if it exists, otherwise create new
if [ -d "$WORKTREE_DIR" ]; then
git worktree list
cd "$WORKTREE_DIR"
git fetch origin main
git checkout -B temp/pr-<PR> 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 /review-pr to /prepare-pr to /merge-pr
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
pr_meta_json=$(gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions,statusCheckRollup)
printf '%s\n' "$pr_meta_json" | jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headSha:.headRefOid,headRepo:.headRepository.nameWithOwner,additions,deletions,files:(.files|length),body}'
cat > .local/pr-meta.env <<EOF
PR_NUMBER=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
PR_URL=$(printf '%s\n' "$pr_meta_json" | jq -r .url)
PR_AUTHOR=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
PR_BASE=$(printf '%s\n' "$pr_meta_json" | jq -r .baseRefName)
PR_HEAD=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefName)
PR_HEAD_SHA=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
PR_HEAD_REPO=$(printf '%s\n' "$pr_meta_json" | jq -r .headRepository.nameWithOwner)
EOF
ls -la .local/pr-meta.env
```
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" || echo "Could not assign reviewer, continuing"
```
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> --force
mb=$(git merge-base origin/main pr-<PR>)
# Show only this PR patch relative to merge-base, not total branch drift
git diff --stat "$mb"..pr-<PR>
git diff "$mb"..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>
# git branch --show-current
# ...inspect files...
git checkout temp/pr-<PR>
git checkout -B temp/pr-<PR> origin/main
git branch --show-current
```
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.
If you run local tests in the worktree, bootstrap dependencies first:
```sh
if [ ! -x node_modules/.bin/vitest ]; then
pnpm install --frozen-lockfile
fi
```
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 /prepare-pr, only flag it here.
12. Answer the key question
Decide if /prepare-pr 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 /prepare-pr | 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.

View 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."

View File

@@ -1 +0,0 @@
Maintainer skills now live in [`openclaw/maintainers`](https://github.com/openclaw/maintainers/).

View File

@@ -0,0 +1,249 @@
# PR Workflow for Maintainers
Please read this in full and do not skip sections.
This is the single source of truth for the maintainer PR workflow.
## Triage order
Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain.
## Working rule
Skills execute workflow. Maintainers provide judgment.
Always pause between skills to evaluate technical direction, not just command success.
These three skills must be used in order:
1. `review-pr` — review only, produce findings
2. `prepare-pr` — rebase, fix, gate, push to PR head branch
3. `merge-pr` — squash-merge, verify MERGED state, clean up
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
Treat PRs as reports first, code second.
If submitted code is low quality, ignore it and implement the best solution for the problem.
Do not continue if you cannot verify the problem is real or test the fix.
## Script-first contract
Skill runs should invoke these wrappers automatically. You only need to run them manually when debugging or doing an explicit script-only run:
- `scripts/pr-review <PR>`
- `scripts/pr review-checkout-main <PR>` or `scripts/pr review-checkout-pr <PR>` while reviewing
- `scripts/pr review-guard <PR>` before writing review outputs
- `scripts/pr review-validate-artifacts <PR>` after writing outputs
- `scripts/pr-prepare init <PR>`
- `scripts/pr-prepare validate-commit <PR>`
- `scripts/pr-prepare gates <PR>`
- `scripts/pr-prepare push <PR>`
- Optional one-shot prepare: `scripts/pr-prepare run <PR>`
- `scripts/pr-merge <PR>` (verify-only; short form remains backward compatible)
- `scripts/pr-merge verify <PR>` (verify-only)
- Optional one-shot merge: `scripts/pr-merge run <PR>`
These wrappers run shared preflight checks and generate deterministic artifacts. They are designed to work from repo root or PR worktree cwd.
## Required artifacts
- `.local/pr-meta.json` and `.local/pr-meta.env` from review init.
- `.local/review.md` and `.local/review.json` from review output.
- `.local/prep-context.env` and `.local/prep.md` from prepare.
- `.local/prep.env` from prepare completion.
## Structured review handoff
`review-pr` must write `.local/review.json`.
In normal skill runs this is handled automatically. Use `scripts/pr review-artifacts-init <PR>` and `scripts/pr review-tests <PR> ...` manually only for debugging or explicit script-only runs.
Minimum schema:
```json
{
"recommendation": "READY FOR /prepare-pr",
"findings": [
{
"id": "F1",
"severity": "IMPORTANT",
"title": "Missing changelog entry",
"area": "CHANGELOG.md",
"fix": "Add a Fixes entry for PR #<PR>"
}
],
"tests": {
"ran": ["pnpm test -- ..."],
"gaps": ["..."],
"result": "pass"
}
}
```
`prepare-pr` resolves all `BLOCKER` and `IMPORTANT` findings from this file.
## Coding Agent
Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary.
## PR quality bar
- Do not trust PR code by default.
- Do not merge changes you cannot validate with a reproducible problem and a tested fix.
- Keep types strict. Do not use `any` in implementation code.
- Keep external-input boundaries typed and validated, including CLI input, environment variables, network payloads, and tool output.
- Keep implementations properly scoped. Fix root causes, not local symptoms.
- Identify and reuse canonical sources of truth so behavior does not drift across the codebase.
- Harden changes. Always evaluate security impact and abuse paths.
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
## Rebase and conflict resolution
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates.
- If conflicts are complex or touch areas you do not understand, stop and escalate.
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
## Commit and changelog rules
- In normal `prepare-pr` runs, commits are created via `scripts/committer "<msg>" <file...>`. Use it manually only when operating outside the skill flow; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- During `prepare-pr`, use concise, action-oriented subjects **without** PR numbers or thanks; reserve `(#<PR>) thanks @<pr-author>` for the final merge/squash commit.
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
- When working on a PR: add a changelog entry with the PR number and thank the contributor (mandatory in this workflow).
- When working on an issue: reference the issue in the changelog entry.
- In this workflow, changelog is always required even for internal/test-only changes.
## Gate policy
In fresh worktrees, dependency bootstrap is handled by wrappers before local gates. Manual equivalent:
```sh
pnpm install --frozen-lockfile
```
Gate set:
- Always: `pnpm build`, `pnpm check`
- `pnpm test` required unless high-confidence docs-only criteria pass.
## Co-contributor and clawtributors
- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer.
- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too.
- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic.
- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report.
- Manual post-merge step for new contributors: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
## Review mode vs landing mode
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this!
## Pre-review safety checks
- Before starting a review when a GH Issue/PR is pasted: `review-pr`/`scripts/pr-review` should create and use an isolated `.worktrees/pr-<PR>` checkout from `origin/main` automatically. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
## Unified workflow
Entry criteria:
- PR URL/number is known.
- Problem statement is clear enough to attempt reproduction.
- A realistic verification path exists (tests, integration checks, or explicit manual validation).
### 1) `review-pr`
Purpose:
- Review only: correctness, value, security risk, tests, docs, and changelog impact.
- Produce structured findings and a recommendation.
Expected output:
- Recommendation: ready, needs work, needs discussion, or close.
- `.local/review.md` with actionable findings.
Maintainer checkpoint before `prepare-pr`:
```
What problem are they trying to solve?
What is the most optimal implementation?
Can we fix up everything?
Do we have any questions?
```
Stop and escalate instead of continuing if:
- The problem cannot be reproduced or confirmed.
- The proposed PR scope does not match the stated problem.
- The design introduces unresolved security or trust-boundary concerns.
### 2) `prepare-pr`
Purpose:
- Make the PR merge-ready on its head branch.
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`).
Expected output:
- Updated code and tests on the PR head branch.
- `.local/prep.md` with changes, verification, and current HEAD SHA.
- Final status: `PR is ready for /merge-pr`.
Maintainer checkpoint before `merge-pr`:
```
Is this the most optimal implementation?
Is the code properly scoped?
Is the code properly reusing existing logic in the codebase?
Is the code properly typed?
Is the code hardened?
Do we have enough tests?
Do we need regression tests?
Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
Do not add performative tests, ensure tests are real and there are no regressions.
Do you see any follow-up refactors we should do?
Did any changes introduce any potential security vulnerabilities?
Take your time, fix it properly, refactor if necessary.
```
Stop and escalate instead of continuing if:
- You cannot verify behavior changes with meaningful tests or validation.
- Fixing findings requires broad architecture changes outside safe PR scope.
- Security hardening requirements remain unresolved.
### 3) `merge-pr`
Purpose:
- Merge only after review and prep artifacts are present and checks are green.
- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state.
- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation.
Go or no-go checklist before merge:
- All BLOCKER and IMPORTANT findings are resolved.
- Verification is meaningful and regression risk is acceptably low.
- Changelog is updated (mandatory) and docs are updated when required.
- Required CI checks are green and the branch is not behind `main`.
Expected output:
- Successful merge commit and recorded merge SHA.
- Worktree cleanup after successful merge.
- Comment on PR indicating merge was successful.
Maintainer checkpoint after merge:
- Were any refactors intentionally deferred and now need follow-up issue(s)?
- Did this reveal broader architecture or test gaps we should address?
- Run `bun scripts/update-clawtributors.ts` if the contributor is new.

View File

@@ -0,0 +1,99 @@
---
name: merge-pr
description: Script-first deterministic squash merge with strict required-check gating, head-SHA pinning, and reliable attribution/commenting.
---
# Merge PR
## Overview
Merge a prepared PR only after deterministic validation.
## Inputs
- Ask for PR number or URL.
- If missing, use `.local/prep.env` from the PR worktree.
## Safety
- Never use `gh pr merge --auto` in this flow.
- Never run `git push` directly.
- Require `--match-head-commit` during merge.
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
## Execution Contract
1. Validate merge readiness:
```sh
scripts/pr-merge verify <PR>
```
Backward-compatible verify form also works:
```sh
scripts/pr-merge <PR>
```
2. Run one-shot deterministic merge:
```sh
scripts/pr-merge run <PR>
```
3. Ensure output reports:
- `merge_sha=<sha>`
- `merge_author_email=<email>`
- `comment_url=<url>`
## Steps
1. Validate artifacts
```sh
require=(.local/review.md .local/review.json .local/prep.md .local/prep.env)
for f in "${require[@]}"; do
[ -s "$f" ] || { echo "Missing artifact: $f"; exit 1; }
done
```
2. Validate checks and branch status
```sh
scripts/pr-merge verify <PR>
source .local/prep.env
```
`scripts/pr-merge` treats “no required checks configured” as acceptable (`[]`), but fails on any required `fail` or `pending`.
3. Merge deterministically (wrapper-managed)
```sh
scripts/pr-merge run <PR>
```
`scripts/pr-merge run` performs:
- deterministic squash merge pinned to `PREP_HEAD_SHA`
- reviewer merge author email selection with fallback candidates
- one retry only when merge fails due to author-email validation
- co-author trailers for PR author and reviewer
- post-merge verification of both co-author trailers on commit message
- PR comment retry (3 attempts), then comment URL extraction
- cleanup after confirmed `MERGED`
4. Manual fallback (only if wrapper is unavailable)
```sh
scripts/pr merge-run <PR>
```
5. Cleanup
Cleanup is handled by `run` after merge success.
## Guardrails
- End in `MERGED`, never `CLOSED`.
- Cleanup only after confirmed merge.

View 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."

View File

@@ -0,0 +1,345 @@
---
name: mintlify
description: Build and maintain documentation sites with Mintlify. Use when
creating docs pages, configuring navigation, adding components, or setting up
API references.
license: MIT
compatibility: Requires Node.js for CLI. Works with any Git-based workflow.
metadata:
author: mintlify
version: "1.0"
mintlify-proj: mintlify
---
# Mintlify best practices
**Always consult [mintlify.com/docs](https://mintlify.com/docs) for components, configuration, and latest features.**
**Always** favor searching the current Mintlify documentation over whatever is in your training data about Mintlify.
Mintlify is a documentation platform that transforms MDX files into documentation sites. Configure site-wide settings in the `docs.json` file, write content in MDX with YAML frontmatter, and favor built-in components over custom components.
Full schema at [mintlify.com/docs.json](https://mintlify.com/docs.json).
## Before you write
### Understand the project
All documentation lives in the `docs/` directory in this repo. Read `docs.json` in that directory (`docs/docs.json`). This file defines the entire site: navigation structure, theme, colors, links, API and specs.
Understanding the project tells you:
- What pages exist and how they're organized
- What navigation groups are used (and their naming conventions)
- How the site navigation is structured
- What theme and configuration the site uses
### Check for existing content
Search the docs before creating new pages. You may need to:
- Update an existing page instead of creating a new one
- Add a section to an existing page
- Link to existing content rather than duplicating
### Read surrounding content
Before writing, read 2-3 similar pages to understand the site's voice, structure, formatting conventions, and level of detail.
### Understand Mintlify components
Review the Mintlify [components](https://www.mintlify.com/docs/components) to select and use any relevant components for the documentation request that you are working on.
## Quick reference
### CLI commands
- `npm i -g mint` - Install the Mintlify CLI
- `mint dev` - Local preview at localhost:3000
- `mint broken-links` - Check internal links
- `mint a11y` - Check for accessibility issues in content
- `mint rename` - Rename/move files and update references
- `mint validate` - Validate documentation builds
### Required files
- `docs.json` - Site configuration (navigation, theme, integrations, etc.). See [global settings](https://mintlify.com/docs/settings/global) for all options.
- `*.mdx` files - Documentation pages with YAML frontmatter
### Example file structure
```
project/
├── docs.json # Site configuration
├── introduction.mdx
├── quickstart.mdx
├── guides/
│ └── example.mdx
├── openapi.yml # API specification
├── images/ # Static assets
│ └── example.png
└── snippets/ # Reusable components
└── component.jsx
```
## Page frontmatter
Every page requires `title` in its frontmatter. Include `description` for SEO and navigation.
```yaml theme={null}
---
title: "Clear, descriptive title"
description: "Concise summary for SEO and navigation."
---
```
Optional frontmatter fields:
- `sidebarTitle`: Short title for sidebar navigation.
- `icon`: Lucide or Font Awesome icon name, URL, or file path.
- `tag`: Label next to the page title in the sidebar (for example, "NEW").
- `mode`: Page layout mode (`default`, `wide`, `custom`).
- `keywords`: Array of terms related to the page content for local search and SEO.
- Any custom YAML fields for use with personalization or conditional content.
## File conventions
- Match existing naming patterns in the directory
- If there are no existing files or inconsistent file naming patterns, use kebab-case: `getting-started.mdx`, `api-reference.mdx`
- Use root-relative paths without file extensions for internal links: `/getting-started/quickstart`
- Do not use relative paths (`../`) or absolute URLs for internal pages
- When you create a new page, add it to `docs.json` navigation or it won't appear in the sidebar
## Organize content
When a user asks about anything related to site-wide configurations, start by understanding the [global settings](https://www.mintlify.com/docs/organize/settings). See if a setting in the `docs.json` file can be updated to achieve what the user wants.
### Navigation
The `navigation` property in `docs.json` controls site structure. Choose one primary pattern at the root level, then nest others within it.
**Choose your primary pattern:**
| Pattern | When to use |
| ------------- | ---------------------------------------------------------------------------------------------- |
| **Groups** | Default. Single audience, straightforward hierarchy |
| **Tabs** | Distinct sections with different audiences (Guides vs API Reference) or content types |
| **Anchors** | Want persistent section links at sidebar top. Good for separating docs from external resources |
| **Dropdowns** | Multiple doc sections users switch between, but not distinct enough for tabs |
| **Products** | Multi-product company with separate documentation per product |
| **Versions** | Maintaining docs for multiple API/product versions simultaneously |
| **Languages** | Localized content |
**Within your primary pattern:**
- **Groups** - Organize related pages. Can nest groups within groups, but keep hierarchy shallow
- **Menus** - Add dropdown navigation within tabs for quick jumps to specific pages
- **`expanded: false`** - Collapse nested groups by default. Use for reference sections users browse selectively
- **`openapi`** - Auto-generate pages from OpenAPI spec. Add at group/tab level to inherit
**Common combinations:**
- Tabs containing groups (most common for docs with API reference)
- Products containing tabs (multi-product SaaS)
- Versions containing tabs (versioned API docs)
- Anchors containing groups (simple docs with external resource links)
### Links and paths
- **Internal links:** Root-relative, no extension: `/getting-started/quickstart`
- **Images:** Store in `/images`, reference as `/images/example.png`
- **External links:** Use full URLs, they open in new tabs automatically
## Customize docs sites
**What to customize where:**
- **Brand colors, fonts, logo** → `docs.json`. See [global settings](https://mintlify.com/docs/settings/global)
- **Component styling, layout tweaks** → `custom.css` at project root
- **Dark mode** → Enabled by default. Only disable with `"appearance": "light"` in `docs.json` if brand requires it
Start with `docs.json`. Only add `custom.css` when you need styling that config doesn't support.
## Write content
### Components
The [components overview](https://mintlify.com/docs/components) organizes all components by purpose: structure content, draw attention, show/hide content, document APIs, link to pages, and add visual context. Start there to find the right component.
**Common decision points:**
| Need | Use |
| -------------------------- | ----------------------- |
| Hide optional details | `<Accordion>` |
| Long code examples | `<Expandable>` |
| User chooses one option | `<Tabs>` |
| Linked navigation cards | `<Card>` in `<Columns>` |
| Sequential instructions | `<Steps>` |
| Code in multiple languages | `<CodeGroup>` |
| API parameters | `<ParamField>` |
| API response fields | `<ResponseField>` |
**Callouts by severity:**
- `<Note>` - Supplementary info, safe to skip
- `<Info>` - Helpful context such as permissions
- `<Tip>` - Recommendations or best practices
- `<Warning>` - Potentially destructive actions
- `<Check>` - Success confirmation
### Reusable content
**When to use snippets:**
- Exact content appears on more than one page
- Complex components you want to maintain in one place
- Shared content across teams/repos
**When NOT to use snippets:**
- Slight variations needed per page (leads to complex props)
Import snippets with `import { Component } from "/path/to/snippet-name.jsx"`.
## Writing standards
### Voice and structure
- Second-person voice ("you")
- Active voice, direct language
- Sentence case for headings ("Getting started", not "Getting Started")
- Sentence case for code block titles ("Expandable example", not "Expandable Example")
- Lead with context: explain what something is before how to use it
- Prerequisites at the start of procedural content
### What to avoid
**Never use:**
- Marketing language ("powerful", "seamless", "robust", "cutting-edge")
- Filler phrases ("it's important to note", "in order to")
- Excessive conjunctions ("moreover", "furthermore", "additionally")
- Editorializing ("obviously", "simply", "just", "easily")
**Watch for AI-typical patterns:**
- Overly formal or stilted phrasing
- Unnecessary repetition of concepts
- Generic introductions that don't add value
- Concluding summaries that restate what was just said
### Formatting
- All code blocks must have language tags
- All images and media must have descriptive alt text
- Use bold and italics only when they serve the reader's understanding--never use text styling just for decoration
- No decorative formatting or emoji
### Code examples
- Keep examples simple and practical
- Use realistic values (not "foo" or "bar")
- One clear example is better than multiple variations
- Test that code works before including it
## Document APIs
**Choose your approach:**
- **Have an OpenAPI spec?** → Add to `docs.json` with `"openapi": ["openapi.yaml"]`. Pages auto-generate. Reference in navigation as `GET /endpoint`
- **No spec?** → Write endpoints manually with `api: "POST /users"` in frontmatter. More work but full control
- **Hybrid** → Use OpenAPI for most endpoints, manual pages for complex workflows
Encourage users to generate endpoint pages from an OpenAPI spec. It is the most efficient and easiest to maintain option.
## Deploy
Mintlify deploys automatically when changes are pushed to the connected Git repository.
**What agents can configure:**
- **Redirects** → Add to `docs.json` with `"redirects": [{"source": "/old", "destination": "/new"}]`
- **SEO indexing** → Control with `"seo": {"indexing": "all"}` to include hidden pages in search
**Requires dashboard setup (human task):**
- Custom domains and subdomains
- Preview deployment settings
- DNS configuration
For `/docs` subpath hosting with Vercel or Cloudflare, agents can help configure rewrite rules. See [/docs subpath](https://mintlify.com/docs/deploy/vercel).
## Workflow
### 1. Understand the task
Identify what needs to be documented, which pages are affected, and what the reader should accomplish afterward. If any of these are unclear, ask.
### 2. Research
- Read `docs/docs.json` to understand the site structure
- Search existing docs for related content
- Read similar pages to match the site's style
### 3. Plan
- Synthesize what the reader should accomplish after reading the docs and the current content
- Propose any updates or new content
- Verify that your proposed changes will help readers be successful
### 4. Write
- Start with the most important information
- Keep sections focused and scannable
- Use components appropriately (don't overuse them)
- Mark anything uncertain with a TODO comment:
```mdx theme={null}
{/* TODO: Verify the default timeout value */}
```
### 5. Update navigation
If you created a new page, add it to the appropriate group in `docs.json`.
### 6. Verify
Before submitting:
- [ ] Frontmatter includes title and description
- [ ] All code blocks have language tags
- [ ] Internal links use root-relative paths without file extensions
- [ ] New pages are added to `docs.json` navigation
- [ ] Content matches the style of surrounding pages
- [ ] No marketing language or filler phrases
- [ ] TODOs are clearly marked for anything uncertain
- [ ] Run `mint broken-links` to check links
- [ ] Run `mint validate` to find any errors
## Edge cases
### Migrations
If a user asks about migrating to Mintlify, ask if they are using ReadMe or Docusaurus. If they are, use the [@mintlify/scraping](https://www.npmjs.com/package/@mintlify/scraping) CLI to migrate content. If they are using a different platform to host their documentation, help them manually convert their content to MDX pages using Mintlify components.
### Hidden pages
Any page that is not included in the `docs.json` navigation is hidden. Use hidden pages for content that should be accessible by URL or indexed for the assistant or search, but not discoverable through the sidebar navigation.
### Exclude pages
The `.mintignore` file is used to exclude files from a documentation repository from being processed.
## Common gotchas
1. **Component imports** - JSX components need explicit import, MDX components don't
2. **Frontmatter required** - Every MDX file needs `title` at minimum
3. **Code block language** - Always specify language identifier
4. **Never use `mint.json`** - `mint.json` is deprecated. Only ever use `docs.json`
## Resources
- [Documentation](https://mintlify.com/docs)
- [Configuration schema](https://mintlify.com/docs.json)
- [Feature requests](https://github.com/orgs/mintlify/discussions/categories/feature-requests)
- [Bugs and feedback](https://github.com/orgs/mintlify/discussions/categories/bugs-feedback)

View File

@@ -0,0 +1,122 @@
---
name: prepare-pr
description: Script-first PR preparation with structured findings resolution, deterministic push safety, and explicit gate execution.
---
# Prepare PR
## Overview
Prepare the PR head branch for merge after `/review-pr`.
## Inputs
- Ask for PR number or URL.
- If missing, use `.local/pr-meta.env` if present in the PR worktree.
## Safety
- Never push to `main`.
- Only push to PR head with explicit `--force-with-lease` against known head SHA.
- Do not run `git clean -fdx`.
- Wrappers are cwd-agnostic; run from repo root or PR worktree.
## Execution Contract
1. Run setup:
```sh
scripts/pr-prepare init <PR>
```
2. Resolve findings from structured review:
- `.local/review.json` is mandatory.
- Resolve all `BLOCKER` and `IMPORTANT` items.
3. Commit scoped changes with concise subjects (no PR number/thanks; those belong on the final merge/squash commit).
4. Run gates via wrapper.
5. Push via wrapper (includes pre-push remote verification, one automatic lease-retry path, and post-push API propagation retry).
Optional one-shot path:
```sh
scripts/pr-prepare run <PR>
```
## Steps
1. Setup and artifacts
```sh
scripts/pr-prepare init <PR>
ls -la .local/review.md .local/review.json .local/pr-meta.env .local/prep-context.env
jq . .local/review.json >/dev/null
```
2. Resolve required findings
List required items:
```sh
jq -r '.findings[] | select(.severity=="BLOCKER" or .severity=="IMPORTANT") | "- [\(.severity)] \(.id): \(.title) => \(.fix)"' .local/review.json
```
Fix all required findings. Keep scope tight.
3. Update changelog/docs (changelog is mandatory in this workflow)
```sh
jq -r '.changelog' .local/review.json
jq -r '.docs' .local/review.json
```
4. Commit scoped changes
Use concise, action-oriented subject lines without PR numbers/thanks. The final merge/squash commit is the only place we include PR numbers and contributor thanks.
Use explicit file list:
```sh
scripts/committer "fix: <summary>" <file1> <file2> ...
```
5. Run gates
```sh
scripts/pr-prepare gates <PR>
```
6. Push safely to PR head
```sh
scripts/pr-prepare push <PR>
```
This push step includes:
- robust fork remote resolution from owner/name,
- pre-push remote SHA verification,
- one automatic rebase + gate rerun + retry if lease push fails,
- post-push PR-head propagation retry,
- idempotent behavior when local prep HEAD is already on the PR head,
- post-push SHA verification and `.local/prep.env` generation.
7. Verify handoff artifacts
```sh
ls -la .local/prep.md .local/prep.env
```
8. Output
- Summarize resolved findings and gate results.
- Print exactly: `PR is ready for /merge-pr`.
## Guardrails
- Do not run `gh pr merge` in this skill.
- Do not delete worktree.

View 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."

View File

@@ -0,0 +1,142 @@
---
name: review-pr
description: Script-first review-only GitHub pull request analysis. Use for deterministic PR review with structured findings handoff to /prepare-pr.
---
# Review PR
## Overview
Perform a read-only review and produce both human and machine-readable outputs.
## Inputs
- Ask for PR number or URL.
- If missing, always ask.
## Safety
- Never push, merge, or modify code intended to keep.
- Work only in `.worktrees/pr-<PR>`.
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
## Execution Contract
1. Run wrapper setup:
```sh
scripts/pr-review <PR>
```
2. Use explicit branch mode switches:
- Main baseline mode: `scripts/pr review-checkout-main <PR>`
- PR-head mode: `scripts/pr review-checkout-pr <PR>`
3. Before writing review outputs, run branch guard:
```sh
scripts/pr review-guard <PR>
```
4. Write both outputs:
- `.local/review.md` with sections A through J.
- `.local/review.json` with structured findings.
5. Validate artifacts semantically:
```sh
scripts/pr review-validate-artifacts <PR>
```
## Steps
1. Setup and metadata
```sh
scripts/pr-review <PR>
ls -la .local/pr-meta.json .local/pr-meta.env .local/review-context.env .local/review-mode.env
```
2. Existing implementation check on main
```sh
scripts/pr review-checkout-main <PR>
rg -n "<keyword>" -S src extensions apps || true
git log --oneline --all --grep "<keyword>" | head -20
```
3. Claim PR
```sh
gh_user=$(gh api user --jq .login)
gh pr edit <PR> --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing"
```
4. Read PR description and diff
```sh
scripts/pr review-checkout-pr <PR>
gh pr diff <PR>
source .local/review-context.env
git diff --stat "$MERGE_BASE"..pr-<PR>
git diff "$MERGE_BASE"..pr-<PR>
```
5. Optional local tests
Use the wrapper for target validation and executed-test verification:
```sh
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
```
6. Initialize review artifact templates
```sh
scripts/pr review-artifacts-init <PR>
```
7. Produce review outputs
- Fill `.local/review.md` sections A through J.
- Fill `.local/review.json`.
Minimum JSON shape:
```json
{
"recommendation": "READY FOR /prepare-pr",
"findings": [
{
"id": "F1",
"severity": "IMPORTANT",
"title": "...",
"area": "path/or/component",
"fix": "Actionable fix"
}
],
"tests": {
"ran": [],
"gaps": [],
"result": "pass"
},
"docs": "up_to_date|missing|not_applicable",
"changelog": "required"
}
```
8. Guard + validate before final output
```sh
scripts/pr review-guard <PR>
scripts/pr review-validate-artifacts <PR>
```
## Guardrails
- Keep review read-only.
- Do not delete worktree.
- Use merge-base scoped diff for local context to avoid stale branch drift.

View 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."

View File

@@ -37,16 +37,6 @@ OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token
# ANTHROPIC_API_KEY=sk-ant-...
# GEMINI_API_KEY=...
# OPENROUTER_API_KEY=sk-or-...
# OPENCLAW_LIVE_OPENAI_KEY=sk-...
# OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-...
# OPENCLAW_LIVE_GEMINI_KEY=...
# OPENAI_API_KEY_1=...
# ANTHROPIC_API_KEY_1=...
# GEMINI_API_KEY_1=...
# GOOGLE_API_KEY=...
# OPENAI_API_KEYS=sk-1,sk-2
# ANTHROPIC_API_KEYS=sk-ant-1,sk-ant-2
# GEMINI_API_KEYS=key-1,key-2
# Optional additional providers
# ZAI_API_KEY=...

2
.gitattributes vendored
View File

@@ -1,3 +1 @@
* text=auto eol=lf
CLAUDE.md -text
src/gateway/server-methods/CLAUDE.md -text

View File

@@ -13,7 +13,7 @@ body:
attributes:
label: Summary
description: One-sentence statement of what is broken.
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
placeholder: After upgrading to 2026.2.13, Telegram thread replies fail with "reply target not found".
validations:
required: true
- type: textarea
@@ -48,7 +48,7 @@ body:
attributes:
label: OpenClaw version
description: Exact version/build tested.
placeholder: <version such as 2026.2.17>
placeholder: 2026.2.13
validations:
required: true
- type: input
@@ -83,7 +83,7 @@ body:
- Frequency (always/intermittent/edge case)
- Consequence (missed messages, failed onboarding, extra cost, etc.)
placeholder: |
Affected: Telegram group users on <version>
Affected: Telegram group users on 2026.2.13
Severity: High (blocks replies)
Frequency: 100% repro
Consequence: Agents cannot respond in threads
@@ -92,4 +92,4 @@ body:
attributes:
label: Additional information
description: Add any context that helps triage but does not fit above.
placeholder: Regression started after upgrade from <previous-version>; temporary workaround is ...
placeholder: Regression started after upgrade from 2026.2.12; temporary workaround is restarting gateway every 30m.

View File

@@ -2,7 +2,7 @@ blank_issues_enabled: false
contact_links:
- name: Onboarding
url: https://discord.gg/clawd
about: "New to OpenClaw? Join Discord for setup guidance in #help."
about: New to OpenClaw? Join Discord for setup guidance from Krill in \#help.
- name: Support
url: https://discord.gg/clawd
about: "Get help from the OpenClaw community on Discord in #help."
about: Get help from Krill and the community on Discord in \#help.

View File

@@ -21,7 +21,7 @@ body:
attributes:
label: Problem to solve
description: What user pain this solves and why current behavior is insufficient.
placeholder: Agents cannot distinguish persona context in mixed channels, causing misrouted follow-ups.
placeholder: Teams cannot distinguish agent personas in mixed channels, causing misrouted follow-ups.
validations:
required: true
- type: textarea

View File

@@ -4,11 +4,8 @@
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-16vcpu-windows-2025
- blacksmith-16vcpu-ubuntu-2404-arm
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-windows-2025
# Ignore patterns for known issues
paths:
@@ -18,5 +15,3 @@ paths:
- "shellcheck reported issue.+"
# Ignore intentional if: false for disabled jobs
- 'constant expression "false" in condition'
# actionlint's built-in runner label allowlist lags Blacksmith additions.
- 'label "blacksmith-16vcpu-[^"]+" is unknown\.'

View File

@@ -37,7 +37,7 @@ runs:
exit 1
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
check-latest: true
@@ -52,7 +52,7 @@ runs:
if: inputs.install-bun == 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.9+cf6cdbbba"
bun-version: latest
- name: Runtime versions
shell: bash
@@ -70,29 +70,14 @@ runs:
shell: bash
env:
CI: "true"
FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }}
run: |
set -euo pipefail
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
case "$FROZEN_LOCKFILE" in
true) LOCKFILE_FLAG="--frozen-lockfile" ;;
false) LOCKFILE_FLAG="" ;;
*)
echo "::error::Invalid frozen-lockfile input: '$FROZEN_LOCKFILE' (expected true or false)"
exit 2
;;
esac
install_args=(
install
--ignore-scripts=false
--config.engine-strict=false
--config.enable-pre-post-scripts=true
)
if [ -n "$LOCKFILE_FLAG" ]; then
install_args+=("$LOCKFILE_FLAG")
LOCKFILE_FLAG=""
if [ "${{ inputs.frozen-lockfile }}" = "true" ]; then
LOCKFILE_FLAG="--frozen-lockfile"
fi
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || \
pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true

View File

@@ -14,17 +14,11 @@ runs:
steps:
- name: Setup pnpm (corepack retry)
shell: bash
env:
PNPM_VERSION: ${{ inputs.pnpm-version }}
run: |
set -euo pipefail
if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'"
exit 2
fi
corepack enable
for attempt in 1 2 3; do
if corepack prepare "pnpm@$PNPM_VERSION" --activate; then
if corepack prepare "pnpm@${{ inputs.pnpm-version }}" --activate; then
pnpm -v
exit 0
fi

View File

@@ -111,16 +111,3 @@ updates:
- minor
- patch
open-pull-requests-limit: 5
# Docker base images
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
docker-images:
patterns:
- "*"
open-pull-requests-limit: 5

4
.github/labeler.yml vendored
View File

@@ -240,10 +240,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/device-pair/**"
"extensions: acpx":
- changed-files:
- any-glob-to-any-file:
- "extensions/acpx/**"
"extensions: minimax-portal-auth":
- changed-files:
- any-glob-to-any-file:

View File

@@ -13,7 +13,7 @@ jobs:
permissions:
issues: write
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
@@ -48,7 +48,7 @@ jobs:
label: "r: third-party-extension",
close: true,
message:
"Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community",
"This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.openclaw.ai/plugin.",
},
{
label: "r: moltbook",
@@ -134,7 +134,7 @@ jobs:
const invalidLabel = "invalid";
const dirtyLabel = "dirty";
const noisyPrMessage =
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
"Closing this PR because it looks dirty (too many unrelated commits). Please recreate the PR from a clean branch.";
const pullRequest = context.payload.pull_request;
if (pullRequest) {

View File

@@ -13,7 +13,7 @@ jobs:
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
# Lint and format always run. Fail-safe: if detection fails, run everything.
docs-scope:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
docs_changed: ${{ steps.check.outputs.docs_changed }}
@@ -33,7 +33,7 @@ jobs:
changed-scope:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
run_node: ${{ steps.scope.outputs.run_node }}
run_macos: ${{ steps.scope.outputs.run_macos }}
@@ -127,7 +127,7 @@ jobs:
build-artifacts:
needs: [docs-scope, changed-scope, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -153,7 +153,7 @@ jobs:
release-check:
needs: [docs-scope, build-artifacts]
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -177,7 +177,7 @@ jobs:
checks:
needs: [docs-scope, changed-scope, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
@@ -192,28 +192,20 @@ jobs:
task: test
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
steps:
- name: Skip bun lane on push
if: github.event_name == 'push' && matrix.runtime == 'bun'
run: echo "Skipping bun test lane on push events."
- name: Checkout
if: github.event_name != 'push' || matrix.runtime != 'bun'
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
if: matrix.runtime != 'bun' || github.event_name != 'push'
uses: ./.github/actions/setup-node-env
with:
install-bun: "${{ matrix.runtime == 'bun' }}"
- name: Configure vitest JSON reports
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
if: matrix.task == 'test' && matrix.runtime == 'node'
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
- name: Configure Node test resources
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
if: matrix.task == 'test' && matrix.runtime == 'node'
run: |
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
@@ -221,17 +213,16 @@ jobs:
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: matrix.runtime != 'bun' || github.event_name != 'push'
run: ${{ matrix.command }}
- name: Summarize slowest tests
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
if: matrix.task == 'test' && matrix.runtime == 'node'
run: |
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
- name: Upload vitest reports
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
if: matrix.task == 'test' && matrix.runtime == 'node'
uses: actions/upload-artifact@v4
with:
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
@@ -244,7 +235,7 @@ jobs:
name: "check"
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -253,59 +244,15 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check types and lint and oxfmt
run: pnpm check
- name: Enforce safe external URL opening policy
run: pnpm lint:ui:no-raw-window-open
# Report-only dead-code scans. Runs after scope detection and stores machine-readable
# results as artifacts for later triage before we enable hard gates.
# Temporarily disabled in CI while we process initial findings.
deadcode:
name: dead-code report
needs: [docs-scope, changed-scope]
# if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
if: false
runs-on: blacksmith-16vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
include:
- tool: knip
command: pnpm deadcode:report:ci:knip
- tool: ts-prune
command: pnpm deadcode:report:ci:ts-prune
- tool: ts-unused-exports
command: pnpm deadcode:report:ci:ts-unused
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run ${{ matrix.tool }} dead-code scan
run: ${{ matrix.command }}
- name: Upload dead-code results
uses: actions/upload-artifact@v4
with:
name: dead-code-${{ matrix.tool }}-${{ github.run_id }}
path: .artifacts/deadcode
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_changed == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -314,60 +261,27 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check docs
run: pnpm check:docs
skills-python:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Python tooling
run: |
python -m pip install --upgrade pip
python -m pip install pytest ruff pyyaml
- name: Lint Python skill scripts
run: python -m ruff check skills
- name: Test skill Python scripts
run: python -m pytest -q skills
secrets:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install pre-commit
- name: Install detect-secrets
run: |
python -m pip install --upgrade pip
python -m pip install pre-commit detect-secrets==1.5.0
python -m pip install detect-secrets==1.5.0
- name: Detect secrets
run: |
@@ -376,37 +290,13 @@ jobs:
exit 1
fi
- name: Detect committed private keys
run: pre-commit run --all-files detect-private-key
- name: Audit changed GitHub workflows with zizmor
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
BASE="${{ github.event.pull_request.base.sha }}"
fi
mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml')
if [ "${#workflow_files[@]}" -eq 0 ]; then
echo "No workflow changes detected; skipping zizmor."
exit 0
fi
pre-commit run zizmor --files "${workflow_files[@]}"
- name: Audit production dependencies
run: pre-commit run --all-files pnpm-audit-prod
checks-windows:
needs: [docs-scope, changed-scope, build-artifacts, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-windows-2025
runs-on: blacksmith-4vcpu-windows-2025
env:
NODE_OPTIONS: --max-old-space-size=4096
# Keep total concurrency predictable on the 16 vCPU runner:
# 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:
@@ -418,23 +308,12 @@ jobs:
include:
- runtime: node
task: lint
shard_index: 0
shard_count: 1
command: pnpm lint
- runtime: node
task: test
shard_index: 1
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node
task: protocol
shard_index: 0
shard_count: 1
command: pnpm protocol:check
steps:
- name: Checkout
@@ -476,7 +355,7 @@ jobs:
test -s dist/plugin-sdk/index.js
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version: 22.x
check-latest: true
@@ -487,10 +366,16 @@ jobs:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Runtime versions
run: |
node -v
npm -v
bun -v
pnpm -v
- name: Capture node path
@@ -506,12 +391,6 @@ 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: Configure test shard (Windows)
if: matrix.task == 'test'
run: |
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
- name: Configure vitest JSON reports
if: matrix.task == 'test'
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
@@ -529,7 +408,7 @@ jobs:
if: matrix.task == 'test'
uses: actions/upload-artifact@v4
with:
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}-shard${{ matrix.shard_index }}of${{ matrix.shard_count }}
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
path: |
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
${{ runner.temp }}/vitest-slowest.md
@@ -774,7 +653,7 @@ jobs:
android:
needs: [docs-scope, changed-scope, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:

View File

@@ -24,12 +24,13 @@ env:
jobs:
# Build amd64 image
build-amd64:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-metadata: ${{ steps.meta.outputs.json }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -44,30 +45,18 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve image tags (amd64)
id: tags
shell: bash
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
tags=()
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
tags+=("${IMAGE}:main-amd64")
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}-amd64")
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}"
exit 1
fi
{
echo "value<<EOF"
printf "%s\n" "${tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64
- name: Build and push amd64 image
id: build
@@ -75,7 +64,8 @@ jobs:
with:
context: .
platforms: linux/amd64
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
provenance: false
@@ -83,12 +73,13 @@ jobs:
# Build arm64 image
build-arm64:
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
runs-on: ubuntu-24.04-arm
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-metadata: ${{ steps.meta.outputs.json }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -103,30 +94,18 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve image tags (arm64)
id: tags
shell: bash
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
tags=()
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
tags+=("${IMAGE}:main-arm64")
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}-arm64")
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No arm64 tags resolved for ref ${GITHUB_REF}"
exit 1
fi
{
echo "value<<EOF"
printf "%s\n" "${tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64
- name: Build and push arm64 image
id: build
@@ -134,7 +113,8 @@ jobs:
with:
context: .
platforms: linux/arm64
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
provenance: false
@@ -142,15 +122,12 @@ jobs:
# Create multi-platform manifest
create-manifest:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
needs: [build-amd64, build-arm64]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -158,44 +135,19 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve manifest tags
id: tags
shell: bash
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
tags=()
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
tags+=("${IMAGE}:main")
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}")
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
tags+=("${IMAGE}:latest")
fi
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"
exit 1
fi
{
echo "value<<EOF"
printf "%s\n" "${tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Extract metadata for manifest
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
- name: Create and push manifest
shell: bash
run: |
set -euo pipefail
mapfile -t tags <<< "${{ steps.tags.outputs.value }}"
args=()
for tag in "${tags[@]}"; do
[ -z "$tag" ] && continue
args+=("-t" "$tag")
done
docker buildx imagetools create "${args[@]}" \
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
${{ needs.build-amd64.outputs.image-digest }} \
${{ needs.build-arm64.outputs.image-digest }}
env:
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}

139
.github/workflows/formal-conformance.yml vendored Normal file
View File

@@ -0,0 +1,139 @@
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
timeout-minutes: 20
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout openclaw (PR)
uses: actions/checkout@v4
with:
path: openclaw
- name: Checkout formal models
uses: actions/checkout@v4
with:
repository: vignesh07/clawdbot-formal-models
ref: main
path: clawdbot-formal-models
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Regenerate extracted constants from openclaw
run: |
set -euo pipefail
cd clawdbot-formal-models
export OPENCLAW_REPO_DIR="${GITHUB_WORKSPACE}/openclaw"
node scripts/extract-tool-groups.mjs
node scripts/check-tool-group-alias.mjs
# Drift is about extracted artifacts only; compute it before model checking
# to avoid any incidental file touches affecting the result.
- name: Compute drift (generated/*)
id: drift
run: |
set -euo pipefail
cd clawdbot-formal-models
if git diff --quiet -- generated; then
echo "drift=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "drift=true" >> "$GITHUB_OUTPUT"
git diff -- generated > "${GITHUB_WORKSPACE}/formal-models-drift.diff"
- name: Model check (green suite)
run: |
set -euo pipefail
cd clawdbot-formal-models
make \
precedence groups elevated nodes-policy \
attacker approvals approvals-token nodes-pipeline \
gateway-exposure gateway-exposure-v2 gateway-exposure-v2-protected \
gateway-auth-conformance gateway-auth-tailscale gateway-auth-proxy \
pairing pairing-cap pairing-idempotency pairing-refresh pairing-refresh-race \
ingress-gating ingress-idempotency ingress-dedupe-fallback ingress-trace ingress-trace2 \
routing-isolation routing-precedence routing-identitylinks routing-identity-transitive routing-identity-symmetry routing-identity-channel-override \
routing-thread-parent discord-pluralkit \
ingress-retry session-key-stability session-explosion-bound config-normalization \
queue-drain delivery-route-stability delivery-pipeline retry-termination retry-eventual-success \
no-cross-stream multi-event-eventual-emission \
dedupe-collision-fallback crash-restart-dedupe two-worker-dedupe openclaw-session-key-conformance \
routing-thread-parent-channel-override routing-trirule gateway-auth-proxy-header-spoof \
group-alias-check
- name: Model check (negative suite, expected violations)
continue-on-error: true
run: |
set -euo pipefail
cd clawdbot-formal-models
make -k \
precedence-negative groups-negative elevated-negative nodes-policy-negative \
attacker-negative attacker-nodes-negative attacker-nodes-allowlist-negative attacker-nodes-allowlist-negative \
approvals-negative approvals-token-negative nodes-pipeline-negative \
gateway-exposure-negative gateway-exposure-v2-negative gateway-exposure-v2-protected-negative \
gateway-exposure-v2-unsafe-custom gateway-exposure-v2-unsafe-tailnet gateway-exposure-v2-unsafe-auto \
gateway-auth-conformance-negative gateway-auth-tailscale-negative gateway-auth-proxy-negative \
pairing-negative pairing-cap-negative pairing-idempotency-negative pairing-refresh-negative pairing-refresh-race-negative \
ingress-gating-negative ingress-idempotency-negative ingress-dedupe-fallback-negative ingress-trace-negative ingress-trace2-negative \
routing-isolation-negative routing-precedence-negative routing-identitylinks-negative routing-identity-transitive-negative routing-identity-symmetry-negative routing-identity-channel-override-negative \
routing-thread-parent-negative discord-pluralkit-negative \
ingress-retry-negative session-key-stability-negative config-normalization-negative \
queue-drain delivery-route-stability-negative delivery-pipeline-negative retry-termination-negative retry-eventual-success-negative \
no-cross-stream-negative multi-event-eventual-emission-negative \
dedupe-collision-fallback-negative crash-restart-dedupe-negative two-worker-dedupe-negative openclaw-session-key-conformance-negative \
routing-thread-parent-channel-override-negative routing-trirule-negative gateway-auth-proxy-header-spoof-negative
- name: Upload drift diff artifact
if: steps.drift.outputs.drift == 'true'
uses: actions/upload-artifact@v4
with:
name: formal-models-conformance-drift
path: formal-models-drift.diff
- name: Comment on PR (informational)
if: steps.drift.outputs.drift == 'true'
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
const body = [
'⚠️ **Formal models conformance drift detected**',
'',
'The formal models extracted constants (`generated/*`) do not match this openclaw PR.',
'',
'This check is **informational** (not blocking merges yet).',
'See the `formal-models-conformance-drift` artifact for the diff.',
'',
'If this change is intentional, follow up by updating the formal models repo or regenerating the extracted artifacts there.',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body,
});
- name: Summary
run: |
if [ "${{ steps.drift.outputs.drift }}" = "true" ]; then
echo "Formal conformance drift detected (informational)."
else
echo "Formal conformance: no drift."
fi

View File

@@ -12,7 +12,7 @@ concurrency:
jobs:
docs-scope:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
steps:
@@ -28,13 +28,13 @@ jobs:
install-smoke:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- name: Checkout CLI
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version: 22.x
check-latest: true

View File

@@ -23,7 +23,7 @@ jobs:
permissions:
contents: read
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
@@ -200,7 +200,7 @@ jobs:
permissions:
contents: read
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
@@ -440,7 +440,7 @@ jobs:
label-issues:
permissions:
issues: write
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token

View File

@@ -19,7 +19,7 @@ concurrency:
jobs:
sandbox-common-smoke:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

View File

@@ -12,7 +12,7 @@ jobs:
permissions:
issues: write
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
@@ -31,7 +31,7 @@ jobs:
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
exempt-pr-labels: maintainer,no-stale
operations-per-run: 10000
operations-per-run: 500
exempt-all-assignees: true
remove-stale-when-updated: true
stale-issue-message: |

View File

@@ -11,7 +11,7 @@ concurrency:
jobs:
no-tabs:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -40,28 +40,3 @@ jobs:
print(f"- {path}")
sys.exit(1)
PY
actionlint:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install actionlint
shell: bash
run: |
set -euo pipefail
ACTIONLINT_VERSION="1.7.11"
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}"
curl -sSfL -o "${archive}" "${base_url}/${archive}"
curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt"
grep " ${archive}\$" checksums.txt | sha256sum -c -
tar -xzf "${archive}" actionlint
sudo install -m 0755 actionlint /usr/local/bin/actionlint
- name: Lint workflows
run: actionlint
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py

34
.gitignore vendored
View File

@@ -17,11 +17,6 @@ __pycache__/
ui/src/ui/__screenshots__/
ui/playwright-report/
ui/test-results/
packages/dashboard-next/.next/
packages/dashboard-next/out/
# Mise configuration files
mise.toml
# Android build artifacts
apps/android/.gradle/
@@ -41,13 +36,10 @@ bin/docs-list
apps/macos/.build-local/
apps/macos/.swiftpm/
apps/shared/MoltbotKit/.swiftpm/
apps/shared/OpenClawKit/.swiftpm/
Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/
apps/ios/.derivedData/
apps/ios/.local-signing.xcconfig
vendor/
apps/ios/Clawdbot.xcodeproj/
apps/ios/Clawdbot.xcodeproj/**
@@ -94,29 +86,3 @@ USER.md
!.agent/workflows/
/local/
package-lock.json
.claude/settings.local.json
.agents/
.agents
.agent/
skills-lock.json
# Local iOS signing overrides
apps/ios/LocalSigning.xcconfig
# Xcode build directories (xcodebuild output)
apps/ios/build/
apps/shared/OpenClawKit/build/
Swabble/build/
# Generated protocol schema (produced via pnpm protocol:gen)
dist/protocol.schema.json
.ant-colony/
# Eclipse
**/.project
**/.classpath
**/.settings/
**/.gradle/
# Synthing
**/.stfolder/

View File

@@ -1,13 +0,0 @@
# Canonical contributor identity mappings for cherry-picked commits.
bmendonca3 <208517100+bmendonca3@users.noreply.github.com> <brianmendonca@Brians-MacBook-Air.local>
hcl <7755017+hclsys@users.noreply.github.com> <chenglunhu@gmail.com>
Glucksberg <80581902+Glucksberg@users.noreply.github.com> <markuscontasul@gmail.com>
JackyWay <53031570+JackyWay@users.noreply.github.com> <jackybbc@gmail.com>
Marcus Castro <7562095+mcaxtr@users.noreply.github.com> <mcaxtr@gmail.com>
Marc Gratch <2238658+mgratch@users.noreply.github.com> <me@marcgratch.com>
Peter Machona <7957943+chilu18@users.noreply.github.com> <chilu.machona@icloud.com>
Ben Marvell <92585+easternbloc@users.noreply.github.com> <ben@marvell.consulting>
zerone0x <39543393+zerone0x@users.noreply.github.com> <hi@trine.dev>
Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> <m.didionisio23@gmail.com>
mujiannan <46643837+mujiannan@users.noreply.github.com> <shennan@mujiannan.com>
Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> <noreply@anthropic.com>

2
.npmrc
View File

@@ -1 +1 @@
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs

View File

@@ -6,20 +6,14 @@
"experimentalSortPackageJson": {
"sortScripts": true,
},
"tabWidth": 2,
"useTabs": false,
"ignorePatterns": [
"apps/",
"assets/",
"CLAUDE.md",
"docker-compose.yml",
"dist/",
"docs/_layouts/",
"node_modules/",
"patches/",
"pnpm-lock.yaml/",
"src/gateway/server-methods/CLAUDE.md",
"src/auto-reply/reply/export-html/",
"Swabble/",
"vendor/",
],

View File

@@ -11,8 +11,6 @@
"eslint-plugin-unicorn/prefer-array-find": "off",
"eslint/no-await-in-loop": "off",
"eslint/no-new": "off",
"eslint/no-shadow": "off",
"eslint/no-unmodified-loop-condition": "off",
"oxc/no-accumulating-spread": "off",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-map-spread": "off",
@@ -29,9 +27,8 @@
"extensions/",
"node_modules/",
"patches/",
"pnpm-lock.yaml",
"pnpm-lock.yaml/",
"skills/",
"src/auto-reply/reply/export-html/template.js",
"src/canvas-host/a2ui/a2ui.bundle.js",
"Swabble/",
"vendor/"

View File

@@ -18,8 +18,6 @@ repos:
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
- id: detect-private-key
exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)'
# Secret detection (same as CI)
- repo: https://github.com/Yelp/detect-secrets
@@ -47,6 +45,7 @@ repos:
- '=== "string"'
- --exclude-lines
- 'typeof remote\?\.password === "string"'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0
@@ -70,34 +69,9 @@ repos:
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
exclude: "^(vendor/|Swabble/)"
# Python checks for skills scripts
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.1
hooks:
- id: ruff
files: "^skills/.*\\.py$"
args: [--config, pyproject.toml]
- repo: local
hooks:
- id: skills-python-tests
name: skills python tests
entry: pytest -q skills
language: python
additional_dependencies: [pytest>=8, <9]
pass_filenames: false
files: "^skills/.*\\.py$"
# Project checks (same commands as CI)
- repo: local
hooks:
# pnpm audit --prod --audit-level=high
- id: pnpm-audit-prod
name: pnpm-audit-prod
entry: pnpm audit --prod --audit-level=high
language: system
pass_filenames: false
# oxlint --type-aware src test
- id: oxlint
name: oxlint

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,6 @@
- Repo: https://github.com/openclaw/openclaw
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
- GitHub linking footgun: dont wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
## Project Structure & Module Organization
@@ -73,10 +70,6 @@
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
@@ -86,7 +79,6 @@
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
- dev: moving head on `main` (no tag; git checkout main).
## Testing Guidelines
@@ -95,7 +87,6 @@
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Do not set test workers above 16; tried already.
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + whats covered: `docs/testing.md`.
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
@@ -119,16 +110,6 @@
## Git Notes
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
## GitHub Search (`gh`)
- Prefer targeted keyword search before proposing new work or duplicating fixes.
- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads.
- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
- Structured output example:
`gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'`
## Security & Configuration Tips
@@ -140,7 +121,6 @@
## GHSA (Repo Advisory) Patch/Publish
- Before reviewing security advisories, read `SECURITY.md`.
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
- Private fork PRs must be closed:
@@ -148,7 +128,6 @@
`gh pr list -R "$fork" --state open` (must be empty)
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
@@ -207,7 +186,6 @@
- launchd PATH is minimal; ensure the apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
- Release guardrails: do not change version numbers without operators explicit consent; always ask permission before running any npm publish/release step.
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
## NPM + 1Password (publish/verify)
@@ -235,7 +213,7 @@
- skip if package is missing on npm or version already matches.
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
- Post-check for each release:
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.17`
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.16`
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
## Changelog Release Notes

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
AGENTS.md
AGENTS.md

View File

@@ -5,7 +5,6 @@ Welcome to the lobster tank! 🦞
## Quick Links
- **GitHub:** https://github.com/openclaw/openclaw
- **Vision:** [`VISION.md`](VISION.md)
- **Discord:** https://discord.gg/qkhbAGHRBT
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw)
@@ -14,7 +13,7 @@ Welcome to the lobster tank! 🦞
- **Peter Steinberger** - Benevolent Dictator
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
- **Shadow** - Discord subsystem, Discord admin, Clawhub, all community moderation
- **Shadow** - Discord subsystem, Discord admin
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster
@@ -32,12 +31,6 @@ Welcome to the lobster tank! 🦞
- **Mariano Belinky** - iOS app, Security
- GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
- **Vincent Koc** - Agents, Telemetry, Hooks, Security
- GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc)
- **Val Alexander** - UI/UX, Docs, and Agent DevX
- GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev)
- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening
- GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig)
@@ -47,24 +40,18 @@ Welcome to the lobster tank! 🦞
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
- **Josh Avant** - Core, CLI, Gateway, Security, Agents
- GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
3. **Questions** → Discord #setup-help
## Before You PR
- Test locally with your OpenClaw instance
- Run tests: `pnpm build && pnpm check && pnpm test`
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Keep PRs focused (one thing per PR)
- Describe what & why
## Control UI Decorators
@@ -106,26 +93,6 @@ We are currently prioritizing:
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
## Maintainers
We're selectively expanding the maintainer team.
If you're an experienced contributor who wants to help shape OpenClaw's direction — whether through code, docs, or community — we'd like to hear from you.
Being a maintainer is a responsibility, not an honorary title. We expect active, consistent involvement — triaging issues, reviewing PRs, and helping move the project forward.
Still interested? Email contributing@openclaw.ai with:
- Links to your PRs on OpenClaw (if you don't have any, start there first)
- Links to open source projects you maintain or actively contribute to
- Your GitHub, Discord, and X/Twitter handles
- A brief intro: background, experience, and areas of interest
- Languages you speak and where you're based
- How much time you can realistically commit
We welcome people across all skill sets — engineering, documentation, community management, and more.
We review every human-only-written application carefully and add maintainers slowly and deliberately.
Please allow a few weeks for a response.
## Report a Vulnerability
We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives:

View File

@@ -1,4 +1,4 @@
FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
FROM node:22-bookworm
# Install Bun (required for build scripts)
RUN curl -fsSL https://bun.sh/install | bash
@@ -7,7 +7,6 @@ ENV PATH="/root/.bun/bin:${PATH}"
RUN corepack enable
WORKDIR /app
RUN chown node:node /app
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
@@ -17,35 +16,14 @@ RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY --chown=node:node ui/package.json ./ui/package.json
COPY --chown=node:node patches ./patches
COPY --chown=node:node scripts ./scripts
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
USER node
# Reduce OOM risk on low-memory hosts during dependency installation.
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
RUN pnpm install --frozen-lockfile
# Optionally install Chromium and Xvfb for browser automation.
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
# Must run after pnpm install so playwright-core is available in node_modules.
USER root
ARG OPENCLAW_INSTALL_BROWSER=""
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
mkdir -p /home/node/.cache/ms-playwright && \
PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
chown -R node:node /home/node/.cache/ms-playwright && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
USER node
COPY --chown=node:node . .
COPY . .
RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
@@ -53,6 +31,9 @@ RUN pnpm ui:build
ENV NODE_ENV=production
# Allow non-root user to write temp files during runtime/tests.
RUN chown -R node:node /app
# Security hardening: Run as non-root user
# The node:22-bookworm image includes a 'node' user (uid 1000)
# This reduces the attack surface by preventing container escape via root privileges

View File

@@ -1,4 +1,4 @@
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive

View File

@@ -1,4 +1,4 @@
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive

114
README.md
View File

@@ -23,21 +23,16 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal.
The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
Works with npm, pnpm, or bun.
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
## Sponsors
| OpenAI | Blacksmith |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) |
**Subscriptions (OAuth):**
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for longcontext strength and better promptinjection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
@@ -145,13 +140,13 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming.
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups).
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups).
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
### 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), [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/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
- [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
@@ -170,7 +165,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
### Runtime + safety
- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
@@ -502,54 +497,53 @@ 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/sktbrd"><img src="https://avatars.githubusercontent.com/u/116202536?v=4&s=48" width="48" height="48" alt="sktbrd" title="sktbrd"/></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/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/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/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/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/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/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></a> <a href="https://github.com/VeriteIgiraneza"><img src="https://avatars.githubusercontent.com/u/69280208?v=4&s=48" width="48" height="48" alt="Verite Igiraneza" title="Verite Igiraneza"/></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/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/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/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/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/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/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/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/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/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/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/smartprogrammer93"><img src="https://avatars.githubusercontent.com/u/33181301?v=4&s=48" width="48" height="48" alt="smartprogrammer93" title="smartprogrammer93"/></a> <a href="https://github.com/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></a> <a href="https://github.com/HenryLoenwind"><img src="https://avatars.githubusercontent.com/u/1485873?v=4&s=48" width="48" height="48" alt="HenryLoenwind" title="HenryLoenwind"/></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/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/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/joshavant"><img src="https://avatars.githubusercontent.com/u/830519?v=4&s=48" width="48" height="48" alt="joshavant" title="joshavant"/></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/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/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/ranausmanai"><img src="https://avatars.githubusercontent.com/u/257128159?v=4&s=48" width="48" height="48" alt="ranausmanai" title="ranausmanai"/></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/heyhudson"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="heyhudson" title="heyhudson"/></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/yinghaosang"><img src="https://avatars.githubusercontent.com/u/261132136?v=4&s=48" width="48" height="48" alt="yinghaosang" title="yinghaosang"/></a>
<a href="https://github.com/nabbilkhan"><img src="https://avatars.githubusercontent.com/u/203121263?v=4&s=48" width="48" height="48" alt="nabbilkhan" title="nabbilkhan"/></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/aether-ai-agent"><img src="https://avatars.githubusercontent.com/u/261339948?v=4&s=48" width="48" height="48" alt="aether-ai-agent" title="aether-ai-agent"/></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/Mrseenz"><img src="https://avatars.githubusercontent.com/u/101962919?v=4&s=48" width="48" height="48" alt="Mrseenz" title="Mrseenz"/></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/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/buerbaumer"><img src="https://avatars.githubusercontent.com/u/44548809?v=4&s=48" width="48" height="48" alt="Harald Buerbaumer" title="Harald Buerbaumer"/></a> <a href="https://github.com/akoscz"><img src="https://avatars.githubusercontent.com/u/1360047?v=4&s=48" width="48" height="48" alt="akoscz" title="akoscz"/></a> <a href="https://github.com/Bridgerz"><img src="https://avatars.githubusercontent.com/u/24499532?v=4&s=48" width="48" height="48" alt="Bridgerz" title="Bridgerz"/></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/openclaw-bot"><img src="https://avatars.githubusercontent.com/u/258178069?v=4&s=48" width="48" height="48" alt="openclaw-bot" title="openclaw-bot"/></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/JustasMonkev"><img src="https://avatars.githubusercontent.com/u/59362982?v=4&s=48" width="48" height="48" alt="JustasM" title="JustasM"/></a> <a href="https://github.com/Phineas1500"><img src="https://avatars.githubusercontent.com/u/41450967?v=4&s=48" width="48" height="48" alt="Phineas1500" title="Phineas1500"/></a> <a href="https://github.com/ENCHIGO"><img src="https://avatars.githubusercontent.com/u/38551565?v=4&s=48" width="48" height="48" alt="ENCHIGO" title="ENCHIGO"/></a>
<a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="Hiren Patel" title="Hiren Patel"/></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/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/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/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></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/Ryan-Haines"><img src="https://avatars.githubusercontent.com/u/1855752?v=4&s=48" width="48" height="48" alt="Ryan Haines" title="Ryan Haines"/></a> <a href="https://github.com/Blakeshannon"><img src="https://avatars.githubusercontent.com/u/257822860?v=4&s=48" width="48" height="48" alt="Blakeshannon" title="Blakeshannon"/></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/Marvae"><img src="https://avatars.githubusercontent.com/u/11957602?v=4&s=48" width="48" height="48" alt="Marvae" title="Marvae"/></a>
<a href="https://github.com/arosstale"><img src="https://avatars.githubusercontent.com/u/117890364?v=4&s=48" width="48" height="48" alt="arosstale" title="arosstale"/></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/gejifeng"><img src="https://avatars.githubusercontent.com/u/17561857?v=4&s=48" width="48" height="48" alt="gejifeng" title="gejifeng"/></a> <a href="https://github.com/divanoli"><img src="https://avatars.githubusercontent.com/u/12023205?v=4&s=48" width="48" height="48" alt="divanoli" title="divanoli"/></a> <a href="https://github.com/ryan-crabbe"><img src="https://avatars.githubusercontent.com/u/128659760?v=4&s=48" width="48" height="48" alt="ryan-crabbe" title="ryan-crabbe"/></a> <a href="https://github.com/nyanjou"><img src="https://avatars.githubusercontent.com/u/258645604?v=4&s=48" width="48" height="48" alt="nyanjou" title="nyanjou"/></a> <a href="https://github.com/theSamPadilla"><img src="https://avatars.githubusercontent.com/u/35386211?v=4&s=48" width="48" height="48" alt="Sam Padilla" title="Sam Padilla"/></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/solstead"><img src="https://avatars.githubusercontent.com/u/168413654?v=4&s=48" width="48" height="48" alt="solstead" title="solstead"/></a>
<a href="https://github.com/natefikru"><img src="https://avatars.githubusercontent.com/u/10344644?v=4&s=48" width="48" height="48" alt="natefikru" title="natefikru"/></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/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="LeftX" title="LeftX"/></a> <a href="https://github.com/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></a> <a href="https://github.com/harhogefoo"><img src="https://avatars.githubusercontent.com/u/11906529?v=4&s=48" width="48" height="48" alt="Masataka Shinohara" title="Masataka Shinohara"/></a> <a href="https://github.com/lewiswigmore"><img src="https://avatars.githubusercontent.com/u/58551848?v=4&s=48" width="48" height="48" alt="Lewis" title="Lewis"/></a> <a href="https://github.com/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></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/BillChirico"><img src="https://avatars.githubusercontent.com/u/13951316?v=4&s=48" width="48" height="48" alt="BillChirico" title="BillChirico"/></a> <a href="https://github.com/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></a> <a href="https://github.com/CharlieGreenman"><img src="https://avatars.githubusercontent.com/u/8540141?v=4&s=48" width="48" height="48" alt="CharlieGreenman" title="CharlieGreenman"/></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/Mellowambience"><img src="https://avatars.githubusercontent.com/u/40958792?v=4&s=48" width="48" height="48" alt="Mars" title="Mars"/></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/mcrolly"><img src="https://avatars.githubusercontent.com/u/60803337?v=4&s=48" width="48" height="48" alt="McRolly NWANGWU" title="McRolly NWANGWU"/></a> <a href="https://github.com/PeterShanxin"><img src="https://avatars.githubusercontent.com/u/128674037?v=4&s=48" width="48" height="48" alt="LI SHANXIN" title="LI SHANXIN"/></a> <a href="https://github.com/simonemacario"><img src="https://avatars.githubusercontent.com/u/2116609?v=4&s=48" width="48" height="48" alt="Simone Macario" title="Simone Macario"/></a> <a href="https://github.com/durenzidu"><img src="https://avatars.githubusercontent.com/u/38130340?v=4&s=48" width="48" height="48" alt="durenzidu" title="durenzidu"/></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/Minidoracat"><img src="https://avatars.githubusercontent.com/u/11269639?v=4&s=48" width="48" height="48" alt="Minidoracat" title="Minidoracat"/></a> <a href="https://github.com/magendary"><img src="https://avatars.githubusercontent.com/u/30611068?v=4&s=48" width="48" height="48" alt="magendary" title="magendary"/></a> <a href="https://github.com/jessy2027"><img src="https://avatars.githubusercontent.com/u/89694096?v=4&s=48" width="48" height="48" alt="Jessy LANGE" title="Jessy LANGE"/></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/brandonwise"><img src="https://avatars.githubusercontent.com/u/21148772?v=4&s=48" width="48" height="48" alt="brandonwise" title="brandonwise"/></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/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/Harrington-bot"><img src="https://avatars.githubusercontent.com/u/261410808?v=4&s=48" width="48" height="48" alt="Harrington-bot" title="Harrington-bot"/></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="Lalit Singh" title="Lalit Singh"/></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/jscaldwell55"><img src="https://avatars.githubusercontent.com/u/111952840?v=4&s=48" width="48" height="48" alt="Jay Caldwell" title="Jay Caldwell"/></a> <a href="https://github.com/KirillShchetinin"><img src="https://avatars.githubusercontent.com/u/13061871?v=4&s=48" width="48" height="48" alt="Kirill Shchetynin" title="Kirill Shchetynin"/></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/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/TsekaLuk"><img src="https://avatars.githubusercontent.com/u/79151285?v=4&s=48" width="48" height="48" alt="TsekaLuk" title="TsekaLuk"/></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/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="Shailesh" title="Shailesh"/></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/jackheuberger"><img src="https://avatars.githubusercontent.com/u/7830838?v=4&s=48" width="48" height="48" alt="jackheuberger" title="jackheuberger"/></a> <a href="https://github.com/loiie45e"><img src="https://avatars.githubusercontent.com/u/15420100?v=4&s=48" width="48" height="48" alt="loiie45e" title="loiie45e"/></a> <a href="https://github.com/El-Fitz"><img src="https://avatars.githubusercontent.com/u/8971906?v=4&s=48" width="48" height="48" alt="El-Fitz" title="El-Fitz"/></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/pvtclawn"><img src="https://avatars.githubusercontent.com/u/258811507?v=4&s=48" width="48" height="48" alt="pvtclawn" title="pvtclawn"/></a> <a href="https://github.com/0xRaini"><img src="https://avatars.githubusercontent.com/u/190923101?v=4&s=48" width="48" height="48" alt="0xRaini" title="0xRaini"/></a> <a href="https://github.com/ruypang"><img src="https://avatars.githubusercontent.com/u/46941315?v=4&s=48" width="48" height="48" alt="ruypang" title="ruypang"/></a> <a href="https://github.com/xinhuagu"><img src="https://avatars.githubusercontent.com/u/562450?v=4&s=48" width="48" height="48" alt="xinhuagu" title="xinhuagu"/></a>
<a href="https://github.com/DrCrinkle"><img src="https://avatars.githubusercontent.com/u/62564740?v=4&s=48" width="48" height="48" alt="Taylor Asplund" title="Taylor Asplund"/></a> <a href="https://github.com/adhitShet"><img src="https://avatars.githubusercontent.com/u/131381638?v=4&s=48" width="48" height="48" alt="adhitShet" title="adhitShet"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="Paul van Oorschot" title="Paul van Oorschot"/></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/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/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/AI-Reviewer-QS"><img src="https://avatars.githubusercontent.com/u/255312808?v=4&s=48" width="48" height="48" alt="AI-Reviewer-QS" title="AI-Reviewer-QS"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="Stefan Galescu" title="Stefan Galescu"/></a> <a href="https://github.com/WalterSumbon"><img src="https://avatars.githubusercontent.com/u/45062253?v=4&s=48" width="48" height="48" alt="WalterSumbon" title="WalterSumbon"/></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/rodbland2021"><img src="https://avatars.githubusercontent.com/u/86267410?v=4&s=48" width="48" height="48" alt="rodbland2021" title="rodbland2021"/></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/fagemx"><img src="https://avatars.githubusercontent.com/u/117356295?v=4&s=48" width="48" height="48" alt="fagemx" title="fagemx"/></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/omair445"><img src="https://avatars.githubusercontent.com/u/32237905?v=4&s=48" width="48" height="48" alt="omair445" title="omair445"/></a> <a href="https://github.com/dorukardahan"><img src="https://avatars.githubusercontent.com/u/35905596?v=4&s=48" width="48" height="48" alt="dorukardahan" title="dorukardahan"/></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/Clawborn"><img src="https://avatars.githubusercontent.com/u/261310391?v=4&s=48" width="48" height="48" alt="Clawborn" title="Clawborn"/></a> <a href="https://github.com/davidrudduck"><img src="https://avatars.githubusercontent.com/u/47308254?v=4&s=48" width="48" height="48" alt="davidrudduck" title="davidrudduck"/></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="Igor Markelov" title="Igor Markelov"/></a> <a href="https://github.com/rrenamed"><img src="https://avatars.githubusercontent.com/u/87486610?v=4&s=48" width="48" height="48" alt="rrenamed" title="rrenamed"/></a> <a href="https://github.com/parkertoddbrooks"><img src="https://avatars.githubusercontent.com/u/585456?v=4&s=48" width="48" height="48" alt="Parker Todd Brooks" title="Parker Todd Brooks"/></a> <a href="https://github.com/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></a> <a href="https://github.com/CommanderCrowCode"><img src="https://avatars.githubusercontent.com/u/72845369?v=4&s=48" width="48" height="48" alt="Tanwa Arpornthip" title="Tanwa Arpornthip"/></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/tomron87"><img src="https://avatars.githubusercontent.com/u/126325152?v=4&s=48" width="48" height="48" alt="Tom Ron" title="Tom Ron"/></a>
<a href="https://github.com/popomore"><img src="https://avatars.githubusercontent.com/u/360661?v=4&s=48" width="48" height="48" alt="popomore" title="popomore"/></a> <a href="https://github.com/Patrick-Barletta"><img src="https://avatars.githubusercontent.com/u/67929313?v=4&s=48" width="48" height="48" alt="Patrick Barletta" title="Patrick Barletta"/></a> <a href="https://github.com/shayan919293"><img src="https://avatars.githubusercontent.com/u/60409704?v=4&s=48" width="48" height="48" alt="shayan919293" title="shayan919293"/></a> <a href="https://github.com/stakeswky"><img src="https://avatars.githubusercontent.com/u/64798754?v=4&s=48" width="48" height="48" alt="不做了睡大觉" title="不做了睡大觉"/></a> <a href="https://github.com/luijoc"><img src="https://avatars.githubusercontent.com/u/96428056?v=4&s=48" width="48" height="48" alt="Luis Conde" title="Luis Conde"/></a> <a href="https://github.com/Kepler2024"><img src="https://avatars.githubusercontent.com/u/166882517?v=4&s=48" width="48" height="48" alt="Harry Cui Kepler" title="Harry Cui Kepler"/></a> <a href="https://github.com/SidQin-cyber"><img src="https://avatars.githubusercontent.com/u/201593046?v=4&s=48" width="48" height="48" alt="SidQin-cyber" title="SidQin-cyber"/></a> <a href="https://github.com/L-U-C-K-Y"><img src="https://avatars.githubusercontent.com/u/14868134?v=4&s=48" width="48" height="48" alt="Lucky" title="Lucky"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="Michael Lee" title="Michael Lee"/></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/dakshaymehta"><img src="https://avatars.githubusercontent.com/u/50276213?v=4&s=48" width="48" height="48" alt="dakshaymehta" title="dakshaymehta"/></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="nonggia.liang" title="nonggia.liang"/></a> <a href="https://github.com/seheepeak"><img src="https://avatars.githubusercontent.com/u/134766597?v=4&s=48" width="48" height="48" alt="seheepeak" title="seheepeak"/></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/danielwanwx"><img src="https://avatars.githubusercontent.com/u/144515713?v=4&s=48" width="48" height="48" alt="danielwanwx" title="danielwanwx"/></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/minupla"><img src="https://avatars.githubusercontent.com/u/42547246?v=4&s=48" width="48" height="48" alt="minupla" title="minupla"/></a> <a href="https://github.com/misterdas"><img src="https://avatars.githubusercontent.com/u/170702047?v=4&s=48" width="48" height="48" alt="misterdas" title="misterdas"/></a>
<a href="https://github.com/Shuai-DaiDai"><img src="https://avatars.githubusercontent.com/u/134567396?v=4&s=48" width="48" height="48" alt="Shuai-DaiDai" title="Shuai-DaiDai"/></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/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/dirbalak"><img src="https://avatars.githubusercontent.com/u/30323349?v=4&s=48" width="48" height="48" alt="dirbalak" title="dirbalak"/></a> <a href="https://github.com/cathrynlavery"><img src="https://avatars.githubusercontent.com/u/50469282?v=4&s=48" width="48" height="48" alt="cathrynlavery" title="cathrynlavery"/></a> <a href="https://github.com/Joly0"><img src="https://avatars.githubusercontent.com/u/13993216?v=4&s=48" width="48" height="48" alt="Joly0" title="Joly0"/></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/niceysam"><img src="https://avatars.githubusercontent.com/u/256747835?v=4&s=48" width="48" height="48" alt="niceysam" title="niceysam"/></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/carrotRakko"><img src="https://avatars.githubusercontent.com/u/24588751?v=4&s=48" width="48" height="48" alt="carrotRakko" title="carrotRakko"/></a> <a href="https://github.com/Oceanswave"><img src="https://avatars.githubusercontent.com/u/760674?v=4&s=48" width="48" height="48" alt="Oceanswave" title="Oceanswave"/></a> <a href="https://github.com/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="cdorsey" title="cdorsey"/></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/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/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/adao-max"><img src="https://avatars.githubusercontent.com/u/153898832?v=4&s=48" width="48" height="48" alt="Skyler Miao" title="Skyler Miao"/></a> <a href="https://github.com/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></a>
<a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="TideFinder" title="TideFinder"/></a> <a href="https://github.com/CornBrother0x"><img src="https://avatars.githubusercontent.com/u/101160087?v=4&s=48" width="48" height="48" alt="CornBrother0x" title="CornBrother0x"/></a> <a href="https://github.com/DukeDeSouth"><img src="https://avatars.githubusercontent.com/u/51200688?v=4&s=48" width="48" height="48" alt="DukeDeSouth" title="DukeDeSouth"/></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/bsormagec"><img src="https://avatars.githubusercontent.com/u/965219?v=4&s=48" width="48" height="48" alt="bsormagec" title="bsormagec"/></a> <a href="https://github.com/Diaspar4u"><img src="https://avatars.githubusercontent.com/u/3605840?v=4&s=48" width="48" height="48" alt="Diaspar4u" title="Diaspar4u"/></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/nk1tz"><img src="https://avatars.githubusercontent.com/u/12980165?v=4&s=48" width="48" height="48" alt="Nate" title="Nate"/></a> <a href="https://github.com/OscarMinjarez"><img src="https://avatars.githubusercontent.com/u/86080038?v=4&s=48" width="48" height="48" alt="OscarMinjarez" title="OscarMinjarez"/></a> <a href="https://github.com/webvijayi"><img src="https://avatars.githubusercontent.com/u/49924855?v=4&s=48" width="48" height="48" alt="webvijayi" title="webvijayi"/></a>
<a href="https://github.com/garnetlyx"><img src="https://avatars.githubusercontent.com/u/12513503?v=4&s=48" width="48" height="48" alt="garnetlyx" title="garnetlyx"/></a> <a href="https://github.com/miloudbelarebia"><img src="https://avatars.githubusercontent.com/u/136994453?v=4&s=48" width="48" height="48" alt="miloudbelarebia" title="miloudbelarebia"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="Jeremiah Lowin" title="Jeremiah Lowin"/></a> <a href="https://github.com/liebertar"><img src="https://avatars.githubusercontent.com/u/99405438?v=4&s=48" width="48" height="48" alt="liebertar" title="liebertar"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="Max" title="Max"/></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/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/taw0002"><img src="https://avatars.githubusercontent.com/u/42811278?v=4&s=48" width="48" height="48" alt="taw0002" title="taw0002"/></a>
<a href="https://github.com/asklee-klawd"><img src="https://avatars.githubusercontent.com/u/105007315?v=4&s=48" width="48" height="48" alt="asklee-klawd" title="asklee-klawd"/></a> <a href="https://github.com/h0tp-ftw"><img src="https://avatars.githubusercontent.com/u/141889580?v=4&s=48" width="48" height="48" alt="h0tp-ftw" title="h0tp-ftw"/></a> <a href="https://github.com/constansino"><img src="https://avatars.githubusercontent.com/u/65108260?v=4&s=48" width="48" height="48" alt="constansino" title="constansino"/></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/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/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryan" title="ryan"/></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/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/Solvely-Colin"><img src="https://avatars.githubusercontent.com/u/211764741?v=4&s=48" width="48" height="48" alt="Solvely-Colin" title="Solvely-Colin"/></a> <a href="https://github.com/pahdo"><img src="https://avatars.githubusercontent.com/u/12799392?v=4&s=48" width="48" height="48" alt="pahdo" title="pahdo"/></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 Watanabe" title="Kimitaka Watanabe"/></a> <a href="https://github.com/detecti1"><img src="https://avatars.githubusercontent.com/u/1622461?v=4&s=48" width="48" height="48" alt="Lilo" title="Lilo"/></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="Rajat Joshi" title="Rajat Joshi"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="Yuting Lin" title="Yuting Lin"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="Neo" title="Neo"/></a> <a href="https://github.com/wu-tian807"><img src="https://avatars.githubusercontent.com/u/61640083?v=4&s=48" width="48" height="48" alt="wu-tian807" title="wu-tian807"/></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/crimeacs"><img src="https://avatars.githubusercontent.com/u/35071559?v=4&s=48" width="48" height="48" alt="crimeacs" title="crimeacs"/></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/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/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/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="Manik Vahsith" title="Manik Vahsith"/></a> <a href="https://github.com/alexgleason"><img src="https://avatars.githubusercontent.com/u/3639540?v=4&s=48" width="48" height="48" alt="alexgleason" title="alexgleason"/></a> <a href="https://github.com/nicholascyh"><img src="https://avatars.githubusercontent.com/u/188132635?v=4&s=48" width="48" height="48" alt="Nicholas" title="Nicholas"/></a> <a href="https://github.com/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="Stephen Brian King" title="Stephen Brian King"/></a> <a href="https://github.com/justinhuangcode"><img src="https://avatars.githubusercontent.com/u/252443740?v=4&s=48" width="48" height="48" alt="justinhuangcode" title="justinhuangcode"/></a> <a href="https://github.com/mahanandhi"><img src="https://avatars.githubusercontent.com/u/46371575?v=4&s=48" width="48" height="48" alt="mahanandhi" title="mahanandhi"/></a> <a href="https://github.com/andreesg"><img src="https://avatars.githubusercontent.com/u/810322?v=4&s=48" width="48" height="48" alt="andreesg" title="andreesg"/></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/dinakars777"><img src="https://avatars.githubusercontent.com/u/250428393?v=4&s=48" width="48" height="48" alt="dinakars777" title="dinakars777"/></a>
<a href="https://github.com/Flash-LHR"><img src="https://avatars.githubusercontent.com/u/47357603?v=4&s=48" width="48" height="48" alt="Flash-LHR" title="Flash-LHR"/></a> <a href="https://github.com/divisonofficer"><img src="https://avatars.githubusercontent.com/u/41609506?v=4&s=48" width="48" height="48" alt="JINNYEONG KIM" title="JINNYEONG KIM"/></a> <a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol Zero" title="Protocol Zero"/></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/Limitless2023"><img src="https://avatars.githubusercontent.com/u/127183162?v=4&s=48" width="48" height="48" alt="Limitless" title="Limitless"/></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/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/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/JayMishra-source"><img src="https://avatars.githubusercontent.com/u/82963117?v=4&s=48" width="48" height="48" alt="JayMishra-source" title="JayMishra-source"/></a> <a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></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/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/echoVic"><img src="https://avatars.githubusercontent.com/u/16428813?v=4&s=48" width="48" height="48" alt="echoVic" title="echoVic"/></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/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/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/dddabtc"><img src="https://avatars.githubusercontent.com/u/104875499?v=4&s=48" width="48" height="48" alt="dddabtc" title="dddabtc"/></a> <a href="https://github.com/JonathanWorks"><img src="https://avatars.githubusercontent.com/u/124476234?v=4&s=48" width="48" height="48" alt="Jonathan Works" title="Jonathan Works"/></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/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/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="Shivam Kumar Raut" title="Shivam Kumar Raut"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="Mykyta Bozhenko" title="Mykyta Bozhenko"/></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/ThomsenDrake"><img src="https://avatars.githubusercontent.com/u/120344051?v=4&s=48" width="48" height="48" alt="ThomsenDrake" title="ThomsenDrake"/></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/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/jadilson12"><img src="https://avatars.githubusercontent.com/u/36805474?v=4&s=48" width="48" height="48" alt="jadilson12" title="jadilson12"/></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/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/emonty"><img src="https://avatars.githubusercontent.com/u/95156?v=4&s=48" width="48" height="48" alt="emonty" title="emonty"/></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/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/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/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/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/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/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/17jmumford"><img src="https://avatars.githubusercontent.com/u/36290330?v=4&s=48" width="48" height="48" alt="Jeremy Mumford" title="Jeremy Mumford"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="Kenny Lee" title="Kenny Lee"/></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/widingmarcus-cyber"><img src="https://avatars.githubusercontent.com/u/245375637?v=4&s=48" width="48" height="48" alt="widingmarcus-cyber" title="widingmarcus-cyber"/></a> <a href="https://github.com/DylanWoodAkers"><img src="https://avatars.githubusercontent.com/u/253595314?v=4&s=48" width="48" height="48" alt="DylanWoodAkers" title="DylanWoodAkers"/></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/boris721"><img src="https://avatars.githubusercontent.com/u/257853888?v=4&s=48" width="48" height="48" alt="boris721" title="boris721"/></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/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/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="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/sumleo"><img src="https://avatars.githubusercontent.com/u/29517764?v=4&s=48" width="48" height="48" alt="sumleo" title="sumleo"/></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/akyourowngames"><img src="https://avatars.githubusercontent.com/u/123736861?v=4&s=48" width="48" height="48" alt="akyourowngames" title="akyourowngames"/></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/Dithilli"><img src="https://avatars.githubusercontent.com/u/41286037?v=4&s=48" width="48" height="48" alt="Dithilli" title="Dithilli"/></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/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/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/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/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="Oren" title="Oren"/></a> <a href="https://github.com/shtse8"><img src="https://avatars.githubusercontent.com/u/8020099?v=4&s=48" width="48" height="48" alt="shtse8" title="shtse8"/></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/thesomewhatyou"><img src="https://avatars.githubusercontent.com/u/162917831?v=4&s=48" width="48" height="48" alt="thesomewhatyou" title="thesomewhatyou"/></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/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/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/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/ghsmc"><img src="https://avatars.githubusercontent.com/u/68118719?v=4&s=48" width="48" height="48" alt="ghsmc" title="ghsmc"/></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/ibrahimq21"><img src="https://avatars.githubusercontent.com/u/8392472?v=4&s=48" width="48" height="48" alt="ibrahimq21" title="ibrahimq21"/></a> <a href="https://github.com/irtiq7"><img src="https://avatars.githubusercontent.com/u/3823029?v=4&s=48" width="48" height="48" alt="irtiq7" title="irtiq7"/></a> <a href="https://github.com/jeann2013"><img src="https://avatars.githubusercontent.com/u/3299025?v=4&s=48" width="48" height="48" alt="jeann2013" title="jeann2013"/></a> <a href="https://github.com/jogelin"><img src="https://avatars.githubusercontent.com/u/954509?v=4&s=48" width="48" height="48" alt="jogelin" title="jogelin"/></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/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="Justin Ling" title="Justin Ling"/></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/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/ZetiMente"><img src="https://avatars.githubusercontent.com/u/76985631?v=4&s=48" width="48" height="48" alt="Matthew" title="Matthew"/></a> <a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="MattQ" title="MattQ"/></a> <a href="https://github.com/Milofax"><img src="https://avatars.githubusercontent.com/u/2537423?v=4&s=48" width="48" height="48" alt="Milofax" title="Milofax"/></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/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/pejmanjohn"><img src="https://avatars.githubusercontent.com/u/481729?v=4&s=48" width="48" height="48" alt="pejmanjohn" title="pejmanjohn"/></a> <a href="https://github.com/ProspectOre"><img src="https://avatars.githubusercontent.com/u/54486432?v=4&s=48" width="48" height="48" alt="ProspectOre" title="ProspectOre"/></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/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/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></a> <a href="https://github.com/santiagomed"><img src="https://avatars.githubusercontent.com/u/30184543?v=4&s=48" width="48" height="48" alt="santiagomed" title="santiagomed"/></a> <a href="https://github.com/stevebot-alive"><img src="https://avatars.githubusercontent.com/u/261149299?v=4&s=48" width="48" height="48" alt="Steve (OpenClaw)" title="Steve (OpenClaw)"/></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/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4&s=48" width="48" height="48" alt="AkashKobal" title="AkashKobal"/></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/awkoy"><img src="https://avatars.githubusercontent.com/u/13995636?v=4&s=48" width="48" height="48" alt="awkoy" title="awkoy"/></a>
<a href="https://github.com/battman21"><img src="https://avatars.githubusercontent.com/u/2656916?v=4&s=48" width="48" height="48" alt="battman21" title="battman21"/></a> <a href="https://github.com/BinHPdev"><img src="https://avatars.githubusercontent.com/u/219093083?v=4&s=48" width="48" height="48" alt="BinHPdev" title="BinHPdev"/></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/dashed"><img src="https://avatars.githubusercontent.com/u/139499?v=4&s=48" width="48" height="48" alt="dashed" title="dashed"/></a> <a href="https://github.com/dawondyifraw"><img src="https://avatars.githubusercontent.com/u/9797257?v=4&s=48" width="48" height="48" alt="dawondyifraw" title="dawondyifraw"/></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/hyojin"><img src="https://avatars.githubusercontent.com/u/3413183?v=4&s=48" width="48" height="48" alt="hyojin" title="hyojin"/></a> <a href="https://github.com/joeykrug"><img src="https://avatars.githubusercontent.com/u/5925937?v=4&s=48" width="48" height="48" alt="joeykrug" title="joeykrug"/></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/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></a> <a href="https://github.com/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="Mark Liu" title="Mark Liu"/></a> <a href="https://github.com/natedenh"><img src="https://avatars.githubusercontent.com/u/13399956?v=4&s=48" width="48" height="48" alt="natedenh" title="natedenh"/></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/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/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/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></a> <a href="https://github.com/uli-will-code"><img src="https://avatars.githubusercontent.com/u/49715419?v=4&s=48" width="48" height="48" alt="uli-will-code" title="uli-will-code"/></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/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/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/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST" title="CJWTRUST"/></a> <a href="https://github.com/cordx56"><img src="https://avatars.githubusercontent.com/u/23298744?v=4&s=48" width="48" height="48" alt="cordx56" title="cordx56"/></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/Grynn"><img src="https://avatars.githubusercontent.com/u/212880?v=4&s=48" width="48" height="48" alt="Grynn" title="Grynn"/></a> <a href="https://github.com/huntharo"><img src="https://avatars.githubusercontent.com/u/5617868?v=4&s=48" width="48" height="48" alt="huntharo" title="huntharo"/></a> <a href="https://github.com/hydro13"><img src="https://avatars.githubusercontent.com/u/6640526?v=4&s=48" width="48" height="48" alt="hydro13" title="hydro13"/></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/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/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/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/loeclos"><img src="https://avatars.githubusercontent.com/u/116607327?v=4&s=48" width="48" height="48" alt="loeclos" title="loeclos"/></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/MisterGuy420"><img src="https://avatars.githubusercontent.com/u/255743668?v=4&s=48" width="48" height="48" alt="MisterGuy420" title="MisterGuy420"/></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/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/RamiNoodle733"><img src="https://avatars.githubusercontent.com/u/117773986?v=4&s=48" width="48" height="48" alt="RamiNoodle733" title="RamiNoodle733"/></a> <a href="https://github.com/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="Raymond Berger" title="Raymond Berger"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="Rob Axelsen" title="Rob Axelsen"/></a> <a href="https://github.com/sauerdaniel"><img src="https://avatars.githubusercontent.com/u/81422812?v=4&s=48" width="48" height="48" alt="sauerdaniel" title="sauerdaniel"/></a> <a href="https://github.com/SleuthCo"><img src="https://avatars.githubusercontent.com/u/259695222?v=4&s=48" width="48" height="48" alt="SleuthCo" title="SleuthCo"/></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/TaKO8Ki"><img src="https://avatars.githubusercontent.com/u/41065217?v=4&s=48" width="48" height="48" alt="TaKO8Ki" title="TaKO8Ki"/></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/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/8BlT"><img src="https://avatars.githubusercontent.com/u/162764392?v=4&s=48" width="48" height="48" alt="8BlT" title="8BlT"/></a> <a href="https://github.com/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></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/afurm"><img src="https://avatars.githubusercontent.com/u/6375192?v=4&s=48" width="48" height="48" alt="afurm" title="afurm"/></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/akari-musubi"><img src="https://avatars.githubusercontent.com/u/259925157?v=4&s=48" width="48" height="48" alt="akari-musubi" title="akari-musubi"/></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/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/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/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/bennewton999"><img src="https://avatars.githubusercontent.com/u/458991?v=4&s=48" width="48" height="48" alt="bennewton999" title="bennewton999"/></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/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/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/dario-github"><img src="https://avatars.githubusercontent.com/u/40749119?v=4&s=48" width="48" height="48" alt="dario-github" title="dario-github"/></a> <a href="https://github.com/DarwinsBuddy"><img src="https://avatars.githubusercontent.com/u/490836?v=4&s=48" width="48" height="48" alt="DarwinsBuddy" title="DarwinsBuddy"/></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/dcantu96"><img src="https://avatars.githubusercontent.com/u/32658690?v=4&s=48" width="48" height="48" alt="dcantu96" title="dcantu96"/></a> <a href="https://github.com/dndodson"><img src="https://avatars.githubusercontent.com/u/5123985?v=4&s=48" width="48" height="48" alt="dndodson" title="dndodson"/></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/EmberCF"><img src="https://avatars.githubusercontent.com/u/258471336?v=4&s=48" width="48" height="48" alt="EmberCF" title="EmberCF"/></a> <a href="https://github.com/ephraimm"><img src="https://avatars.githubusercontent.com/u/2803669?v=4&s=48" width="48" height="48" alt="ephraimm" title="ephraimm"/></a> <a href="https://github.com/ereid7"><img src="https://avatars.githubusercontent.com/u/27597719?v=4&s=48" width="48" height="48" alt="ereid7" title="ereid7"/></a> <a href="https://github.com/eternauta1337"><img src="https://avatars.githubusercontent.com/u/550409?v=4&s=48" width="48" height="48" alt="eternauta1337" title="eternauta1337"/></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/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/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/ikari-pl"><img src="https://avatars.githubusercontent.com/u/811702?v=4&s=48" width="48" height="48" alt="ikari-pl" title="ikari-pl"/></a>
<a href="https://github.com/kesor"><img src="https://avatars.githubusercontent.com/u/7056?v=4&s=48" width="48" height="48" alt="kesor" title="kesor"/></a> <a href="https://github.com/knocte"><img src="https://avatars.githubusercontent.com/u/331303?v=4&s=48" width="48" height="48" alt="knocte" title="knocte"/></a> <a href="https://github.com/MackDing"><img src="https://avatars.githubusercontent.com/u/19878893?v=4&s=48" width="48" height="48" alt="MackDing" title="MackDing"/></a> <a href="https://github.com/nobrainer-tech"><img src="https://avatars.githubusercontent.com/u/445466?v=4&s=48" width="48" height="48" alt="nobrainer-tech" title="nobrainer-tech"/></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/Olshansk"><img src="https://avatars.githubusercontent.com/u/1892194?v=4&s=48" width="48" height="48" alt="Olshansk" title="Olshansk"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="Pratham Dubey" title="Pratham Dubey"/></a> <a href="https://github.com/Raikan10"><img src="https://avatars.githubusercontent.com/u/20675476?v=4&s=48" width="48" height="48" alt="Raikan10" title="Raikan10"/></a> <a href="https://github.com/SecondThread"><img src="https://avatars.githubusercontent.com/u/18317476?v=4&s=48" width="48" height="48" alt="SecondThread" title="SecondThread"/></a> <a href="https://github.com/Swader"><img src="https://avatars.githubusercontent.com/u/1430603?v=4&s=48" width="48" height="48" alt="Swader" title="Swader"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></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/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/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/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/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/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/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/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/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/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></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/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></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/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></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/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></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/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/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/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/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/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/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></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/jarvis89757"><img src="https://avatars.githubusercontent.com/u/258175441?v=4&s=48" width="48" height="48" alt="jarvis89757" title="jarvis89757"/></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/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="nonggia.liang" title="nonggia.liang"/></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/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/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="cdorsey" title="cdorsey"/></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/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/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/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/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></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/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></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="ryan" title="ryan"/></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/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/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="sbking" title="sbking"/></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/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/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/calvin-hpnet"><img src="https://avatars.githubusercontent.com/u/258432838?v=4&s=48" width="48" height="48" alt="calvin-hpnet" title="calvin-hpnet"/></a> <a href="https://github.com/gitpds"><img src="https://avatars.githubusercontent.com/u/78130276?v=4&s=48" width="48" height="48" alt="gitpds" title="gitpds"/></a> <a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></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/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/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/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/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></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/jabezborja"><img src="https://avatars.githubusercontent.com/u/64759159?v=4&s=48" width="48" height="48" alt="jabezborja" title="jabezborja"/></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/patrickshao"><img src="https://avatars.githubusercontent.com/u/5953037?v=4&s=48" width="48" height="48" alt="patrickshao" title="patrickshao"/></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/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/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/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/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/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/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/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="orenyomtov" title="orenyomtov"/></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/hudson-rivera"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="hudson-rivera" title="hudson-rivera"/></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/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/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/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/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/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/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></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/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/search?q=damaozi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="damaozi" title="damaozi"/></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/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="liuxiaopai-ai" title="liuxiaopai-ai"/></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/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></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/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/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/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/danielcadenhead"><img src="https://avatars.githubusercontent.com/u/195258443?v=4&s=48" width="48" height="48" alt="danielcadenhead" title="danielcadenhead"/></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/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/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="RayBB" title="RayBB"/></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/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/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></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/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=Claude%20Code"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Claude Code" title="Claude Code"/></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/Fronut"><img src="https://avatars.githubusercontent.com/u/165925262?v=4&s=48" width="48" height="48" alt="Fronut" title="Fronut"/></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/kossoy"><img src="https://avatars.githubusercontent.com/u/51094?v=4&s=48" width="48" height="48" alt="kossoy" title="kossoy"/></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/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></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/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/markusbkoch"><img src="https://avatars.githubusercontent.com/u/34865315?v=4&s=48" width="48" height="48" alt="markusbkoch" title="markusbkoch"/></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/search?q=minghinmatthewlam"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></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=mudrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mudrii" title="mudrii"/></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/search?q=myfunc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="myfunc" title="myfunc"/></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/search?q=pasogott"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/search?q=plum-dawg"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/search?q=pookNast"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pookNast" title="pookNast"/></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/search?q=rafaelreis-r"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></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=robhparker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="robhparker" title="robhparker"/></a> <a href="https://github.com/rohansachinpatil"><img src="https://avatars.githubusercontent.com/u/172933149?v=4&s=48" width="48" height="48" alt="rohansachinpatil" title="rohansachinpatil"/></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/search?q=seans-openclawbot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="seans-openclawbot" title="seans-openclawbot"/></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/search?q=shatner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="shatner" title="shatner"/></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/search?q=spiceoogway"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></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=succ985"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="succ985" title="succ985"/></a> <a href="https://github.com/Suvink"><img src="https://avatars.githubusercontent.com/u/10671497?v=4&s=48" width="48" height="48" alt="Suvink" title="Suvink"/></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=tewatia"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="tewatia" title="tewatia"/></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/search?q=therealZpoint-bot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="therealZpoint-bot" title="therealZpoint-bot"/></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=uos-status"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/vcastellm"><img src="https://avatars.githubusercontent.com/u/47026?v=4&s=48" width="48" height="48" alt="vcastellm" title="vcastellm"/></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/search?q=void"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="void" title="void"/></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/search?q=zhixian"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="zhixian" title="zhixian"/></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/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/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/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/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>

View File

@@ -13,7 +13,7 @@ Report vulnerabilities directly to the repository where the issue lives:
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
For issues that don't fit a specific repo, or if you're unsure, email **[security@openclaw.ai](mailto:security@openclaw.ai)** and we'll route it.
For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it.
For full reporting instructions see our [Trust page](https://trust.openclaw.ai).
@@ -30,43 +30,6 @@ For full reporting instructions see our [Trust page](https://trust.openclaw.ai).
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.
### Report Acceptance Gate (Triage Fast Path)
For fastest triage, include all of the following:
- Exact vulnerable path (`file`, function, and line range) on a current revision.
- Tested version details (OpenClaw version and/or commit SHA).
- Reproducible PoC against latest `main` or latest released version.
- Demonstrated impact tied to OpenClaw's documented trust boundaries.
- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services).
- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config.
- Scope check explaining why the report is **not** covered by the Out of Scope section below.
Reports that miss these requirements may be closed as `invalid` or `no-action`.
### Common False-Positive Patterns
These are frequently reported but are typically closed with no code change:
- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope).
- Operator-intended local features (for example TUI local `!` shell) presented as remote injection.
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Missing HSTS findings on default local/loopback deployments.
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
### Duplicate Report Handling
- Search existing advisories before filing.
- Include likely duplicate GHSA IDs in your report when applicable.
- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report.
## Security & Trust
**Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development.
@@ -80,115 +43,11 @@ The best way to help the project right now is by sending PRs.
When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200.
## Operator Trust Model (Important)
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries.
- If one operator can view data from another operator on the same gateway, that is expected in this trust model.
- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary.
- Recommended mode: one user per machine/host (or VPS), one gateway for that user, and one or more agents inside that gateway.
- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user.
- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default.
- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`.
- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host.
- Implicit exec calls (no explicit host in the tool call) follow the same behavior.
- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy.
## Trusted Plugin Concept (Core)
Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Installing or enabling a plugin grants it the same trust level as local code running on that gateway host.
- Plugin behavior such as reading env/files or running host commands is expected inside this trust boundary.
- Security reports must show a boundary bypass (for example unauthenticated plugin load, allowlist/policy bypass, or sandbox/path-safety bypass), not only malicious behavior from a trusted-installed plugin.
## Out of Scope
- Public Internet Exposure
- Using OpenClaw in ways that the docs recommend not to
- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads)
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These may be accepted as hardening improvements, but not as vulnerabilities.
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
## Deployment Assumptions
OpenClaw security guidance assumes:
- The host where OpenClaw runs is within a trusted OS/admin boundary.
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.
- Multiple gateway instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/VPS per user).
## One-User Trust Model (Personal Assistant)
OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus."
- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions.
- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries.
- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary.
- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only.
- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime.
- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk.
## Agent and Model Assumptions
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
## Gateway and Node trust concept
OpenClaw separates routing from execution, but both remain inside the same operator trust boundary:
- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway.
- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node.
- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary.
- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary.
## Workspace Memory Trust Boundary
`MEMORY.md` and `memory/*.md` are plain workspace files and are treated as trusted local operator state.
- If someone can edit workspace memory files, they already crossed the trusted operator boundary.
- Memory search indexing/recall over those files is expected behavior, not a sandbox/security boundary.
- Example report pattern considered out of scope: "attacker writes malicious content into `memory/*.md`, then `memory_search` returns it."
- If you need isolation between mutually untrusted users, split by OS user or host and run separate gateways.
## Plugin Trust Boundary
Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code.
- Plugins can execute with the same OS privileges as the OpenClaw process.
- Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary.
- Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids.
## Temp Folder Boundary (Media/Sandbox)
OpenClaw uses a dedicated temp root for local media handoff and sandbox-adjacent temp artifacts:
- Preferred temp root: `/tmp/openclaw` (when available and safe on the host).
- Fallback temp root: `os.tmpdir()/openclaw` (or `openclaw-<uid>` on multi-user hosts).
Security boundary notes:
- Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root.
- Arbitrary host tmp paths are not treated as trusted media roots.
- Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files.
- Enforcement reference points:
- temp root resolver: `src/infra/tmp-openclaw-dir.ts`
- SDK temp helpers: `src/plugin-sdk/temp-path.ts`
- messaging/channel tmp guardrail: `scripts/check-no-random-messaging-tmp.mjs`
- Prompt injection attacks
## Operational Guidance
@@ -199,7 +58,7 @@ For threat model + hardening guidance (including `openclaw security audit --deep
### Tool filesystem hardening
- `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory.
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory.
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory.
- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution.
### Web Interface Safety
@@ -209,14 +68,6 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`).
- Config: `gateway.bind="loopback"` (default).
- CLI: `openclaw gateway run --bind loopback`.
- `gateway.controlUi.dangerouslyDisableDeviceAuth` is intended for localhost-only break-glass use.
- OpenClaw keeps deployment flexibility by design and does not hard-forbid non-local setups.
- Non-local and other risky configurations are surfaced by `openclaw security audit` as dangerous findings.
- This operator-selected tradeoff is by design and not, by itself, a security vulnerability.
- Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet).
- Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls.
- Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`.
- This deployment model alone is not a security vulnerability.
- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.

110
VISION.md
View File

@@ -1,110 +0,0 @@
## OpenClaw Vision
OpenClaw is the AI that actually does things.
It runs on your devices, in your channels, with your rules.
This document explains the current state and direction of the project.
We are still early, so iteration is fast.
Project overview and developer docs: [`README.md`](README.md)
Contribution guide: [`CONTRIBUTING.md`](CONTRIBUTING.md)
OpenClaw started as a personal playground to learn AI and build something genuinely useful:
an assistant that can run real tasks on a real computer.
It evolved through several names and shells: Warelay -> Clawdbot -> Moltbot -> OpenClaw.
The goal: a personal assistant that is easy to use, supports a wide range of platforms, and respects privacy and security.
The current focus is:
Priority:
- Security and safe defaults
- Bug fixes and stability
- Setup reliability and first-run UX
Next priorities:
- Supporting all major model providers
- Improving support for major messaging channels (and adding a few high-demand ones)
- Performance and test infrastructure
- Better computer-use and agent harness capabilities
- Ergonomics across CLI and web frontend
- Companion apps on macOS, iOS, Android, Windows, and Linux
Contribution rules:
- One PR = one issue/topic. Do not bundle multiple unrelated fixes/features.
- PRs over ~5,000 changed lines are reviewed only in exceptional circumstances.
- Do not open large batches of tiny PRs at once; each PR has review cost.
- For very small related fixes, grouping into one focused PR is encouraged.
## Security
Security in OpenClaw is a deliberate tradeoff: strong defaults without killing capability.
The goal is to stay powerful for real work while making risky paths explicit and operator-controlled.
Canonical security policy and reporting:
- [`SECURITY.md`](SECURITY.md)
We prioritize secure defaults, but also expose clear knobs for trusted high-power workflows.
## Plugins & Memory
OpenClaw has an extensive plugin API.
Core stays lean; optional capability should usually ship as plugins.
Preferred plugin path is npm package distribution plus local extension loading for development.
If you build a plugin, host and maintain it in your own repository.
The bar for adding optional plugins to core is intentionally high.
Plugin docs: [`docs/tools/plugin.md`](docs/tools/plugin.md)
Community plugin listing + PR bar: https://docs.openclaw.ai/plugins/community
Memory is a special plugin slot where only one memory plugin can be active at a time.
Today we ship multiple memory options; over time we plan to converge on one recommended default path.
### Skills
We still ship some bundled skills for baseline UX.
New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default.
Core skill additions should be rare and require a strong product or security reason.
### MCP Support
OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter
This keeps MCP integration flexible and decoupled from core runtime:
- add or change MCP servers without restarting the gateway
- keep core tool/context surface lean
- reduce MCP churn impact on core stability and security
For now, we prefer this bridge model over building first-class MCP runtime into core.
If there is an MCP server or feature `mcporter` does not support yet, please open an issue there.
### Setup
OpenClaw is currently terminal-first by design.
This keeps setup explicit: users see docs, auth, permissions, and security posture up front.
Long term, we want easier onboarding flows as hardening matures.
We do not want convenience wrappers that hide critical security decisions from users.
### Why TypeScript?
OpenClaw is primarily an orchestration system: prompts, tools, protocols, and integrations.
TypeScript was chosen to keep OpenClaw hackable by default.
It is widely known, fast to iterate in, and easy to read, modify, and extend.
## What We Will Not Merge (For Now)
- New core skills when they can live on ClawHub
- Full-doc translation sets for all docs (deferred; we plan AI-generated translations later)
- Commercial service integrations that do not clearly fit the model-provider category
- Wrapper channels around already supported channels without a clear capability or security gap
- First-class MCP runtime in core when `mcporter` already provides the integration path
- Agent-hierarchy frameworks (manager-of-managers / nested planner trees) as a default architecture
- Heavy orchestration layers that duplicate existing agent and tool infrastructure
This list is a roadmap guardrail, not a law of physics.
Strong user demand and strong technical rationale can change it.

View File

@@ -144,7 +144,7 @@
<title>2026.2.15</title>
<pubDate>Mon, 16 Feb 2026 05:04:34 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>202602150</sparkle:version>
<sparkle:version>11213</sparkle:version>
<sparkle:shortVersionString>2026.2.15</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.15</h2>
@@ -209,84 +209,105 @@
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
</item>
<item>
<title>2026.2.25</title>
<pubDate>Thu, 26 Feb 2026 05:14:17 +0100</pubDate>
<title>2026.2.13</title>
<pubDate>Sat, 14 Feb 2026 04:30:23 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>14883</sparkle:version>
<sparkle:shortVersionString>2026.2.25</sparkle:shortVersionString>
<sparkle:version>9846</sparkle:version>
<sparkle:shortVersionString>2026.2.13</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.25</h2>
<description><![CDATA[<h2>OpenClaw 2026.2.13</h2>
<h3>Changes</h3>
<ul>
<li>Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.</li>
<li>Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.</li>
<li>UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.</li>
<li>Heartbeat/Config: replace heartbeat DM toggle with <code>agents.defaults.heartbeat.directPolicy</code> (<code>allow</code> | <code>block</code>; also supported per-agent via <code>agents.list[].heartbeat.directPolicy</code>) for clearer delivery semantics.</li>
<li>Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.</li>
<li>Branding/Docs + Apple surfaces: replace remaining <code>bot.molt</code> launchd label, bundle-id, logging subsystem, and command examples with <code>ai.openclaw</code> across docs, iOS app surfaces, helper scripts, and CLI test fixtures.</li>
<li>Agents/Config: remind agents to call <code>config.schema</code> before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.</li>
<li>Dependencies: update workspace dependency pins and lockfile (Bedrock SDK <code>3.998.0</code>, <code>@mariozechner/pi-*</code> <code>0.55.1</code>, TypeScript native preview <code>7.0.0-dev.20260225.1</code>) while keeping <code>@buape/carbon</code> pinned.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> Heartbeat direct/DM delivery default is now <code>allow</code> again. To keep DM-blocked behavior from <code>2026.2.24</code>, set <code>agents.defaults.heartbeat.directPolicy: "block"</code> (or per-agent override).</li>
<li>Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.</li>
<li>Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.</li>
<li>Slack/Plugins: add thread-ownership outbound gating via <code>message_sending</code> hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.</li>
<li>Agents: add synthetic catalog support for <code>hf:zai-org/GLM-5</code>. (#15867) Thanks @battman21.</li>
<li>Skills: remove duplicate <code>local-places</code> Google Places skill/proxy and keep <code>goplaces</code> as the single supported Google Places path.</li>
<li>Agents: add pre-prompt context diagnostics (<code>messages</code>, <code>systemPromptChars</code>, <code>promptChars</code>, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without <code>message_id</code> as delivery failures (instead of false-success <code>"unknown"</code> IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.</li>
<li>Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)</li>
<li>Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable <code>session.parentForkMaxTokens</code> (default <code>100000</code>, <code>0</code> disables). (#26912) Thanks @markshields-tl.</li>
<li>Cron/Message multi-account routing: honor explicit <code>delivery.accountId</code> for isolated cron delivery resolution, and when <code>message.send</code> omits <code>accountId</code>, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.</li>
<li>Gateway/Message media roots: thread <code>agentId</code> through gateway <code>send</code> RPC and prefer explicit <code>agentId</code> over session/default resolution so non-default agent workspace media sends no longer fail with <code>LocalMediaAccessError</code>; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.</li>
<li>Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin.</li>
<li>Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed <code>delivered</code>, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)</li>
<li>LINE/Lifecycle: keep LINE <code>startAccount</code> pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.</li>
<li>Discord/Gateway: capture and drain startup-time gateway <code>error</code> events before lifecycle listeners attach so early <code>Fatal Gateway error: 4014</code> closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.</li>
<li>Discord/Inbound text: preserve embed <code>title</code> + <code>description</code> fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.</li>
<li>Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to <code>file</code> so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.</li>
<li>Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)</li>
<li>Telegram/Markdown spoilers: keep valid <code>||spoiler||</code> pairs while leaving unmatched trailing <code>||</code> delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.</li>
<li>Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example <code>c0abc12345</code>) correctly match Slack runtime IDs (<code>C0ABC12345</code>) under <code>groupPolicy: "allowlist"</code>, preventing silent channel-event drops. (#26878) Thanks @lbo728.</li>
<li>Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.</li>
<li>Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.</li>
<li>Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including <code>NO_REPLY</code>, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.</li>
<li>Voice-call/TTS tools: hide the <code>tts</code> tool when the message provider is <code>voice</code>, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)</li>
<li>Agents/Tools: normalize non-standard plugin tool results that omit <code>content</code> so embedded runs no longer crash with <code>Cannot read properties of undefined (reading 'filter')</code> after tool completion (including <code>tesseramemo_query</code>). (#27007)</li>
<li>Cron/Model overrides: when isolated <code>payload.model</code> is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.</li>
<li>Agents/Model fallback: keep explicit text + image fallback chains reachable even when <code>agents.defaults.models</code> allowlists are present, prefer explicit run <code>agentId</code> over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify <code>model_cooldown</code> / <code>cooling down</code> errors as <code>rate_limit</code> so failover continues. (#11972, #24137, #17231)</li>
<li>Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of <code>disabledReason</code> only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for <code>rate_limit</code>. (#23816) thanks @ramezgaberiel.</li>
<li>Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.</li>
<li>Models/Auth probes: map permanent auth failover reasons (<code>auth_permanent</code>, for example revoked keys) into probe auth status instead of <code>unknown</code>, so <code>openclaw models status --probe</code> reports actionable auth failures. (#25754) thanks @rrenamed.</li>
<li>Hooks/Inbound metadata: include <code>guildId</code> and <code>channelName</code> in <code>message_received</code> metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.</li>
<li>Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get <code>CommandAuthorized: true</code> on modal/button events. (#26119) Thanks @bmendonca3.</li>
<li>Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.</li>
<li>Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (<code>2026.2.25</code>). Thanks @luz-oasis for reporting.</li>
<li>Security/Gateway trusted proxy: require <code>operator</code> role for the Control UI trusted-proxy pairing bypass so unpaired <code>node</code> sessions can no longer connect via <code>client.id=control-ui</code> and invoke node event methods. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy <code>oauth.json</code> onboarding path that exposed the PKCE verifier via OAuth <code>state</code>; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (<code>2026.2.25</code>). Thanks @zdi-disclosures for reporting.</li>
<li>Security/Microsoft Teams file consent: bind <code>fileConsent/invoke</code> upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked <code>uploadId</code> values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Gateway: harden <code>agents.files</code> path handling to block out-of-workspace symlink targets for <code>agents.files.get</code>/<code>agents.files.set</code>, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.</li>
<li>Security/Workspace FS: reject hardlinked workspace file aliases in <code>tools.fs.workspaceOnly</code> and <code>tools.exec.applyPatch.workspaceOnly</code> boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before <code>setFiles</code>, with regression coverage for strict missing-path handling.</li>
<li>Security/Exec approvals: bind <code>system.run</code> approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Exec approvals: harden approval-bound <code>system.run</code> execution on node hosts by rejecting symlink <code>cwd</code> paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under <code>dmPolicy</code>/<code>groupPolicy</code>; reaction notifications now require channel access checks first. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild <code>groupPolicy</code> channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Slack reactions + pins: gate <code>reaction_*</code> and <code>pin_*</code> system-event enqueue through shared sender authorization so DM <code>dmPolicy</code>/<code>allowFrom</code> and channel <code>users</code> allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Telegram reactions: enforce <code>dmPolicy</code>/<code>allowFrom</code> and group allowlist authorization on <code>message_reaction</code> events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Slack interactions: enforce channel/DM authorization and modal actor binding (<code>private_metadata.userId</code>) before enqueueing <code>block_action</code>/<code>view_submission</code>/<code>view_closed</code> system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.</li>
<li>Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.</li>
<li>Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.</li>
<li>Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.</li>
<li>Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.</li>
<li>Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.</li>
<li>Security/SSRF guard: classify IPv6 multicast literals (<code>ff00::/8</code>) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (<code>2026.2.25</code>). Thanks @zpbrent for reporting.</li>
<li>Tests/Low-memory stability: disable Vitest <code>vmForks</code> by default on low-memory local hosts (<code><64 GiB</code>), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with <code>setSessionRuntimeModel</code> usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.</li>
<li>Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.</li>
<li>Auto-reply/Threading: auto-inject implicit reply threading so <code>replyToMode</code> works without requiring model-emitted <code>[[reply_to_current]]</code>, while preserving <code>replyToMode: "off"</code> behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under <code>replyToMode: "first"</code>. (#14976) Thanks @Diaspar4u.</li>
<li>Outbound/Threading: pass <code>replyTo</code> and <code>threadId</code> from <code>message send</code> tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.</li>
<li>Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.</li>
<li>Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.</li>
<li>Web UI: add <code>img</code> to DOMPurify allowed tags and <code>src</code>/<code>alt</code> to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.</li>
<li>Telegram/Matrix: treat MP3 and M4A (including <code>audio/mp4</code>) as voice-compatible for <code>asVoice</code> routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.</li>
<li>WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending <code>"file"</code>. (#15594) Thanks @TsekaLuk.</li>
<li>Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.</li>
<li>Telegram: scope skill commands to the resolved agent for default accounts so <code>setMyCommands</code> no longer triggers <code>BOT_COMMANDS_TOO_MUCH</code> when multiple agents are configured. (#15599)</li>
<li>Discord: avoid misrouting numeric guild allowlist entries to <code>/channels/<guildId></code> by prefixing guild-only inputs with <code>guild:</code> during resolution. (#12326) Thanks @headswim.</li>
<li>MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (<code>29:...</code>, <code>8:orgid:...</code>) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.</li>
<li>Media: classify <code>text/*</code> MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.</li>
<li>Inbound/Web UI: preserve literal <code>\n</code> sequences when normalizing inbound text so Windows paths like <code>C:\\Work\\nxxx\\README.md</code> are not corrupted. (#11547) Thanks @mcaxtr.</li>
<li>TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.</li>
<li>Providers/MiniMax: switch implicit MiniMax API-key provider from <code>openai-completions</code> to <code>anthropic-messages</code> with the correct Anthropic-compatible base URL, fixing <code>invalid role: developer (2013)</code> errors on MiniMax M2.5. (#15275) Thanks @lailoo.</li>
<li>Ollama/Agents: use resolved model/provider base URLs for native <code>/api/chat</code> streaming (including aliased providers), normalize <code>/v1</code> endpoints, and forward abort + <code>maxTokens</code> stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.</li>
<li>OpenAI Codex/Spark: implement end-to-end <code>gpt-5.3-codex-spark</code> support across fallback/thinking/model resolution and <code>models list</code> forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.</li>
<li>Agents/Codex: allow <code>gpt-5.3-codex-spark</code> in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.</li>
<li>Models/Codex: resolve configured <code>openai-codex/gpt-5.3-codex-spark</code> through forward-compat fallback during <code>models list</code>, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.</li>
<li>OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into <code>pi</code> <code>auth.json</code> so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.</li>
<li>Auth/OpenAI Codex: share OAuth login handling across onboarding and <code>models auth login --provider openai-codex</code>, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.</li>
<li>Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.</li>
<li>Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (<code>tokenProvider=huggingface</code> with <code>authChoice=apiKey</code>) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.</li>
<li>Onboarding/CLI: restore terminal state without resuming paused <code>stdin</code>, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.</li>
<li>Signal/Install: auto-install <code>signal-cli</code> via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary <code>Exec format error</code> failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.</li>
<li>macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.</li>
<li>Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.</li>
<li>Discord/Agents: apply channel/group <code>historyLimit</code> during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.</li>
<li>Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.</li>
<li>Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.</li>
<li>Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.</li>
<li>Heartbeat: allow explicit wake (<code>wake</code>) and hook wake (<code>hook:*</code>) reasons to run even when <code>HEARTBEAT.md</code> is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.</li>
<li>Auto-reply/Heartbeat: strip sentence-ending <code>HEARTBEAT_OK</code> tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.</li>
<li>Agents/Heartbeat: stop auto-creating <code>HEARTBEAT.md</code> during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.</li>
<li>Sessions/Agents: pass <code>agentId</code> when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with <code>Session file path must be within sessions directory</code>. (#15141) Thanks @Goldenmonstew.</li>
<li>Sessions/Agents: pass <code>agentId</code> through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.</li>
<li>Sessions: archive previous transcript files on <code>/new</code> and <code>/reset</code> session resets (including gateway <code>sessions.reset</code>) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.</li>
<li>Status/Sessions: stop clamping derived <code>totalTokens</code> to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.</li>
<li>CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid <code>source <(openclaw completion ...)</code> corruption. (#15481) Thanks @arosstale.</li>
<li>CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.</li>
<li>Security/Gateway + ACP: block high-risk tools (<code>sessions_spawn</code>, <code>sessions_send</code>, <code>gateway</code>, <code>whatsapp_login</code>) from HTTP <code>/tools/invoke</code> by default with <code>gateway.tools.{allow,deny}</code> overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting <code>allow_always</code>/<code>reject_always</code>. (#15390) Thanks @aether-ai-agent.</li>
<li>Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.</li>
<li>Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.</li>
<li>Security/Browser: constrain <code>POST /trace/stop</code>, <code>POST /wait/download</code>, and <code>POST /download</code> output paths to OpenClaw temp roots and reject traversal/escape paths.</li>
<li>Security/Canvas: serve A2UI assets via the shared safe-open path (<code>openFileWithinRoot</code>) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.</li>
<li>Security/WhatsApp: enforce <code>0o600</code> on <code>creds.json</code> and <code>creds.json.bak</code> on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.</li>
<li>Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.</li>
<li>Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective <code>gateway.nodes.denyCommands</code> entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.</li>
<li>Security/Audit: distinguish external webhooks (<code>hooks.enabled</code>) from internal hooks (<code>hooks.internal.enabled</code>) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.</li>
<li>Security/Onboarding: clarify multi-user DM isolation remediation with explicit <code>openclaw config set session.dmScope ...</code> commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.</li>
<li>Agents/Nodes: harden node exec approval decision handling in the <code>nodes</code> tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.</li>
<li>Android/Nodes: harden <code>app.update</code> by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.</li>
<li>Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.</li>
<li>Exec/Allowlist: allow multiline heredoc bodies (<code><<</code>, <code><<-</code>) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.</li>
<li>Config: preserve <code>${VAR}</code> env references when writing config files so <code>openclaw config set/apply/patch</code> does not persist secrets to disk. Thanks @thewilloftheshadow.</li>
<li>Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving <code>${VAR}</code> refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.</li>
<li>Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.</li>
<li>Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.</li>
<li>Config: accept <code>$schema</code> key in config file so JSON Schema editor tooling works without validation errors. (#14998)</li>
<li>Gateway/Tools Invoke: sanitize <code>/tools/invoke</code> execution failures while preserving <code>400</code> for tool input errors and returning <code>500</code> for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.</li>
<li>Gateway/Hooks: preserve <code>408</code> for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.</li>
<li>Plugins/Hooks: fire <code>before_tool_call</code> hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.</li>
<li>Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.</li>
<li>Agents/Image tool: cap image-analysis completion <code>maxTokens</code> by model capability (<code>min(4096, model.maxTokens)</code>) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.</li>
<li>Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent <code>tools.exec</code> overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.</li>
<li>Gateway/Agents: stop injecting a phantom <code>main</code> agent into gateway agent listings when <code>agents.list</code> explicitly excludes it. (#11450) Thanks @arosstale.</li>
<li>Process/Exec: avoid shell execution for <code>.exe</code> commands on Windows so env overrides work reliably in <code>runCommandWithTimeout</code>. Thanks @thewilloftheshadow.</li>
<li>Daemon/Windows: preserve literal backslashes in <code>gateway.cmd</code> command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.</li>
<li>Sandbox: pass configured <code>sandbox.docker.env</code> variables to sandbox containers at <code>docker create</code> time. (#15138) Thanks @stevebot-alive.</li>
<li>Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96.</li>
<li>Cron: add regression coverage for announce-mode isolated jobs so runs that already report <code>delivered: true</code> do not enqueue duplicate main-session relays, including delivery configs where <code>mode</code> is omitted and defaults to announce. (#15737) Thanks @brandonwise.</li>
<li>Cron: honor <code>deleteAfterRun</code> in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.</li>
<li>Web tools/web_fetch: prefer <code>text/markdown</code> responses for Cloudflare Markdown for Agents, add <code>cf-markdown</code> extraction for markdown bodies, and redact fetched URLs in <code>x-markdown-tokens</code> debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.</li>
<li>Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.</li>
<li>Memory: switch default local embedding model to the QAT <code>embeddinggemma-300m-qat-Q8_0</code> variant for better quality at the same footprint. (#15429) Thanks @azade-c.</li>
<li>Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.</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.25/OpenClaw-2026.2.25.zip" length="23078398" type="application/octet-stream" sparkle:edSignature="PJjvRhivhybV5bYr8u1C9Dyw4h8yePGwG8SFsr4QRqMSBYMEedraPJO3KNbkoChjclYUYf3oGcC4daNZnFvgBA=="/>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.13/OpenClaw-2026.2.13.zip" length="22902077" type="application/octet-stream" sparkle:edSignature="RpkwlPtB2yN7UOYZWfthV5grhDUcbhcHMeicdRA864Vo/P0Hnq5aHKmSvcbWkjHut96TC57bX+AeUrL7txpLCg=="/>
</item>
</channel>
</rss>

View File

@@ -1,26 +1,13 @@
## OpenClaw Android App
## OpenClaw Node (Android) (internal)
Status: **extremely alpha**. The app is actively being rebuilt from the ground up.
Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**.
### Rebuild Checklist
- [x] New 4-step onboarding flow
- [x] Connect tab with `Setup Code` + `Manual` modes
- [x] Encrypted persistence for gateway setup/auth state
- [x] Chat UI restyled
- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect)
- [ ] QR code scanning in onboarding
- [ ] Performance improvements
- [ ] Streaming support in chat UI
- [ ] Request camera/location and other permissions in onboarding/settings flow
- [ ] Push notifications for gateway/chat status updates
- [ ] Security hardening (biometric lock, token handling, safer defaults)
- [ ] Voice tab full functionality
- [ ] Screen tab full functionality
- [ ] Full end-to-end QA and release hardening
Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).
## Open in Android Studio
- Open the folder `apps/android`.
## Build / Run
@@ -34,105 +21,18 @@ cd apps/android
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
## Macrobenchmark (Startup + Frame Timing)
```bash
cd apps/android
./gradlew :benchmark:connectedDebugAndroidTest
```
Reports are written under:
- `apps/android/benchmark/build/reports/androidTests/connected/`
## Perf CLI (low-noise)
Deterministic startup measurement + hotspot extraction with compact CLI output:
```bash
cd apps/android
./scripts/perf-startup-benchmark.sh
./scripts/perf-startup-hotspots.sh
```
Benchmark script behavior:
- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations).
- Prints median/min/max/COV in one line.
- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`.
- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline <old-benchmarkData.json>`).
Hotspot script behavior:
- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`.
- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView).
- Writes raw `perf.data` path for deeper follow-up if needed.
## Run on a Real Android Phone (USB)
1) On phone, enable **Developer options** + **USB debugging**.
2) Connect by USB and accept the debugging trust prompt on phone.
3) Verify ADB can see the device:
```bash
adb devices -l
```
4) Install + launch debug build:
```bash
pnpm android:install
pnpm android:run
```
If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again.
### USB-only gateway testing (no LAN dependency)
Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`.
Terminal A (gateway):
```bash
pnpm openclaw gateway --port 18789 --verbose
```
Terminal B (USB tunnel):
```bash
adb reverse tcp:18789 tcp:18789
```
Then in app **Connect → Manual**:
- Host: `127.0.0.1`
- Port: `18789`
- TLS: off
## Hot Reload / Fast Iteration
This app is native Kotlin + Jetpack Compose.
- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement).
- For many non-structural code/resource changes: use Android Studio **Apply Changes**.
- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`).
- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`).
## Connect / Pair
1) Start the gateway (on your main machine):
1) Start the gateway (on your master” machine):
```bash
pnpm openclaw gateway --port 18789 --verbose
```
2) In the Android app:
- Open the **Connect** tab.
- Use **Setup Code** or **Manual** mode to connect.
- Open **Settings**
- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port).
3) Approve pairing (on the gateway machine):
```bash
openclaw nodes pending
openclaw nodes approve <requestId>
@@ -149,8 +49,3 @@ More details: `docs/platforms/android.md`.
- Camera:
- `CAMERA` for `camera.snap` and `camera.clip`
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
## Contributions
This Android app is currently being rebuilt.
Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord.

View File

@@ -1,93 +0,0 @@
Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -2,6 +2,7 @@ import com.android.build.api.variant.impl.VariantOutputImpl
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
@@ -12,7 +13,7 @@ android {
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources"))
}
}
@@ -20,8 +21,8 @@ android {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602260
versionName = "2026.2.26"
versionCode = 202602160
versionName = "2026.2.16"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -96,7 +97,7 @@ kotlin {
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.02.00")
val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
implementation(composeBom)
androidTestImplementation(composeBom)
@@ -111,7 +112,7 @@ dependencies {
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.9.7")
implementation("androidx.navigation:navigation-compose:2.9.6")
debugImplementation("androidx.compose.ui:ui-tooling")
@@ -119,17 +120,12 @@ dependencies {
implementation("com.google.android.material:material:1.13.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
implementation("org.commonmark:commonmark:0.27.1")
implementation("org.commonmark:commonmark-ext-autolink:0.27.1")
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.1")
implementation("org.commonmark:commonmark-ext-gfm-tables:0.27.1")
implementation("org.commonmark:commonmark-ext-task-list-items:0.27.1")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
@@ -137,17 +133,15 @@ dependencies {
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
}

View File

@@ -38,15 +38,6 @@
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
<service
android:name=".node.DeviceNotificationListenerService"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -59,7 +50,6 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -1,13 +1,20 @@
package ai.openclaw.android
import android.Manifest
import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.os.Build
import android.view.WindowManager
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.core.view.WindowCompat
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -22,7 +29,12 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
WebView.setWebContentsDebuggingEnabled(isDebuggable)
applyImmersiveMode()
requestDiscoveryPermissionsIfNeeded()
requestNotificationPermissionIfNeeded()
NodeForegroundService.start(this)
permissionRequester = PermissionRequester(this)
screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this)
@@ -50,9 +62,18 @@ class MainActivity : ComponentActivity() {
}
}
}
}
// Keep startup path lean: start foreground service after first frame.
window.decorView.post { NodeForegroundService.start(this) }
override fun onResume() {
super.onResume()
applyImmersiveMode()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
applyImmersiveMode()
}
}
override fun onStart() {
@@ -64,4 +85,46 @@ class MainActivity : ComponentActivity() {
viewModel.setForeground(false)
super.onStop()
}
private fun applyImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
}
private fun requestDiscoveryPermissionsIfNeeded() {
if (Build.VERSION.SDK_INT >= 33) {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.NEARBY_WIFI_DEVICES,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
}
} else {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
}
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < 33) return
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
}
}
}

View File

@@ -8,17 +8,12 @@ import ai.openclaw.android.node.CameraCaptureManager
import ai.openclaw.android.node.CanvasController
import ai.openclaw.android.node.ScreenRecordManager
import ai.openclaw.android.node.SmsManager
import ai.openclaw.android.voice.VoiceConversationEntry
import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val runtime: NodeRuntime = (app as NodeApp).runtime
val canvas: CanvasController = runtime.canvas
val canvasCurrentUrl: StateFlow<String?> = runtime.canvas.currentUrl
val canvasA2uiHydrated: StateFlow<Boolean> = runtime.canvasA2uiHydrated
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText
val camera: CameraCaptureManager = runtime.camera
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
@@ -27,7 +22,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
val isConnected: StateFlow<Boolean> = runtime.isConnected
val isNodeConnected: StateFlow<Boolean> = runtime.nodeConnected
val statusText: StateFlow<String> = runtime.statusText
val serverName: StateFlow<String?> = runtime.serverName
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
@@ -46,20 +40,19 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
val micEnabled: StateFlow<Boolean> = runtime.micEnabled
val micStatusText: StateFlow<String> = runtime.micStatusText
val micLiveTranscript: StateFlow<String?> = runtime.micLiveTranscript
val micIsListening: StateFlow<Boolean> = runtime.micIsListening
val micQueuedMessages: StateFlow<List<String>> = runtime.micQueuedMessages
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
val talkStatusText: StateFlow<String> = runtime.talkStatusText
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls
val gatewayToken: StateFlow<String> = runtime.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = runtime.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
@@ -117,20 +110,24 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setGatewayToken(value)
}
fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value)
}
fun setOnboardingCompleted(value: Boolean) {
runtime.setOnboardingCompleted(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value)
}
fun setMicEnabled(enabled: Boolean) {
runtime.setMicEnabled(enabled)
fun setWakeWords(words: List<String>) {
runtime.setWakeWords(words)
}
fun resetWakeWordsDefaults() {
runtime.resetWakeWordsDefaults()
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
runtime.setVoiceWakeMode(mode)
}
fun setTalkEnabled(enabled: Boolean) {
runtime.setTalkEnabled(enabled)
}
fun refreshGatewayConnection() {
@@ -161,10 +158,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}
fun requestCanvasRehydrate(source: String = "screen_tab") {
runtime.requestCanvasRehydrate(source = source, force = true)
}
fun loadChat(sessionKey: String) {
runtime.loadChat(sessionKey)
}

View File

@@ -2,12 +2,23 @@ package ai.openclaw.android
import android.app.Application
import android.os.StrictMode
import android.util.Log
import java.security.Security
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() {
super.onCreate()
// Register Bouncy Castle as highest-priority provider for Ed25519 support
try {
val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider")
.getDeclaredConstructor().newInstance() as java.security.Provider
Security.removeProvider("BC")
Security.insertProviderAt(bcProvider, 1)
} catch (it: Throwable) {
Log.e("NodeApp", "Failed to register Bouncy Castle provider", it)
}
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()

View File

@@ -39,22 +39,22 @@ class NodeForegroundService : Service() {
runtime.statusText,
runtime.serverName,
runtime.isConnected,
runtime.micEnabled,
runtime.micIsListening,
) { status, server, connected, micEnabled, micListening ->
Quint(status, server, connected, micEnabled, micListening)
}.collect { (status, server, connected, micEnabled, micListening) ->
runtime.voiceWakeMode,
runtime.voiceWakeIsListening,
) { status, server, connected, voiceMode, voiceListening ->
Quint(status, server, connected, voiceMode, voiceListening)
}.collect { (status, server, connected, voiceMode, voiceListening) ->
val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node"
val micSuffix =
if (micEnabled) {
if (micListening) " · Mic: Listening" else " · Mic: Pending"
val voiceSuffix =
if (voiceMode == VoiceWakeMode.Always) {
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
} else {
""
}
val text = (server?.let { "$status · $it" } ?: status) + micSuffix
val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix
val requiresMic =
micEnabled && hasRecordAudioPermission()
voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission()
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
requiresMic = requiresMic,

View File

@@ -4,7 +4,6 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.SystemClock
import android.util.Log
import androidx.core.content.ContextCompat
import ai.openclaw.android.chat.ChatController
import ai.openclaw.android.chat.ChatMessage
@@ -19,8 +18,8 @@ import ai.openclaw.android.gateway.GatewaySession
import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
import ai.openclaw.android.node.*
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
import ai.openclaw.android.voice.MicCaptureManager
import ai.openclaw.android.voice.VoiceConversationEntry
import ai.openclaw.android.voice.TalkModeManager
import ai.openclaw.android.voice.VoiceWakeManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -37,7 +36,6 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
class NodeRuntime(context: Context) {
@@ -55,6 +53,40 @@ class NodeRuntime(context: Context) {
private val externalAudioCaptureActive = MutableStateFlow(false)
private val voiceWake: VoiceWakeManager by lazy {
VoiceWakeManager(
context = appContext,
scope = scope,
onCommand = { command ->
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(command))
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("deliver", JsonPrimitive(false))
}.toString(),
)
},
)
}
val voiceWakeIsListening: StateFlow<Boolean>
get() = voiceWake.isListening
val voiceWakeStatusText: StateFlow<String>
get() = voiceWake.statusText
val talkStatusText: StateFlow<String>
get() = talkMode.statusText
val talkIsListening: StateFlow<Boolean>
get() = talkMode.isListening
val talkIsSpeaking: StateFlow<Boolean>
get() = talkMode.isSpeaking
private val discovery = GatewayDiscovery(appContext, scope = scope)
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
val discoveryStatusText: StateFlow<String> = discovery.statusText
@@ -92,14 +124,6 @@ class NodeRuntime(context: Context) {
locationPreciseEnabled = { locationPreciseEnabled.value },
)
private val deviceHandler: DeviceHandler = DeviceHandler(
appContext = appContext,
)
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
appContext = appContext,
)
private val screenHandler: ScreenHandler = ScreenHandler(
screenRecorder = screenRecorder,
setScreenRecordActive = { _screenRecordActive.value = it },
@@ -121,7 +145,7 @@ class NodeRuntime(context: Context) {
prefs = prefs,
cameraEnabled = { cameraEnabled.value },
locationMode = { locationMode.value },
voiceWakeMode = { VoiceWakeMode.Off },
voiceWakeMode = { voiceWakeMode.value },
smsAvailable = { sms.canSendSms() },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
@@ -131,8 +155,6 @@ class NodeRuntime(context: Context) {
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
deviceHandler = deviceHandler,
notificationsHandler = notificationsHandler,
screenHandler = screenHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
@@ -141,16 +163,10 @@ class NodeRuntime(context: Context) {
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
smsAvailable = { sms.canSendSms() },
debugBuild = { BuildConfig.DEBUG },
onCanvasA2uiPush = {
_canvasA2uiHydrated.value = true
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
},
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
)
private lateinit var gatewayEventHandler: GatewayEventHandler
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
val fingerprintSha256: String,
@@ -158,8 +174,6 @@ class NodeRuntime(context: Context) {
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _nodeConnected = MutableStateFlow(false)
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow<String> = _statusText.asStateFlow()
@@ -180,13 +194,6 @@ class NodeRuntime(context: Context) {
private val _screenRecordActive = MutableStateFlow(false)
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
private val _canvasA2uiHydrated = MutableStateFlow(false)
val canvasA2uiHydrated: StateFlow<Boolean> = _canvasA2uiHydrated.asStateFlow()
private val _canvasRehydratePending = MutableStateFlow(false)
val canvasRehydratePending: StateFlow<Boolean> = _canvasRehydratePending.asStateFlow()
private val _canvasRehydrateErrorText = MutableStateFlow<String?>(null)
val canvasRehydrateErrorText: StateFlow<String?> = _canvasRehydrateErrorText.asStateFlow()
private val _serverName = MutableStateFlow<String?>(null)
val serverName: StateFlow<String?> = _serverName.asStateFlow()
@@ -200,9 +207,8 @@ class NodeRuntime(context: Context) {
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private var lastAutoA2uiUrl: String? = null
private var didAutoRequestCanvasRehydrate = false
private val canvasRehydrateSeq = AtomicLong(0)
private var operatorConnected = false
private var nodeConnected = false
private var operatorStatusText: String = "Offline"
private var nodeStatusText: String = "Offline"
@@ -219,8 +225,8 @@ class NodeRuntime(context: Context) {
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
updateStatus()
micCapture.onGatewayConnectionChanged(true)
scope.launch { refreshBrandingFromGateway() }
scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() }
},
onDisconnected = { message ->
operatorConnected = false
@@ -231,10 +237,11 @@ class NodeRuntime(context: Context) {
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
chat.applyMainSessionKey(resolveMainSessionKey())
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
chat.onDisconnected(message)
updateStatus()
micCapture.onGatewayConnectionChanged(false)
},
onEvent = { event, payloadJson ->
handleGatewayEvent(event, payloadJson)
@@ -247,22 +254,14 @@ class NodeRuntime(context: Context) {
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
_nodeConnected.value = true
nodeConnected = true
nodeStatusText = "Connected"
didAutoRequestCanvasRehydrate = false
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
updateStatus()
maybeNavigateToA2uiOnConnect()
},
onDisconnected = { message ->
_nodeConnected.value = false
nodeConnected = false
nodeStatusText = message
didAutoRequestCanvasRehydrate = false
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
updateStatus()
showLocalCanvasOnDisconnect()
},
@@ -282,74 +281,34 @@ class NodeRuntime(context: Context) {
json = json,
supportsChatSubscribe = false,
)
private val micCapture: MicCaptureManager by lazy {
MicCaptureManager(
private val talkMode: TalkModeManager by lazy {
TalkModeManager(
context = appContext,
scope = scope,
sendToGateway = { message ->
val idempotencyKey = UUID.randomUUID().toString()
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
put("message", JsonPrimitive(message))
put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("timeoutMs", JsonPrimitive(30_000))
put("idempotencyKey", JsonPrimitive(idempotencyKey))
}
val response = operatorSession.request("chat.send", params.toString())
parseChatSendRunId(response) ?: idempotencyKey
},
session = operatorSession,
supportsChatSubscribe = false,
isConnected = { operatorConnected },
)
}
val micStatusText: StateFlow<String>
get() = micCapture.statusText
val micLiveTranscript: StateFlow<String?>
get() = micCapture.liveTranscript
val micIsListening: StateFlow<Boolean>
get() = micCapture.isListening
val micEnabled: StateFlow<Boolean>
get() = micCapture.micEnabled
val micQueuedMessages: StateFlow<List<String>>
get() = micCapture.queuedMessages
val micConversation: StateFlow<List<VoiceConversationEntry>>
get() = micCapture.conversation
val micInputLevel: StateFlow<Float>
get() = micCapture.inputLevel
val micIsSending: StateFlow<Boolean>
get() = micCapture.isSending
private fun applyMainSessionKey(candidate: String?) {
val trimmed = normalizeMainKey(candidate) ?: return
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
if (_mainSessionKey.value == trimmed) return
_mainSessionKey.value = trimmed
talkMode.setMainSessionKey(trimmed)
chat.applyMainSessionKey(trimmed)
}
private fun updateStatus() {
_isConnected.value = operatorConnected
val operator = operatorStatusText.trim()
val node = nodeStatusText.trim()
_statusText.value =
when {
operatorConnected && _nodeConnected.value -> "Connected"
operatorConnected && !_nodeConnected.value -> "Connected (node offline)"
!operatorConnected && _nodeConnected.value ->
if (operator.isNotEmpty() && operator != "Offline") {
"Connected (operator: $operator)"
} else {
"Connected (operator offline)"
}
operator.isNotBlank() && operator != "Offline" -> operator
else -> node
operatorConnected && nodeConnected -> "Connected"
operatorConnected && !nodeConnected -> "Connected (node offline)"
!operatorConnected && nodeConnected -> "Connected (operator offline)"
operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
else -> nodeStatusText
}
}
@@ -369,78 +328,24 @@ class NodeRuntime(context: Context) {
private fun showLocalCanvasOnDisconnect() {
lastAutoA2uiUrl = null
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
canvas.navigate("")
}
fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) {
scope.launch {
if (!_nodeConnected.value) {
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = "Node offline. Reconnect and retry."
return@launch
}
if (!force && didAutoRequestCanvasRehydrate) return@launch
didAutoRequestCanvasRehydrate = true
val requestId = canvasRehydrateSeq.incrementAndGet()
_canvasRehydratePending.value = true
_canvasRehydrateErrorText.value = null
val sessionKey = resolveMainSessionKey()
val prompt =
"Restore canvas now for session=$sessionKey source=$source. " +
"If existing A2UI state exists, replay it immediately. " +
"If not, create and render a compact mobile-friendly dashboard in Canvas."
val sent =
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(prompt))
put("sessionKey", JsonPrimitive(sessionKey))
put("thinking", JsonPrimitive("low"))
put("deliver", JsonPrimitive(false))
}.toString(),
)
if (!sent) {
if (!force) {
didAutoRequestCanvasRehydrate = false
}
if (canvasRehydrateSeq.get() == requestId) {
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = "Failed to request restore. Tap to retry."
}
Log.w("OpenClawCanvas", "canvas rehydrate request failed ($source): transport unavailable")
return@launch
}
scope.launch {
delay(20_000)
if (canvasRehydrateSeq.get() != requestId) return@launch
if (!_canvasRehydratePending.value) return@launch
if (_canvasA2uiHydrated.value) return@launch
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry."
}
}
}
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val locationMode: StateFlow<LocationMode> = prefs.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
val talkEnabled: StateFlow<Boolean> = prefs.talkEnabled
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
@@ -458,17 +363,50 @@ class NodeRuntime(context: Context) {
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
init {
if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) {
prefs.setVoiceWakeMode(VoiceWakeMode.Off)
gatewayEventHandler = GatewayEventHandler(
scope = scope,
prefs = prefs,
json = json,
operatorSession = operatorSession,
isConnected = { _isConnected.value },
)
scope.launch {
combine(
voiceWakeMode,
isForeground,
externalAudioCaptureActive,
wakeWords,
) { mode, foreground, externalAudio, words ->
Quad(mode, foreground, externalAudio, words)
}.distinctUntilChanged()
.collect { (mode, foreground, externalAudio, words) ->
voiceWake.setTriggerWords(words)
val shouldListen =
when (mode) {
VoiceWakeMode.Off -> false
VoiceWakeMode.Foreground -> foreground
VoiceWakeMode.Always -> true
} && !externalAudio
if (!shouldListen) {
voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused")
return@collect
}
if (!hasRecordAudioPermission()) {
voiceWake.stop(statusText = "Microphone permission required")
return@collect
}
voiceWake.start()
}
}
scope.launch {
prefs.loadGatewayToken()
}
scope.launch {
prefs.talkEnabled.collect { enabled ->
micCapture.setMicEnabled(enabled)
talkEnabled.collect { enabled ->
talkMode.setEnabled(enabled)
externalAudioCaptureActive.value = enabled
}
}
@@ -576,20 +514,25 @@ class NodeRuntime(context: Context) {
prefs.setCanvasDebugStatusEnabled(value)
}
fun setMicEnabled(value: Boolean) {
fun setWakeWords(words: List<String>) {
prefs.setWakeWords(words)
gatewayEventHandler.scheduleWakeWordsSyncIfNeeded()
}
fun resetWakeWordsDefaults() {
setWakeWords(SecurePrefs.defaultWakeWords)
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
prefs.setVoiceWakeMode(mode)
}
fun setTalkEnabled(value: Boolean) {
prefs.setTalkEnabled(value)
micCapture.setMicEnabled(value)
externalAudioCaptureActive.value = value
}
fun refreshGatewayConnection() {
val endpoint =
connectedEndpoint ?: run {
_statusText.value = "Failed: no cached gateway endpoint"
return
}
operatorStatusText = "Connecting…"
updateStatus()
val endpoint = connectedEndpoint ?: return
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
val tls = connectionManager.resolveTlsParams(endpoint)
@@ -696,10 +639,10 @@ class NodeRuntime(context: Context) {
contextJson = contextJson,
)
val connected = _nodeConnected.value
val connected = nodeConnected
var error: String? = null
if (connected) {
val sent =
try {
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
@@ -711,8 +654,8 @@ class NodeRuntime(context: Context) {
put("key", JsonPrimitive(actionId))
}.toString(),
)
if (!sent) {
error = "send failed"
} catch (e: Throwable) {
error = e.message ?: "send failed"
}
} else {
error = "gateway not connected"
@@ -762,17 +705,13 @@ class NodeRuntime(context: Context) {
}
private fun handleGatewayEvent(event: String, payloadJson: String?) {
micCapture.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private fun parseChatSendRunId(response: String): String? {
return try {
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
root["runId"].asStringOrNull()
} catch (_: Throwable) {
null
if (event == "voicewake.changed") {
gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson)
return
}
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private suspend fun refreshBrandingFromGateway() {

View File

@@ -20,21 +20,19 @@ class SecurePrefs(context: Context) {
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName"
private const val voiceWakeModeKey = "voiceWake.mode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
}
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
private val plainPrefs: SharedPreferences =
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
private val masterKey by lazy {
MasterKey.Builder(appContext)
private val masterKey =
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs: SharedPreferences by lazy {
createPrefs(appContext, "openclaw.node.secure")
}
private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow<String> = _instanceId
@@ -43,51 +41,48 @@ class SecurePrefs(context: Context) {
MutableStateFlow(loadOrMigrateDisplayName(context = context))
val displayName: StateFlow<String> = _displayName
private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode =
MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off")))
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled =
MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep
private val _manualEnabled =
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false))
val manualEnabled: StateFlow<Boolean> = _manualEnabled
private val _manualHost =
MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "")
val manualHost: StateFlow<String> = _manualHost
private val _manualPort =
MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
MutableStateFlow(prefs.getInt("gateway.manual.port", 18789))
val manualPort: StateFlow<Int> = _manualPort
private val _manualTls =
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow<Boolean> = _manualTls
private val _gatewayToken = MutableStateFlow("")
private val _gatewayToken =
MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "")
val gatewayToken: StateFlow<String> = _gatewayToken
private val _onboardingCompleted =
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
private val _lastDiscoveredStableId =
MutableStateFlow(
plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _wakeWords = MutableStateFlow(loadWakeWords())
@@ -96,134 +91,119 @@ class SecurePrefs(context: Context) {
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
val talkEnabled: StateFlow<Boolean> = _talkEnabled
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString(displayNameKey, trimmed) }
prefs.edit { putString(displayNameKey, trimmed) }
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("camera.enabled", value) }
prefs.edit { putBoolean("camera.enabled", value) }
_cameraEnabled.value = value
}
fun setLocationMode(mode: LocationMode) {
plainPrefs.edit { putString("location.enabledMode", mode.rawValue) }
prefs.edit { putString("location.enabledMode", mode.rawValue) }
_locationMode.value = mode
}
fun setLocationPreciseEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
prefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
plainPrefs.edit { putBoolean("screen.preventSleep", value) }
prefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value
}
fun setManualEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
prefs.edit { putBoolean("gateway.manual.enabled", value) }
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString("gateway.manual.host", trimmed) }
prefs.edit { putString("gateway.manual.host", trimmed) }
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
plainPrefs.edit { putInt("gateway.manual.port", value) }
prefs.edit { putInt("gateway.manual.port", value) }
_manualPort.value = value
}
fun setManualTls(value: Boolean) {
plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
prefs.edit { putBoolean("gateway.manual.tls", value) }
_manualTls.value = value
}
fun setGatewayToken(value: String) {
val trimmed = value.trim()
securePrefs.edit { putString("gateway.manual.token", trimmed) }
_gatewayToken.value = trimmed
}
fun setGatewayPassword(value: String) {
saveGatewayPassword(value)
}
fun setOnboardingCompleted(value: Boolean) {
plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value
prefs.edit { putString("gateway.manual.token", value) }
_gatewayToken.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
}
fun loadGatewayToken(): String? {
val manual =
_gatewayToken.value.trim().ifEmpty {
val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty()
if (stored.isNotEmpty()) _gatewayToken.value = stored
stored
}
val manual = _gatewayToken.value.trim()
if (manual.isNotEmpty()) return manual
val key = "gateway.token.${_instanceId.value}"
val stored = securePrefs.getString(key, null)?.trim()
val stored = prefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
securePrefs.edit { putString(key, token.trim()) }
prefs.edit { putString(key, token.trim()) }
}
fun loadGatewayPassword(): String? {
val key = "gateway.password.${_instanceId.value}"
val stored = securePrefs.getString(key, null)?.trim()
val stored = prefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
fun saveGatewayPassword(password: String) {
val key = "gateway.password.${_instanceId.value}"
securePrefs.edit { putString(key, password.trim()) }
prefs.edit { putString(key, password.trim()) }
}
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
val key = "gateway.tls.$stableId"
plainPrefs.edit { putString(key, fingerprint.trim()) }
prefs.edit { putString(key, fingerprint.trim()) }
}
fun getString(key: String): String? {
return securePrefs.getString(key, null)
return prefs.getString(key, null)
}
fun putString(key: String, value: String) {
securePrefs.edit { putString(key, value) }
prefs.edit { putString(key, value) }
}
fun remove(key: String) {
securePrefs.edit { remove(key) }
prefs.edit { remove(key) }
}
private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
private fun createPrefs(context: Context, name: String): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
name,
@@ -234,21 +214,21 @@ class SecurePrefs(context: Context) {
}
private fun loadOrCreateInstanceId(): String {
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
plainPrefs.edit { putString("node.instanceId", fresh) }
prefs.edit { putString("node.instanceId", fresh) }
return fresh
}
private fun loadOrMigrateDisplayName(context: Context): String {
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty()
if (existing.isNotEmpty() && existing != "Android Node") return existing
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
plainPrefs.edit { putString(displayNameKey, resolved) }
prefs.edit { putString(displayNameKey, resolved) }
return resolved
}
@@ -256,34 +236,34 @@ class SecurePrefs(context: Context) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
plainPrefs.edit { putString("voiceWake.triggerWords", encoded) }
prefs.edit { putString("voiceWake.triggerWords", encoded) }
_wakeWords.value = sanitized
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
_voiceWakeMode.value = mode
}
fun setTalkEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("talk.enabled", value) }
prefs.edit { putBoolean("talk.enabled", value) }
_talkEnabled.value = value
}
private fun loadVoiceWakeMode(): VoiceWakeMode {
val raw = plainPrefs.getString(voiceWakeModeKey, null)
val raw = prefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)
// Default ON (foreground) when unset.
if (raw.isNullOrBlank()) {
plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
}
return resolved
}
private fun loadWakeWords(): List<String> {
val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
if (raw.isNullOrEmpty()) return defaultWakeWords
return try {
val element = json.parseToJsonElement(raw)
@@ -301,4 +281,5 @@ class SecurePrefs(context: Context) {
defaultWakeWords
}
}
}

View File

@@ -261,7 +261,11 @@ class ChatController(
val key = _sessionKey.value
try {
if (supportsChatSubscribe) {
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
try {
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
} catch (_: Throwable) {
// best-effort
}
}
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
@@ -321,12 +325,6 @@ class ChatController(
val state = payload["state"].asStringOrNull()
when (state) {
"delta" -> {
val text = parseAssistantDeltaText(payload)
if (!text.isNullOrEmpty()) {
_streamingAssistantText.value = text
}
}
"final", "aborted", "error" -> {
if (state == "error") {
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
@@ -353,8 +351,9 @@ class ChatController(
private fun handleAgentEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return
val runId = payload["runId"].asStringOrNull()
val sessionId = _sessionId.value
if (sessionId != null && runId != sessionId) return
val stream = payload["stream"].asStringOrNull()
val data = payload["data"].asObjectOrNull()
@@ -399,21 +398,6 @@ class ChatController(
}
}
private fun parseAssistantDeltaText(payload: JsonObject): String? {
val message = payload["message"].asObjectOrNull() ?: return null
if (message["role"].asStringOrNull() != "assistant") return null
val content = message["content"].asArrayOrNull() ?: return null
for (item in content) {
val obj = item.asObjectOrNull() ?: continue
if (obj["type"].asStringOrNull() != "text") continue
val text = obj["text"].asStringOrNull()
if (!text.isNullOrEmpty()) {
return text
}
}
return null
}
private fun publishPendingToolCalls() {
_pendingToolCalls.value =
pendingToolCallsById.values.sortedBy { it.startedAtMs }

View File

@@ -1,52 +0,0 @@
package ai.openclaw.android.gateway
internal object DeviceAuthPayload {
fun buildV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val platformNorm = normalizeMetadataField(platform)
val deviceFamilyNorm = normalizeMetadataField(deviceFamily)
return listOf(
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
nonce,
platformNorm,
deviceFamilyNorm,
).joinToString("|")
}
internal fun normalizeMetadataField(value: String?): String {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) {
return ""
}
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
// lowercase ASCII A-Z only for auth payload metadata fields.
val out = StringBuilder(trimmed.length)
for (ch in trimmed) {
if (ch in 'A'..'Z') {
out.append((ch.code + 32).toChar())
} else {
out.append(ch)
}
}
return out.toString()
}
}

View File

@@ -2,18 +2,13 @@ package ai.openclaw.android.gateway
import ai.openclaw.android.SecurePrefs
interface DeviceAuthTokenStore {
fun loadToken(deviceId: String, role: String): String?
fun saveToken(deviceId: String, role: String, token: String)
}
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
override fun loadToken(deviceId: String, role: String): String? {
class DeviceAuthStore(private val prefs: SecurePrefs) {
fun loadToken(deviceId: String, role: String): String? {
val key = tokenKey(deviceId, role)
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
}
override fun saveToken(deviceId: String, role: String, token: String) {
fun saveToken(deviceId: String, role: String, token: String) {
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
}

View File

@@ -3,7 +3,11 @@ package ai.openclaw.android.gateway
import android.content.Context
import android.util.Base64
import java.io.File
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -18,26 +22,21 @@ data class DeviceIdentity(
class DeviceIdentityStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
@Volatile private var cachedIdentity: DeviceIdentity? = null
@Synchronized
fun loadOrCreate(): DeviceIdentity {
cachedIdentity?.let { return it }
val existing = load()
if (existing != null) {
val derived = deriveDeviceId(existing.publicKeyRawBase64)
if (derived != null && derived != existing.deviceId) {
val updated = existing.copy(deviceId = derived)
save(updated)
cachedIdentity = updated
return updated
}
cachedIdentity = existing
return existing
}
val fresh = generate()
save(fresh)
cachedIdentity = fresh
return fresh
}
@@ -152,16 +151,22 @@ class DeviceIdentityStore(context: Context) {
}
}
private fun stripSpkiPrefix(spki: ByteArray): ByteArray {
if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
) {
return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
}
return spki
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = CharArray(digest.size * 2)
var i = 0
val out = StringBuilder(digest.size * 2)
for (byte in digest) {
val v = byte.toInt() and 0xff
out[i++] = HEX[v ushr 4]
out[i++] = HEX[v and 0x0f]
out.append(String.format("%02x", byte))
}
return String(out)
return out.toString()
}
private fun base64UrlEncode(data: ByteArray): String {
@@ -169,6 +174,9 @@ class DeviceIdentityStore(context: Context) {
}
companion object {
private val HEX = "0123456789abcdef".toCharArray()
private val ED25519_SPKI_PREFIX =
byteArrayOf(
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
)
}
}

View File

@@ -55,18 +55,13 @@ data class GatewayConnectOptions(
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthTokenStore,
private val deviceAuthStore: DeviceAuthStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) {
private companion object {
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
}
data class InvokeRequest(
val id: String,
val nodeId: String,
@@ -136,8 +131,8 @@ class GatewaySession(
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
val conn = currentConnection ?: return false
suspend fun sendNodeEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
@@ -152,10 +147,8 @@ class GatewaySession(
}
try {
conn.request("node.event", params, timeoutMs = 8_000)
return true
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
return false
}
}
@@ -185,7 +178,7 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String>()
private val connectNonceDeferred = CompletableDeferred<String?>()
private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null
private val loggerTag = "OpenClawGateway"
@@ -200,7 +193,9 @@ class GatewaySession(
suspend fun connect() {
val scheme = if (tls != null) "wss" else "ws"
val url = "$scheme://${endpoint.host}:${endpoint.port}"
val request = Request.Builder().url(url).build()
val httpScheme = if (tls != null) "https" else "http"
val origin = "$httpScheme://${endpoint.host}:${endpoint.port}"
val request = Request.Builder().url(url).header("Origin", origin).build()
socket = client.newWebSocket(request, Listener())
try {
connectDeferred.await()
@@ -301,23 +296,21 @@ class GatewaySession(
}
}
private suspend fun sendConnect(connectNonce: String) {
private suspend fun sendConnect(connectNonce: String?) {
val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
// QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding.
val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty()
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) {
val msg = res.error?.message ?: "connect failed"
if (canFallbackToShared) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
throw IllegalStateException(msg)
}
handleConnectSuccess(res, identity.deviceId)
connectDeferred.complete(Unit)
}
private fun handleConnectSuccess(res: RpcResponse, deviceId: String) {
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
@@ -325,20 +318,21 @@ class GatewaySession(
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
if (!deviceToken.isNullOrBlank()) {
deviceAuthStore.saveToken(deviceId, authRole, deviceToken)
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
}
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
val sessionDefaults =
obj["snapshot"].asObjectOrNull()
?.get("sessionDefaults").asObjectOrNull()
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
onConnected(serverName, remoteAddress, mainSessionKey)
connectDeferred.complete(Unit)
}
private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String,
connectNonce: String?,
authToken: String,
authPassword: String?,
): JsonObject {
@@ -372,7 +366,7 @@ class GatewaySession(
val signedAtMs = System.currentTimeMillis()
val payload =
DeviceAuthPayload.buildV3(
buildDeviceAuthPayload(
deviceId = identity.deviceId,
clientId = client.id,
clientMode = client.mode,
@@ -381,8 +375,6 @@ class GatewaySession(
signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
platform = client.platform,
deviceFamily = client.deviceFamily,
)
val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -393,7 +385,9 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs))
put("nonce", JsonPrimitive(connectNonce))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
}
} else {
null
@@ -453,8 +447,8 @@ class GatewaySession(
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) {
connectNonceDeferred.complete(nonce.trim())
if (!connectNonceDeferred.isCompleted) {
connectNonceDeferred.complete(nonce)
}
return
}
@@ -465,11 +459,12 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private suspend fun awaitConnectNonce(): String {
private suspend fun awaitConnectNonce(): String? {
if (isLoopbackHost(endpoint.host)) return null
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (err: Throwable) {
throw IllegalStateException("connect challenge timeout", err)
} catch (_: Throwable) {
null
}
}
@@ -535,8 +530,16 @@ class GatewaySession(
}
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = err::class.java.simpleName)
return InvokeResult.error(code = parsed.code, message = parsed.message)
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
val parts = msg.split(":", limit = 2)
if (parts.size == 2) {
val code = parts[0].trim()
val rest = parts[1].trim()
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
}
}
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
}
private fun failPending() {
@@ -584,30 +587,54 @@ class GatewaySession(
}
}
private fun normalizeCanvasHostUrl(
raw: String?,
endpoint: GatewayEndpoint,
isTlsConnection: Boolean,
): String? {
private fun buildDeviceAuthPayload(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
val parts =
mutableListOf(
version,
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
val trimmed = raw?.trim().orEmpty()
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() }
val host = parsed?.host?.trim().orEmpty()
val port = parsed?.port ?: -1
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
val suffix = buildUrlSuffix(parsed)
// If raw URL is a non-loopback address and this connection uses TLS,
// normalize scheme/port to the endpoint we actually connected to.
if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) {
val needsTlsRewrite =
isTlsConnection &&
(
!scheme.equals("https", ignoreCase = true) ||
(port > 0 && port != endpoint.port) ||
(port <= 0 && endpoint.port != 443)
)
if (needsTlsRewrite) {
return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix)
// Detect TLS reverse proxy: endpoint on port 443, or domain-based host
val tls = endpoint.port == 443 || endpoint.host.contains(".")
// If raw URL is a non-loopback address AND we're behind TLS reverse proxy,
// fix the port (gateway sends its internal port like 18789, but we need 443 via Caddy)
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
if (tls && port > 0 && port != 443) {
// Rewrite the URL to use the reverse proxy port instead of the raw gateway port
val fixedScheme = "https"
val formattedHost = if (host.contains(":")) "[${host}]" else host
return "$fixedScheme://$formattedHost"
}
return trimmed
}
@@ -618,26 +645,14 @@ class GatewaySession(
?: endpoint.host.trim()
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
// For TLS connections, use the connected endpoint's scheme/port instead of raw canvas metadata.
val fallbackScheme = if (isTlsConnection) "https" else scheme
// For TLS, always use the connected endpoint port.
val fallbackPort = if (isTlsConnection) endpoint.port else (endpoint.canvasPort ?: endpoint.port)
return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix)
}
private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String {
val loweredScheme = scheme.lowercase()
val formattedHost = if (host.contains(":")) "[${host}]" else host
val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port"
return "$loweredScheme://$formattedHost$portSuffix$suffix"
}
private fun buildUrlSuffix(uri: java.net.URI?): String {
if (uri == null) return ""
val path = uri.rawPath?.takeIf { it.isNotBlank() } ?: ""
val query = uri.rawQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: ""
val fragment = uri.rawFragment?.takeIf { it.isNotBlank() }?.let { "#$it" } ?: ""
return "$path$query$fragment"
// When connecting through a reverse proxy (TLS on standard port), use the
// connection endpoint's scheme and port instead of the raw canvas port.
val fallbackScheme = if (tls) "https" else scheme
// Behind reverse proxy, always use the proxy port (443), not the raw canvas port
val fallbackPort = if (tls) endpoint.port else (endpoint.canvasPort ?: endpoint.port)
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
val portSuffix = if ((fallbackScheme == "https" && fallbackPort == 443) || (fallbackScheme == "http" && fallbackPort == 80)) "" else ":$fallbackPort"
return "$fallbackScheme://$formattedHost$portSuffix"
}
private fun isLoopbackHost(raw: String?): Boolean {

View File

@@ -1,39 +0,0 @@
package ai.openclaw.android.gateway
data class ParsedInvokeError(
val code: String,
val message: String,
val hadExplicitCode: Boolean,
) {
val prefixedMessage: String
get() = "$code: $message"
}
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
val trimmed = raw.trim()
if (trimmed.isEmpty()) {
return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false)
}
val parts = trimmed.split(":", limit = 2)
if (parts.size == 2) {
val code = parts[0].trim()
val rest = parts[1].trim()
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
return ParsedInvokeError(
code = code,
message = rest.ifEmpty { trimmed },
hadExplicitCode = true,
)
}
}
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
}
fun parseInvokeErrorFromThrowable(
err: Throwable,
fallbackMessage: String = "error",
): ParsedInvokeError {
val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage
return parseInvokeErrorMessage(raw)
}

View File

@@ -81,8 +81,8 @@ class CameraCaptureManager(private val context: Context) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val facing = parseFacing(paramsJson) ?: "front"
val quality = (parseQuality(paramsJson) ?: 0.95).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(paramsJson) ?: 1600
val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(paramsJson) ?: 800
val provider = context.cameraProvider()
val capture = ImageCapture.Builder().build()

View File

@@ -10,9 +10,6 @@ import androidx.core.graphics.scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.ByteArrayOutputStream
import android.util.Base64
import org.json.JSONObject
@@ -34,8 +31,6 @@ class CanvasController {
@Volatile private var debugStatusEnabled: Boolean = false
@Volatile private var debugStatusTitle: String? = null
@Volatile private var debugStatusSubtitle: String? = null
private val _currentUrl = MutableStateFlow<String?>(null)
val currentUrl: StateFlow<String?> = _currentUrl.asStateFlow()
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
@@ -50,16 +45,9 @@ class CanvasController {
applyDebugStatus()
}
fun detach(webView: WebView) {
if (this.webView === webView) {
this.webView = null
}
}
fun navigate(url: String) {
val trimmed = url.trim()
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
_currentUrl.value = this.url
reload()
}

View File

@@ -7,6 +7,12 @@ import ai.openclaw.android.gateway.GatewayClientInfo
import ai.openclaw.android.gateway.GatewayConnectOptions
import ai.openclaw.android.gateway.GatewayEndpoint
import ai.openclaw.android.gateway.GatewayTlsParams
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
import ai.openclaw.android.protocol.OpenClawCapability
import ai.openclaw.android.LocationMode
import ai.openclaw.android.VoiceWakeMode
@@ -74,18 +80,37 @@ class ConnectionManager(
}
fun buildInvokeCommands(): List<String> =
InvokeCommandRegistry.advertisedCommands(
cameraEnabled = cameraEnabled(),
locationEnabled = locationMode() != LocationMode.Off,
smsAvailable = smsAvailable(),
debugBuild = BuildConfig.DEBUG,
)
buildList {
add(OpenClawCanvasCommand.Present.rawValue)
add(OpenClawCanvasCommand.Hide.rawValue)
add(OpenClawCanvasCommand.Navigate.rawValue)
add(OpenClawCanvasCommand.Eval.rawValue)
add(OpenClawCanvasCommand.Snapshot.rawValue)
add(OpenClawCanvasA2UICommand.Push.rawValue)
add(OpenClawCanvasA2UICommand.PushJSONL.rawValue)
add(OpenClawCanvasA2UICommand.Reset.rawValue)
add(OpenClawScreenCommand.Record.rawValue)
if (cameraEnabled()) {
add(OpenClawCameraCommand.Snap.rawValue)
add(OpenClawCameraCommand.Clip.rawValue)
}
if (locationMode() != LocationMode.Off) {
add(OpenClawLocationCommand.Get.rawValue)
}
if (smsAvailable()) {
add(OpenClawSmsCommand.Send.rawValue)
}
if (BuildConfig.DEBUG) {
add("debug.logs")
add("debug.ed25519")
}
add("app.update")
}
fun buildCapabilities(): List<String> =
buildList {
add(OpenClawCapability.Canvas.rawValue)
add(OpenClawCapability.Screen.rawValue)
add(OpenClawCapability.Device.rawValue)
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {
@@ -151,7 +176,7 @@ class ConnectionManager(
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "openclaw-android", clientMode = "ui"),
client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"),
userAgent = buildUserAgent(),
)
}

View File

@@ -1,171 +0,0 @@
package ai.openclaw.android.node
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import android.os.Environment
import android.os.PowerManager
import android.os.StatFs
import android.os.SystemClock
import ai.openclaw.android.BuildConfig
import ai.openclaw.android.gateway.GatewaySession
import java.util.Locale
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class DeviceHandler(
private val appContext: Context,
) {
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(statusPayloadJson())
}
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(infoPayloadJson())
}
private fun statusPayloadJson(): String {
val batteryIntent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryStatus =
batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
?: BatteryManager.BATTERY_STATUS_UNKNOWN
val batteryLevel = batteryLevelFraction(batteryIntent)
val powerManager = appContext.getSystemService(PowerManager::class.java)
val storage = StatFs(Environment.getDataDirectory().absolutePath)
val totalBytes = storage.totalBytes
val freeBytes = storage.availableBytes
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
val connectivity = appContext.getSystemService(ConnectivityManager::class.java)
val activeNetwork = connectivity?.activeNetwork
val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) }
val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0
return buildJsonObject {
put(
"battery",
buildJsonObject {
batteryLevel?.let { put("level", JsonPrimitive(it)) }
put("state", JsonPrimitive(mapBatteryState(batteryStatus)))
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
},
)
put(
"thermal",
buildJsonObject {
put("state", JsonPrimitive(mapThermalState(powerManager)))
},
)
put(
"storage",
buildJsonObject {
put("totalBytes", JsonPrimitive(totalBytes))
put("freeBytes", JsonPrimitive(freeBytes))
put("usedBytes", JsonPrimitive(usedBytes))
},
)
put(
"network",
buildJsonObject {
put("status", JsonPrimitive(mapNetworkStatus(caps)))
put(
"isExpensive",
JsonPrimitive(
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)?.not() ?: false,
),
)
put(
"isConstrained",
JsonPrimitive(
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)?.not() ?: false,
),
)
put("interfaces", networkInterfacesJson(caps))
},
)
put("uptimeSeconds", JsonPrimitive(uptimeSeconds))
}.toString()
}
private fun infoPayloadJson(): String {
val model = Build.MODEL?.trim().orEmpty()
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val modelIdentifier = Build.DEVICE?.trim().orEmpty()
val systemVersion = Build.VERSION.RELEASE?.trim().orEmpty()
val locale = Locale.getDefault().toLanguageTag().trim()
val appVersion = BuildConfig.VERSION_NAME.trim()
val appBuild = BuildConfig.VERSION_CODE.toString()
return buildJsonObject {
put("deviceName", JsonPrimitive(model.ifEmpty { "Android" }))
put("modelIdentifier", JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") }))
put("systemName", JsonPrimitive("Android"))
put("systemVersion", JsonPrimitive(systemVersion.ifEmpty { Build.VERSION.SDK_INT.toString() }))
put("appVersion", JsonPrimitive(appVersion.ifEmpty { "dev" }))
put("appBuild", JsonPrimitive(appBuild.ifEmpty { "0" }))
put("locale", JsonPrimitive(locale.ifEmpty { Locale.getDefault().toString() }))
}.toString()
}
private fun batteryLevelFraction(intent: Intent?): Double? {
val rawLevel = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val rawScale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
if (rawLevel < 0 || rawScale <= 0) return null
return rawLevel.toDouble() / rawScale.toDouble()
}
private fun mapBatteryState(status: Int): String {
return when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
BatteryManager.BATTERY_STATUS_FULL -> "full"
BatteryManager.BATTERY_STATUS_DISCHARGING, BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "unplugged"
else -> "unknown"
}
}
private fun mapThermalState(powerManager: PowerManager?): String {
val thermal = powerManager?.currentThermalStatus ?: return "nominal"
return when (thermal) {
PowerManager.THERMAL_STATUS_NONE, PowerManager.THERMAL_STATUS_LIGHT -> "nominal"
PowerManager.THERMAL_STATUS_MODERATE -> "fair"
PowerManager.THERMAL_STATUS_SEVERE -> "serious"
PowerManager.THERMAL_STATUS_CRITICAL,
PowerManager.THERMAL_STATUS_EMERGENCY,
PowerManager.THERMAL_STATUS_SHUTDOWN -> "critical"
else -> "nominal"
}
}
private fun mapNetworkStatus(caps: NetworkCapabilities?): String {
if (caps == null) return "unsatisfied"
return when {
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied"
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection"
else -> "unsatisfied"
}
}
private fun networkInterfacesJson(caps: NetworkCapabilities?) =
buildJsonArray {
if (caps == null) return@buildJsonArray
var hasKnownTransport = false
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
hasKnownTransport = true
add(JsonPrimitive("wifi"))
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
hasKnownTransport = true
add(JsonPrimitive("cellular"))
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
hasKnownTransport = true
add(JsonPrimitive("wired"))
}
if (!hasKnownTransport) add(JsonPrimitive("other"))
}
}

View File

@@ -1,164 +0,0 @@
package ai.openclaw.android.node
import android.app.Notification
import android.app.NotificationManager
import android.content.ComponentName
import android.content.Context
import android.os.Build
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
internal fun sanitizeNotificationText(value: CharSequence?): String? {
val normalized = value?.toString()?.trim().orEmpty()
return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
}
data class DeviceNotificationEntry(
val key: String,
val packageName: String,
val title: String?,
val text: String?,
val subText: String?,
val category: String?,
val channelId: String?,
val postTimeMs: Long,
val isOngoing: Boolean,
val isClearable: Boolean,
)
data class DeviceNotificationSnapshot(
val enabled: Boolean,
val connected: Boolean,
val notifications: List<DeviceNotificationEntry>,
)
private object DeviceNotificationStore {
private val lock = Any()
private var connected = false
private val byKey = LinkedHashMap<String, DeviceNotificationEntry>()
fun replace(entries: List<DeviceNotificationEntry>) {
synchronized(lock) {
byKey.clear()
for (entry in entries) {
byKey[entry.key] = entry
}
}
}
fun upsert(entry: DeviceNotificationEntry) {
synchronized(lock) {
byKey[entry.key] = entry
}
}
fun remove(key: String) {
synchronized(lock) {
byKey.remove(key)
}
}
fun setConnected(value: Boolean) {
synchronized(lock) {
connected = value
if (!value) {
byKey.clear()
}
}
}
fun snapshot(enabled: Boolean): DeviceNotificationSnapshot {
val (isConnected, entries) =
synchronized(lock) {
connected to byKey.values.sortedByDescending { it.postTimeMs }
}
return DeviceNotificationSnapshot(
enabled = enabled,
connected = isConnected,
notifications = entries,
)
}
}
class DeviceNotificationListenerService : NotificationListenerService() {
override fun onListenerConnected() {
super.onListenerConnected()
DeviceNotificationStore.setConnected(true)
refreshActiveNotifications()
}
override fun onListenerDisconnected() {
DeviceNotificationStore.setConnected(false)
super.onListenerDisconnected()
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
val entry = sbn?.toEntry() ?: return
DeviceNotificationStore.upsert(entry)
}
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
super.onNotificationRemoved(sbn)
val key = sbn?.key ?: return
DeviceNotificationStore.remove(key)
}
private fun refreshActiveNotifications() {
val entries =
runCatching {
activeNotifications
?.mapNotNull { it.toEntry() }
?: emptyList()
}.getOrElse { emptyList() }
DeviceNotificationStore.replace(entries)
}
private fun StatusBarNotification.toEntry(): DeviceNotificationEntry {
val extras = notification.extras
val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime"
val title = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TITLE))
val body =
sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT))
?: sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TEXT))
val subText = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT))
return DeviceNotificationEntry(
key = keyValue,
packageName = packageName,
title = title,
text = body,
subText = subText,
category = notification.category?.trim()?.ifEmpty { null },
channelId = notification.channelId?.trim()?.ifEmpty { null },
postTimeMs = postTime,
isOngoing = isOngoing,
isClearable = isClearable,
)
}
companion object {
private fun serviceComponent(context: Context): ComponentName {
return ComponentName(context, DeviceNotificationListenerService::class.java)
}
fun isAccessEnabled(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
}
fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot {
return DeviceNotificationStore.snapshot(enabled = enabled)
}
fun requestServiceRebind(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
runCatching {
NotificationListenerService.requestRebind(serviceComponent(context))
}
}
}
}

View File

@@ -1,125 +0,0 @@
package ai.openclaw.android.node
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
enum class InvokeCommandAvailability {
Always,
CameraEnabled,
LocationEnabled,
SmsAvailable,
DebugBuild,
}
data class InvokeCommandSpec(
val name: String,
val requiresForeground: Boolean = false,
val availability: InvokeCommandAvailability = InvokeCommandAvailability.Always,
)
object InvokeCommandRegistry {
val all: List<InvokeCommandSpec> =
listOf(
InvokeCommandSpec(
name = OpenClawCanvasCommand.Present.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasCommand.Hide.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasCommand.Navigate.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasCommand.Eval.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasCommand.Snapshot.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasA2UICommand.Push.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasA2UICommand.PushJSONL.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasA2UICommand.Reset.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawScreenCommand.Record.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCameraCommand.Snap.rawValue,
requiresForeground = true,
availability = InvokeCommandAvailability.CameraEnabled,
),
InvokeCommandSpec(
name = OpenClawCameraCommand.Clip.rawValue,
requiresForeground = true,
availability = InvokeCommandAvailability.CameraEnabled,
),
InvokeCommandSpec(
name = OpenClawLocationCommand.Get.rawValue,
availability = InvokeCommandAvailability.LocationEnabled,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Status.rawValue,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Info.rawValue,
),
InvokeCommandSpec(
name = OpenClawNotificationsCommand.List.rawValue,
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
),
InvokeCommandSpec(
name = "debug.logs",
availability = InvokeCommandAvailability.DebugBuild,
),
InvokeCommandSpec(
name = "debug.ed25519",
availability = InvokeCommandAvailability.DebugBuild,
),
InvokeCommandSpec(name = "app.update"),
)
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
fun advertisedCommands(
cameraEnabled: Boolean,
locationEnabled: Boolean,
smsAvailable: Boolean,
debugBuild: Boolean,
): List<String> {
return all
.filter { spec ->
when (spec.availability) {
InvokeCommandAvailability.Always -> true
InvokeCommandAvailability.CameraEnabled -> cameraEnabled
InvokeCommandAvailability.LocationEnabled -> locationEnabled
InvokeCommandAvailability.SmsAvailable -> smsAvailable
InvokeCommandAvailability.DebugBuild -> debugBuild
}
}
.map { it.name }
}
}

View File

@@ -4,9 +4,7 @@ import ai.openclaw.android.gateway.GatewaySession
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
@@ -14,8 +12,6 @@ class InvokeDispatcher(
private val canvas: CanvasController,
private val cameraHandler: CameraHandler,
private val locationHandler: LocationHandler,
private val deviceHandler: DeviceHandler,
private val notificationsHandler: NotificationsHandler,
private val screenHandler: ScreenHandler,
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
@@ -24,25 +20,38 @@ class InvokeDispatcher(
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
private val onCanvasA2uiReset: () -> Unit,
) {
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
val spec =
InvokeCommandRegistry.find(command)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
// Check foreground requirement for canvas/camera/screen commands
if (
command.startsWith(OpenClawCanvasCommand.NamespacePrefix) ||
command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) ||
command.startsWith(OpenClawCameraCommand.NamespacePrefix) ||
command.startsWith(OpenClawScreenCommand.NamespacePrefix)
) {
if (!isForeground()) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
)
if (spec.requiresForeground && !isForeground()) {
}
}
// Check camera enabled
if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings",
)
}
// Check location enabled
if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings",
)
}
availabilityError(spec.availability)?.let { return it }
return when (command) {
// Canvas commands
@@ -64,33 +73,52 @@ class InvokeDispatcher(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: javaScript required",
)
withCanvasAvailable {
val result = canvas.eval(js)
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
}
val result =
try {
canvas.eval(js)
} catch (err: Throwable) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
}
OpenClawCanvasCommand.Snapshot.rawValue -> {
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
withCanvasAvailable {
val base64 =
val base64 =
try {
canvas.snapshotBase64(
format = snapshotParams.format,
quality = snapshotParams.quality,
maxWidth = snapshotParams.maxWidth,
)
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
}
} catch (err: Throwable) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
}
// A2UI commands
OpenClawCanvasA2UICommand.Reset.rawValue ->
withReadyA2ui {
withCanvasAvailable {
val res = canvas.eval(A2UIHandler.a2uiResetJS)
onCanvasA2uiReset()
GatewaySession.InvokeResult.ok(res)
}
OpenClawCanvasA2UICommand.Reset.rawValue -> {
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!ready) {
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val res = canvas.eval(A2UIHandler.a2uiResetJS)
GatewaySession.InvokeResult.ok(res)
}
OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> {
val messages =
try {
@@ -101,14 +129,21 @@ class InvokeDispatcher(
message = err.message ?: "invalid A2UI payload"
)
}
withReadyA2ui {
withCanvasAvailable {
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
val res = canvas.eval(js)
onCanvasA2uiPush()
GatewaySession.InvokeResult.ok(res)
}
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!ready) {
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
val res = canvas.eval(js)
GatewaySession.InvokeResult.ok(res)
}
// Camera commands
@@ -118,13 +153,6 @@ class InvokeDispatcher(
// Location command
OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson)
// Device commands
OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleDeviceStatus(paramsJson)
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
// Notifications command
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
// Screen command
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
@@ -138,80 +166,11 @@ class InvokeDispatcher(
// App update
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
}
}
private suspend fun withReadyA2ui(
block: suspend () -> GatewaySession.InvokeResult,
): GatewaySession.InvokeResult {
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!ready) {
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
return block()
}
private suspend fun withCanvasAvailable(
block: suspend () -> GatewaySession.InvokeResult,
): GatewaySession.InvokeResult {
return try {
block()
} catch (_: Throwable) {
GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
}
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? {
return when (availability) {
InvokeCommandAvailability.Always -> null
InvokeCommandAvailability.CameraEnabled ->
if (cameraEnabled()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings",
)
}
InvokeCommandAvailability.LocationEnabled ->
if (locationEnabled()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings",
)
}
InvokeCommandAvailability.SmsAvailable ->
if (smsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.DebugBuild ->
if (debugBuild()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
)
}
else ->
GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
)
}
}
}

View File

@@ -1,6 +1,5 @@
package ai.openclaw.android.node
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
@@ -38,9 +37,14 @@ fun parseHexColorArgb(raw: String?): Long? {
}
fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error")
val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message
return parsed.code to message
val raw = (err.message ?: "").trim()
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error"
val idx = raw.indexOf(':')
if (idx <= 0) return "UNAVAILABLE" to raw
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
return code to "$code: $message"
}
fun normalizeMainKey(raw: String?): String? {

View File

@@ -1,81 +0,0 @@
package ai.openclaw.android.node
import android.content.Context
import ai.openclaw.android.gateway.GatewaySession
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
internal interface NotificationsStateProvider {
fun readSnapshot(context: Context): DeviceNotificationSnapshot
fun requestServiceRebind(context: Context)
}
private object SystemNotificationsStateProvider : NotificationsStateProvider {
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
val enabled = DeviceNotificationListenerService.isAccessEnabled(context)
if (!enabled) {
return DeviceNotificationSnapshot(
enabled = false,
connected = false,
notifications = emptyList(),
)
}
return DeviceNotificationListenerService.snapshot(context, enabled = true)
}
override fun requestServiceRebind(context: Context) {
DeviceNotificationListenerService.requestServiceRebind(context)
}
}
class NotificationsHandler private constructor(
private val appContext: Context,
private val stateProvider: NotificationsStateProvider,
) {
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
val snapshot = stateProvider.readSnapshot(appContext)
if (snapshot.enabled && !snapshot.connected) {
stateProvider.requestServiceRebind(appContext)
}
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
}
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
return buildJsonObject {
put("enabled", JsonPrimitive(snapshot.enabled))
put("connected", JsonPrimitive(snapshot.connected))
put("count", JsonPrimitive(snapshot.notifications.size))
put(
"notifications",
JsonArray(
snapshot.notifications.map { entry ->
buildJsonObject {
put("key", JsonPrimitive(entry.key))
put("packageName", JsonPrimitive(entry.packageName))
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
put("isOngoing", JsonPrimitive(entry.isOngoing))
put("isClearable", JsonPrimitive(entry.isClearable))
entry.title?.let { put("title", JsonPrimitive(it)) }
entry.text?.let { put("text", JsonPrimitive(it)) }
entry.subText?.let { put("subText", JsonPrimitive(it)) }
entry.category?.let { put("category", JsonPrimitive(it)) }
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
}
},
),
)
}.toString()
}
companion object {
internal fun forTesting(
appContext: Context,
stateProvider: NotificationsStateProvider,
): NotificationsHandler = NotificationsHandler(appContext = appContext, stateProvider = stateProvider)
}
}

View File

@@ -7,7 +7,6 @@ enum class OpenClawCapability(val rawValue: String) {
Sms("sms"),
VoiceWake("voiceWake"),
Location("location"),
Device("device"),
}
enum class OpenClawCanvasCommand(val rawValue: String) {
@@ -70,22 +69,3 @@ enum class OpenClawLocationCommand(val rawValue: String) {
const val NamespacePrefix: String = "location."
}
}
enum class OpenClawDeviceCommand(val rawValue: String) {
Status("device.status"),
Info("device.info"),
;
companion object {
const val NamespacePrefix: String = "device."
}
}
enum class OpenClawNotificationsCommand(val rawValue: String) {
List("notifications.list"),
;
companion object {
const val NamespacePrefix: String = "notifications."
}
}

View File

@@ -1,150 +0,0 @@
package ai.openclaw.android.ui
import android.annotation.SuppressLint
import android.util.Log
import android.view.View
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import ai.openclaw.android.MainViewModel
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
val webViewRef = remember { mutableStateOf<WebView?>(null) }
DisposableEffect(viewModel) {
onDispose {
val webView = webViewRef.value ?: return@onDispose
viewModel.canvas.detach(webView)
webView.removeJavascriptInterface(CanvasA2UIActionBridge.interfaceName)
webView.stopLoading()
webView.destroy()
webViewRef.value = null
}
}
AndroidView(
modifier = modifier,
factory = {
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
settings.useWideViewPort = false
settings.loadWithOverviewMode = false
settings.builtInZoomControls = false
settings.displayZoomControls = false
settings.setSupportZoom(false)
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
} else {
disableForceDarkIfSupported(settings)
}
if (isDebuggable) {
Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}")
}
isScrollContainer = true
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
isVerticalScrollBarEnabled = true
isHorizontalScrollBarEnabled = true
webViewClient =
object : WebViewClient() {
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e(
"OpenClawWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
override fun onPageFinished(view: WebView, url: String?) {
if (isDebuggable) {
Log.d("OpenClawWebView", "onPageFinished: $url")
}
viewModel.canvas.onPageFinished()
}
override fun onRenderProcessGone(
view: WebView,
detail: android.webkit.RenderProcessGoneDetail,
): Boolean {
if (isDebuggable) {
Log.e(
"OpenClawWebView",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
)
}
return true
}
}
webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (!isDebuggable) return false
val msg = consoleMessage ?: return false
Log.d(
"OpenClawWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
)
return false
}
}
val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) }
addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName)
viewModel.canvas.attach(this)
webViewRef.value = this
}
},
)
}
private fun disableForceDarkIfSupported(settings: WebSettings) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return
@Suppress("DEPRECATION")
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
}
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
@JavascriptInterface
fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
onMessage(msg)
}
companion object {
const val interfaceName: String = "openclawCanvasA2UIAction"
}
}

View File

@@ -1,493 +0,0 @@
package ai.openclaw.android.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import ai.openclaw.android.MainViewModel
private enum class ConnectInputMode {
SetupCode,
Manual,
}
@Composable
fun ConnectTabScreen(viewModel: MainViewModel) {
val statusText by viewModel.statusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val gatewayToken by viewModel.gatewayToken.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
var advancedOpen by rememberSaveable { mutableStateOf(false) }
var inputMode by
remember(manualEnabled, manualHost, gatewayToken) {
mutableStateOf(
if (manualEnabled || manualHost.isNotBlank() || gatewayToken.trim().isNotEmpty()) {
ConnectInputMode.Manual
} else {
ConnectInputMode.SetupCode
},
)
}
var setupCode by rememberSaveable { mutableStateOf("") }
var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) }
var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) }
var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) }
var passwordInput by rememberSaveable { mutableStateOf("") }
var validationText by rememberSaveable { mutableStateOf<String?>(null) }
if (pendingTrust != null) {
val prompt = pendingTrust!!
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
title = { Text("Trust this gateway?") },
text = {
Text(
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = mobileCallout,
)
},
confirmButton = {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
Text("Trust and continue")
}
},
dismissButton = {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
Text("Cancel")
}
},
)
}
val setupResolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } }
val manualResolvedEndpoint = remember(manualHostInput, manualPortInput, manualTlsInput) {
composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseGatewayEndpoint(it)?.displayUrl }
}
val activeEndpoint =
remember(isConnected, remoteAddress, setupResolvedEndpoint, manualResolvedEndpoint, inputMode) {
when {
isConnected && !remoteAddress.isNullOrBlank() -> remoteAddress!!
inputMode == ConnectInputMode.SetupCode -> setupResolvedEndpoint ?: "Not set"
else -> manualResolvedEndpoint ?: "Not set"
}
}
val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway"
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Connection Control", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent)
Text("Gateway Connection", style = mobileTitle1, color = mobileText)
Text(
"One primary action. Open advanced controls only when needed.",
style = mobileCallout,
color = mobileTextSecondary,
)
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Active endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Gateway state", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(statusText, style = mobileBody, color = mobileText)
}
}
Button(
onClick = {
if (isConnected) {
viewModel.disconnect()
validationText = null
return@Button
}
if (statusText.contains("operator offline", ignoreCase = true)) {
validationText = null
viewModel.refreshGatewayConnection()
return@Button
}
val config =
resolveGatewayConnectConfig(
useSetupCode = inputMode == ConnectInputMode.SetupCode,
setupCode = setupCode,
manualHost = manualHostInput,
manualPort = manualPortInput,
manualTls = manualTlsInput,
fallbackToken = gatewayToken,
fallbackPassword = passwordInput,
)
if (config == null) {
validationText =
if (inputMode == ConnectInputMode.SetupCode) {
"Paste a valid setup code to connect."
} else {
"Enter a valid manual host and port to connect."
}
return@Button
}
validationText = null
viewModel.setManualEnabled(true)
viewModel.setManualHost(config.host)
viewModel.setManualPort(config.port)
viewModel.setManualTls(config.tls)
if (config.token.isNotBlank()) {
viewModel.setGatewayToken(config.token)
}
viewModel.setGatewayPassword(config.password)
viewModel.connectManual()
},
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = if (isConnected) mobileDanger else mobileAccent,
contentColor = Color.White,
),
) {
Text(primaryLabel, style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
onClick = { advancedOpen = !advancedOpen },
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Advanced controls", style = mobileHeadline, color = mobileText)
Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary)
}
Icon(
imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (advancedOpen) "Collapse advanced controls" else "Expand advanced controls",
tint = mobileTextSecondary,
)
}
}
AnimatedVisibility(visible = advancedOpen) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color.White,
border = BorderStroke(1.dp, mobileBorder),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MethodChip(
label = "Setup Code",
active = inputMode == ConnectInputMode.SetupCode,
onClick = { inputMode = ConnectInputMode.SetupCode },
)
MethodChip(
label = "Manual",
active = inputMode == ConnectInputMode.Manual,
onClick = { inputMode = ConnectInputMode.Manual },
)
}
Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary)
CommandBlock("openclaw qr --setup-code-only")
CommandBlock("openclaw qr --json")
if (inputMode == ConnectInputMode.SetupCode) {
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = setupCode,
onValueChange = {
setupCode = it
validationText = null
},
placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
if (!setupResolvedEndpoint.isNullOrBlank()) {
EndpointPreview(endpoint = setupResolvedEndpoint)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
QuickFillChip(
label = "Android Emulator",
onClick = {
manualHostInput = "10.0.2.2"
manualPortInput = "18789"
manualTlsInput = false
validationText = null
},
)
QuickFillChip(
label = "Localhost",
onClick = {
manualHostInput = "127.0.0.1"
manualPortInput = "18789"
manualTlsInput = false
validationText = null
},
)
}
Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = manualHostInput,
onValueChange = {
manualHostInput = it
validationText = null
},
placeholder = { Text("10.0.2.2", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
textStyle = mobileBody.copy(color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
Text("Port", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = manualPortInput,
onValueChange = {
manualPortInput = it
validationText = null
},
placeholder = { Text("18789", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = mobileHeadline, color = mobileText)
Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary)
}
Switch(
checked = manualTlsInput,
onCheckedChange = {
manualTlsInput = it
validationText = null
},
colors =
SwitchDefaults.colors(
checkedTrackColor = mobileAccent,
uncheckedTrackColor = mobileBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
)
}
Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = gatewayToken,
onValueChange = { viewModel.setGatewayToken(it) },
placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = mobileBody.copy(color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
Text("Password (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = passwordInput,
onValueChange = { passwordInput = it },
placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = mobileBody.copy(color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
if (!manualResolvedEndpoint.isNullOrBlank()) {
EndpointPreview(endpoint = manualResolvedEndpoint)
}
}
HorizontalDivider(color = mobileBorder)
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
}
}
}
}
if (!validationText.isNullOrBlank()) {
Text(validationText!!, style = mobileCaption1, color = mobileWarning)
}
}
}
@Composable
private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.height(40.dp),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = if (active) mobileAccent else mobileSurface,
contentColor = if (active) Color.White else mobileText,
),
border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong),
) {
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold))
}
}
@Composable
private fun QuickFillChip(label: String, onClick: () -> Unit) {
Button(
onClick = onClick,
shape = RoundedCornerShape(999.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = mobileAccentSoft,
contentColor = mobileAccent,
),
elevation = null,
) {
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold))
}
}
@Composable
private fun CommandBlock(command: String) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A)))
Text(
text = command,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = mobileCallout.copy(fontFamily = FontFamily.Monospace),
color = mobileCodeText,
)
}
}
}
@Composable
private fun EndpointPreview(endpoint: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
HorizontalDivider(color = mobileBorder)
Text("Resolved endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(endpoint, style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileText)
HorizontalDivider(color = mobileBorder)
}
}
@Composable
private fun outlinedColors() =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = mobileSurface,
unfocusedContainerColor = mobileSurface,
focusedBorderColor = mobileAccent,
unfocusedBorderColor = mobileBorder,
focusedTextColor = mobileText,
unfocusedTextColor = mobileText,
cursorColor = mobileAccent,
)

View File

@@ -1,142 +0,0 @@
package ai.openclaw.android.ui
import androidx.core.net.toUri
import java.util.Base64
import java.util.Locale
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
internal data class GatewayEndpointConfig(
val host: String,
val port: Int,
val tls: Boolean,
val displayUrl: String,
)
internal data class GatewaySetupCode(
val url: String,
val token: String?,
val password: String?,
)
internal data class GatewayConnectConfig(
val host: String,
val port: Int,
val tls: Boolean,
val token: String,
val password: String,
)
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
setupCode: String,
manualHost: String,
manualPort: String,
manualTls: Boolean,
fallbackToken: String,
fallbackPassword: String,
): GatewayConnectConfig? {
if (useSetupCode) {
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpoint(setup.url) ?: return null
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
token = setup.token ?: fallbackToken.trim(),
password = setup.password ?: fallbackPassword.trim(),
)
}
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null
val parsed = parseGatewayEndpoint(manualUrl) ?: return null
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
token = fallbackToken.trim(),
password = fallbackPassword.trim(),
)
}
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
val raw = rawInput.trim()
if (raw.isEmpty()) return null
val normalized = if (raw.contains("://")) raw else "https://$raw"
val uri = normalized.toUri()
val host = uri.host?.trim().orEmpty()
if (host.isEmpty()) return null
val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty()
val tls =
when (scheme) {
"ws", "http" -> false
"wss", "https" -> true
else -> true
}
val port = uri.port.takeIf { it in 1..65535 } ?: 18789
val displayUrl = "${if (tls) "https" else "http"}://$host:$port"
return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl)
}
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
val trimmed = rawInput.trim()
if (trimmed.isEmpty()) return null
val padded =
trimmed
.replace('-', '+')
.replace('_', '/')
.let { normalized ->
val remainder = normalized.length % 4
if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder)
}
return try {
val decoded = String(Base64.getDecoder().decode(padded), Charsets.UTF_8)
val obj = parseJsonObject(decoded) ?: return null
val url = jsonField(obj, "url").orEmpty()
if (url.isEmpty()) return null
val token = jsonField(obj, "token")
val password = jsonField(obj, "password")
GatewaySetupCode(url = url, token = token, password = password)
} catch (_: IllegalArgumentException) {
null
}
}
internal fun resolveScannedSetupCode(rawInput: String): String? {
val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null
return setupCode.takeIf { decodeGatewaySetupCode(it) != null }
}
internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? {
val host = hostInput.trim()
val port = portInput.trim().toIntOrNull() ?: return null
if (host.isEmpty() || port !in 1..65535) return null
val scheme = if (tls) "https" else "http"
return "$scheme://$host:$port"
}
private fun parseJsonObject(input: String): JsonObject? {
return runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull()
}
private fun resolveSetupCodeCandidate(rawInput: String): String? {
val trimmed = rawInput.trim()
if (trimmed.isEmpty()) return null
val qrSetupCode = parseJsonObject(trimmed)?.let { jsonField(it, "setupCode") }
return qrSetupCode ?: trimmed
}
private fun jsonField(obj: JsonObject, key: String): String? {
val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
return value.ifEmpty { null }
}

View File

@@ -1,106 +0,0 @@
package ai.openclaw.android.ui
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import ai.openclaw.android.R
internal val mobileBackgroundGradient =
Brush.verticalGradient(
listOf(
Color(0xFFFFFFFF),
Color(0xFFF7F8FA),
Color(0xFFEFF1F5),
),
)
internal val mobileSurface = Color(0xFFF6F7FA)
internal val mobileSurfaceStrong = Color(0xFFECEEF3)
internal val mobileBorder = Color(0xFFE5E7EC)
internal val mobileBorderStrong = Color(0xFFD6DAE2)
internal val mobileText = Color(0xFF17181C)
internal val mobileTextSecondary = Color(0xFF5D6472)
internal val mobileTextTertiary = Color(0xFF99A0AE)
internal val mobileAccent = Color(0xFF1D5DD8)
internal val mobileAccentSoft = Color(0xFFECF3FF)
internal val mobileSuccess = Color(0xFF2F8C5A)
internal val mobileSuccessSoft = Color(0xFFEEF9F3)
internal val mobileWarning = Color(0xFFC8841A)
internal val mobileWarningSoft = Color(0xFFFFF8EC)
internal val mobileDanger = Color(0xFFD04B4B)
internal val mobileDangerSoft = Color(0xFFFFF2F2)
internal val mobileCodeBg = Color(0xFF15171B)
internal val mobileCodeText = Color(0xFFE8EAEE)
internal val mobileFontFamily =
FontFamily(
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
internal val mobileTitle1 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 30.sp,
letterSpacing = (-0.5).sp,
)
internal val mobileTitle2 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = (-0.3).sp,
)
internal val mobileHeadline =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = (-0.1).sp,
)
internal val mobileBody =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 22.sp,
)
internal val mobileCallout =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
)
internal val mobileCaption1 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
)
internal val mobileCaption2 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.4.sp,
)

View File

@@ -1,320 +0,0 @@
package ai.openclaw.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ai.openclaw.android.MainViewModel
private enum class HomeTab(
val label: String,
val icon: ImageVector,
) {
Connect(label = "Connect", icon = Icons.Default.CheckCircle),
Chat(label = "Chat", icon = Icons.Default.ChatBubble),
Voice(label = "Voice", icon = Icons.Default.RecordVoiceOver),
Screen(label = "Screen", icon = Icons.AutoMirrored.Filled.ScreenShare),
Settings(label = "Settings", icon = Icons.Default.Settings),
}
private enum class StatusVisual {
Connected,
Connecting,
Warning,
Error,
Offline,
}
@Composable
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
val statusText by viewModel.statusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val statusVisual =
remember(statusText, isConnected) {
val lower = statusText.lowercase()
when {
isConnected -> StatusVisual.Connected
lower.contains("connecting") || lower.contains("reconnecting") -> StatusVisual.Connecting
lower.contains("pairing") || lower.contains("approval") || lower.contains("auth") -> StatusVisual.Warning
lower.contains("error") || lower.contains("failed") -> StatusVisual.Error
else -> StatusVisual.Offline
}
}
val density = LocalDensity.current
val imeVisible = WindowInsets.ime.getBottom(density) > 0
val hideBottomTabBar = activeTab == HomeTab.Chat && imeVisible
Scaffold(
modifier = modifier,
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
TopStatusBar(
statusText = statusText,
statusVisual = statusVisual,
)
},
bottomBar = {
if (!hideBottomTabBar) {
BottomTabBar(
activeTab = activeTab,
onSelect = { activeTab = it },
)
}
},
) { innerPadding ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.background(mobileBackgroundGradient),
) {
when (activeTab) {
HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel)
HomeTab.Chat -> ChatSheet(viewModel = viewModel)
HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel)
HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel)
HomeTab.Settings -> SettingsSheet(viewModel = viewModel)
}
}
}
}
@Composable
private fun ScreenTabScreen(viewModel: MainViewModel) {
val isConnected by viewModel.isConnected.collectAsState()
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val canvasUrl by viewModel.canvasCurrentUrl.collectAsState()
val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState()
val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState()
val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true
val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated))
val restoreCtaText =
when {
canvasRehydratePending -> "Restore requested. Waiting for agent…"
!canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!!
else -> "Canvas reset. Tap to restore dashboard."
}
Box(modifier = Modifier.fillMaxSize()) {
CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
if (showRestoreCta) {
Surface(
onClick = {
if (canvasRehydratePending) return@Surface
viewModel.requestCanvasRehydrate(source = "screen_tab_cta")
},
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp),
shape = RoundedCornerShape(12.dp),
color = mobileSurface.copy(alpha = 0.9f),
border = BorderStroke(1.dp, mobileBorder),
shadowElevation = 4.dp,
) {
Text(
text = restoreCtaText,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = mobileCallout.copy(fontWeight = FontWeight.Medium),
color = mobileText,
)
}
}
}
}
@Composable
private fun TopStatusBar(
statusText: String,
statusVisual: StatusVisual,
) {
val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
val (chipBg, chipDot, chipText, chipBorder) =
when (statusVisual) {
StatusVisual.Connected ->
listOf(
mobileSuccessSoft,
mobileSuccess,
mobileSuccess,
Color(0xFFCFEBD8),
)
StatusVisual.Connecting ->
listOf(
mobileAccentSoft,
mobileAccent,
mobileAccent,
Color(0xFFD5E2FA),
)
StatusVisual.Warning ->
listOf(
mobileWarningSoft,
mobileWarning,
mobileWarning,
Color(0xFFEED8B8),
)
StatusVisual.Error ->
listOf(
mobileDangerSoft,
mobileDanger,
mobileDanger,
Color(0xFFF3C8C8),
)
StatusVisual.Offline ->
listOf(
mobileSurface,
mobileTextTertiary,
mobileTextSecondary,
mobileBorder,
)
}
Surface(
modifier = Modifier.fillMaxWidth().windowInsetsPadding(safeInsets),
color = Color.Transparent,
shadowElevation = 0.dp,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "OpenClaw",
style = mobileTitle2,
color = mobileText,
)
Surface(
shape = RoundedCornerShape(999.dp),
color = chipBg,
border = androidx.compose.foundation.BorderStroke(1.dp, chipBorder),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
modifier = Modifier.padding(top = 1.dp),
color = chipDot,
shape = RoundedCornerShape(999.dp),
) {
Box(modifier = Modifier.padding(4.dp))
}
Text(
text = statusText.trim().ifEmpty { "Offline" },
style = mobileCaption1,
color = chipText,
maxLines = 1,
)
}
}
}
}
}
@Composable
private fun BottomTabBar(
activeTab: HomeTab,
onSelect: (HomeTab) -> Unit,
) {
val safeInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal)
Box(
modifier =
Modifier
.fillMaxWidth(),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White.copy(alpha = 0.97f),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
border = BorderStroke(1.dp, mobileBorder),
shadowElevation = 6.dp,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(safeInsets)
.padding(horizontal = 10.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HomeTab.entries.forEach { tab ->
val active = tab == activeTab
Surface(
onClick = { onSelect(tab) },
modifier = Modifier.weight(1f).heightIn(min = 58.dp),
shape = RoundedCornerShape(16.dp),
color = if (active) mobileAccentSoft else Color.Transparent,
border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null,
shadowElevation = 0.dp,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 7.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Icon(
imageVector = tab.icon,
contentDescription = tab.label,
tint = if (active) mobileAccent else mobileTextTertiary,
)
Text(
text = tab.label,
color = if (active) mobileAccent else mobileTextSecondary,
style = mobileCaption2.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.Medium),
)
}
}
}
}
}
}
}

View File

@@ -1,20 +1,429 @@
package ai.openclaw.android.ui
import android.annotation.SuppressLint
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.util.Log
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebViewClient
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat
import ai.openclaw.android.CameraHudKind
import ai.openclaw.android.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootScreen(viewModel: MainViewModel) {
val onboardingCompleted by viewModel.onboardingCompleted.collectAsState()
var sheet by remember { mutableStateOf<Sheet?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
val context = LocalContext.current
val serverName by viewModel.serverName.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val cameraHud by viewModel.cameraHud.collectAsState()
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
val screenRecordActive by viewModel.screenRecordActive.collectAsState()
val isForeground by viewModel.isForeground.collectAsState()
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
val talkEnabled by viewModel.talkEnabled.collectAsState()
val talkStatusText by viewModel.talkStatusText.collectAsState()
val talkIsListening by viewModel.talkIsListening.collectAsState()
val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState()
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
val audioPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) viewModel.setTalkEnabled(true)
}
val activity =
remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) {
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
if (!isForeground) {
return@remember StatusActivity(
title = "Foreground required",
icon = Icons.Default.Report,
contentDescription = "Foreground required",
)
}
if (!onboardingCompleted) {
OnboardingFlow(viewModel = viewModel, modifier = Modifier.fillMaxSize())
return
val lowerStatus = statusText.lowercase()
if (lowerStatus.contains("repair")) {
return@remember StatusActivity(
title = "Repairing…",
icon = Icons.Default.Refresh,
contentDescription = "Repairing",
)
}
if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) {
return@remember StatusActivity(
title = "Approval pending",
icon = Icons.Default.RecordVoiceOver,
contentDescription = "Approval pending",
)
}
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
if (screenRecordActive) {
return@remember StatusActivity(
title = "Recording screen…",
icon = Icons.AutoMirrored.Filled.ScreenShare,
contentDescription = "Recording screen",
tint = androidx.compose.ui.graphics.Color.Red,
)
}
cameraHud?.let { hud ->
return@remember when (hud.kind) {
CameraHudKind.Photo ->
StatusActivity(
title = hud.message,
icon = Icons.Default.PhotoCamera,
contentDescription = "Taking photo",
)
CameraHudKind.Recording ->
StatusActivity(
title = hud.message,
icon = Icons.Default.FiberManualRecord,
contentDescription = "Recording",
tint = androidx.compose.ui.graphics.Color.Red,
)
CameraHudKind.Success ->
StatusActivity(
title = hud.message,
icon = Icons.Default.CheckCircle,
contentDescription = "Capture finished",
)
CameraHudKind.Error ->
StatusActivity(
title = hud.message,
icon = Icons.Default.Error,
contentDescription = "Capture failed",
tint = androidx.compose.ui.graphics.Color.Red,
)
}
}
if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) {
return@remember StatusActivity(
title = "Mic permission",
icon = Icons.Default.Error,
contentDescription = "Mic permission required",
)
}
if (voiceWakeStatusText == "Paused") {
val suffix = if (!isForeground) " (background)" else ""
return@remember StatusActivity(
title = "Voice Wake paused$suffix",
icon = Icons.Default.RecordVoiceOver,
contentDescription = "Voice Wake paused",
)
}
null
}
val gatewayState =
remember(serverName, statusText) {
when {
serverName != null -> GatewayState.Connected
statusText.contains("connecting", ignoreCase = true) ||
statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting
statusText.contains("error", ignoreCase = true) -> GatewayState.Error
else -> GatewayState.Disconnected
}
}
val voiceEnabled =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
Box(modifier = Modifier.fillMaxSize()) {
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
}
PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize())
// Camera flash must be in a Popup to render above the WebView.
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize())
}
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
StatusPill(
gateway = gatewayState,
voiceEnabled = voiceEnabled,
activity = activity,
onClick = { sheet = Sheet.Settings },
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
)
}
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
Column(
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.End,
) {
OverlayIconButton(
onClick = { sheet = Sheet.Chat },
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
)
// Talk mode gets a dedicated side bubble instead of burying it in settings.
val baseOverlay = overlayContainerColor()
val talkContainer =
lerp(
baseOverlay,
seamColor.copy(alpha = baseOverlay.alpha),
if (talkEnabled) 0.35f else 0.22f,
)
val talkContent = if (talkEnabled) seamColor else overlayIconColor()
OverlayIconButton(
onClick = {
val next = !talkEnabled
if (next) {
val micOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
viewModel.setTalkEnabled(true)
} else {
viewModel.setTalkEnabled(false)
}
},
containerColor = talkContainer,
contentColor = talkContent,
icon = {
Icon(
Icons.Default.RecordVoiceOver,
contentDescription = "Talk Mode",
)
},
)
OverlayIconButton(
onClick = { sheet = Sheet.Settings },
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
)
}
}
if (talkEnabled) {
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
TalkOrbOverlay(
seamColor = seamColor,
statusText = talkStatusText,
isListening = talkIsListening,
isSpeaking = talkIsSpeaking,
)
}
}
val currentSheet = sheet
if (currentSheet != null) {
ModalBottomSheet(
onDismissRequest = { sheet = null },
sheetState = sheetState,
) {
when (currentSheet) {
Sheet.Chat -> ChatSheet(viewModel = viewModel)
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
}
}
}
}
private enum class Sheet {
Chat,
Settings,
}
@Composable
private fun OverlayIconButton(
onClick: () -> Unit,
icon: @Composable () -> Unit,
containerColor: ComposeColor? = null,
contentColor: ComposeColor? = null,
) {
FilledTonalIconButton(
onClick = onClick,
modifier = Modifier.size(44.dp),
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = containerColor ?: overlayContainerColor(),
contentColor = contentColor ?: overlayIconColor(),
),
) {
icon()
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
AndroidView(
modifier = modifier,
factory = {
WebView(context).apply {
settings.javaScriptEnabled = true
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
} else {
disableForceDarkIfSupported(settings)
}
if (isDebuggable) {
Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}")
}
isScrollContainer = true
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
isVerticalScrollBarEnabled = true
isHorizontalScrollBarEnabled = true
webViewClient =
object : WebViewClient() {
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable) return
if (!request.isForMainFrame) return
Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable) return
if (!request.isForMainFrame) return
Log.e(
"OpenClawWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
override fun onPageFinished(view: WebView, url: String?) {
if (isDebuggable) {
Log.d("OpenClawWebView", "onPageFinished: $url")
}
viewModel.canvas.onPageFinished()
}
override fun onRenderProcessGone(
view: WebView,
detail: android.webkit.RenderProcessGoneDetail,
): Boolean {
if (isDebuggable) {
Log.e(
"OpenClawWebView",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
)
}
return true
}
}
webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (!isDebuggable) return false
val msg = consoleMessage ?: return false
Log.d(
"OpenClawWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
)
return false
}
}
// Use default layer/background; avoid forcing a black fill over WebView content.
val a2uiBridge =
CanvasA2UIActionBridge { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
}
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
viewModel.canvas.attach(this)
}
},
)
}
private fun disableForceDarkIfSupported(settings: WebSettings) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return
@Suppress("DEPRECATION")
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
}
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
@JavascriptInterface
fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
onMessage(msg)
}
companion object {
const val interfaceName: String = "openclawCanvasA2UIAction"
}
}

View File

@@ -9,12 +9,10 @@ import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -25,27 +23,29 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -53,33 +53,51 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.android.BuildConfig
import ai.openclaw.android.LocationMode
import ai.openclaw.android.MainViewModel
import ai.openclaw.android.NodeForegroundService
import ai.openclaw.android.VoiceWakeMode
import ai.openclaw.android.WakeWords
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val locationMode by viewModel.locationMode.collectAsState()
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
val wakeWords by viewModel.wakeWords.collectAsState()
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val gatewayToken by viewModel.gatewayToken.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val gateways by viewModel.gateways.collectAsState()
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
var wakeWordsHadFocus by remember { mutableStateOf(false) }
val deviceModel =
remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
@@ -96,14 +114,39 @@ fun SettingsSheet(viewModel: MainViewModel) {
versionName
}
}
val listItemColors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = mobileText,
supportingColor = mobileTextSecondary,
trailingIconColor = mobileTextSecondary,
leadingIconColor = mobileTextSecondary,
if (pendingTrust != null) {
val prompt = pendingTrust!!
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
title = { Text("Trust this gateway?") },
text = {
Text(
"First-time TLS connection.\n\n" +
"Verify this SHA-256 fingerprint out-of-band before trusting:\n" +
prompt.fingerprintSha256,
)
},
confirmButton = {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
Text("Trust and connect")
}
},
dismissButton = {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
Text("Cancel")
}
},
)
}
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val commitWakeWords = {
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
if (parsed != null) {
viewModel.setWakeWords(parsed)
}
}
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
@@ -146,16 +189,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
var micPermissionGranted by
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED,
)
}
val audioPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
micPermissionGranted = granted
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
// Status text is handled by NodeRuntime.
}
val smsPermissionAvailable =
@@ -175,22 +211,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.refreshGatewayConnection()
}
DisposableEffect(lifecycleOwner, context) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
micPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
fun setCameraEnabledChecked(checked: Boolean) {
if (!checked) {
viewModel.setCameraEnabled(false)
@@ -248,152 +268,324 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
Box(
val visibleGateways =
if (isConnected && remoteAddress != null) {
gateways.filterNot { "${it.host}:${it.port}" == remoteAddress }
} else {
gateways
}
val gatewayDiscoveryFooterText =
if (visibleGateways.isEmpty()) {
discoveryStatusText
} else if (isConnected) {
"Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found"
} else {
"Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found"
}
LazyColumn(
state = listState,
modifier =
Modifier
.fillMaxSize()
.background(mobileBackgroundGradient),
.fillMaxWidth()
.fillMaxHeight()
.imePadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
LazyColumn(
state = listState,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight()
.imePadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
"SETTINGS",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
Text("Device Configuration", style = mobileTitle2, color = mobileText)
Text(
"Manage capabilities, permissions, and diagnostics.",
style = mobileCallout,
color = mobileTextSecondary,
)
}
}
item { HorizontalDivider(color = mobileBorder) }
// Order parity: Node → Voice → Camera → Messaging → Location → Screen.
item {
Text(
"NODE",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
// Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen.
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
item {
OutlinedTextField(
value = displayName,
onValueChange = viewModel::setDisplayName,
label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
)
}
item { Text("Instance ID: $instanceId", style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileTextSecondary) }
item { Text("Device: $deviceModel", style = mobileCallout, color = mobileTextSecondary) }
item { Text("Version: $appVersion", style = mobileCallout, color = mobileTextSecondary) }
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { HorizontalDivider(color = mobileBorder) }
item { HorizontalDivider() }
// Voice
// Gateway
item { Text("Gateway", style = MaterialTheme.typography.titleSmall) }
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
if (serverName != null) {
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
}
if (remoteAddress != null) {
item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
}
item {
// UI sanity: "Disconnect" only when we have an active remote.
if (isConnected && remoteAddress != null) {
Button(
onClick = {
viewModel.disconnect()
NodeForegroundService.stop(context)
},
) {
Text("Disconnect")
}
}
}
item { HorizontalDivider() }
if (!isConnected || visibleGateways.isNotEmpty()) {
item {
Text(
"VOICE",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
if (isConnected) "Other Gateways" else "Discovered Gateways",
style = MaterialTheme.typography.titleSmall,
)
}
item {
ListItem(
modifier = settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Microphone permission", style = mobileHeadline) },
supportingContent = {
Text(
if (micPermissionGranted) {
"Granted. Use the Voice tab mic button to capture transcript."
} else {
"Required for Voice tab transcription."
},
style = mobileCallout,
)
},
trailingContent = {
Button(
onClick = {
if (micPermissionGranted) {
openAppSettings(context)
} else {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (micPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
if (!isConnected && visibleGateways.isEmpty()) {
item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else {
items(items = visibleGateways, key = { it.stableId }) { gateway ->
val detailLines =
buildList {
add("IP: ${gateway.host}:${gateway.port}")
gateway.lanHost?.let { add("LAN: $it") }
gateway.tailnetDns?.let { add("Tailnet: $it") }
if (gateway.gatewayPort != null || gateway.canvasPort != null) {
val gw = (gateway.gatewayPort ?: gateway.port).toString()
val canvas = gateway.canvasPort?.toString() ?: ""
add("Ports: gw $gw · canvas $canvas")
}
}
},
)
ListItem(
headlineContent = { Text(gateway.name) },
supportingContent = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
detailLines.forEach { line ->
Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
trailingContent = {
Button(
onClick = {
NodeForegroundService.start(context)
viewModel.connect(gateway)
},
) {
Text("Connect")
}
},
)
}
}
item {
Text(
"Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab.",
style = mobileCallout,
color = mobileTextSecondary,
gatewayDiscoveryFooterText,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item { HorizontalDivider(color = mobileBorder) }
item { HorizontalDivider() }
// Camera
item {
Text(
"CAMERA",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item {
ListItem(
modifier = settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Allow Camera", style = mobileHeadline) },
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) },
headlineContent = { Text("Advanced") },
supportingContent = { Text("Manual gateway connection") },
trailingContent = {
Icon(
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (advancedExpanded) "Collapse" else "Expand",
)
},
modifier =
Modifier.clickable {
setAdvancedExpanded(!advancedExpanded)
},
)
}
item {
AnimatedVisibility(visible = advancedExpanded) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Use Manual Gateway") },
supportingContent = { Text("Use this when discovery is blocked.") },
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
)
OutlinedTextField(
value = manualHost,
onValueChange = viewModel::setManualHost,
label = { Text("Host") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
OutlinedTextField(
value = manualPort.toString(),
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
label = { Text("Port") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
OutlinedTextField(
value = gatewayToken,
onValueChange = viewModel::setGatewayToken,
label = { Text("Gateway Token") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
singleLine = true,
)
ListItem(
headlineContent = { Text("Require TLS") },
supportingContent = { Text("Pin the gateway certificate on first connect.") },
trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) },
modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f),
)
val hostOk = manualHost.trim().isNotEmpty()
val portOk = manualPort in 1..65535
Button(
onClick = {
NodeForegroundService.start(context)
viewModel.connectManual()
},
enabled = manualEnabled && hostOk && portOk,
) {
Text("Connect (Manual)")
}
}
}
}
item { HorizontalDivider() }
// Voice
item { Text("Voice", style = MaterialTheme.typography.titleSmall) }
item {
val enabled = voiceWakeMode != VoiceWakeMode.Off
ListItem(
headlineContent = { Text("Voice Wake") },
supportingContent = { Text(voiceWakeStatusText) },
trailingContent = {
Switch(
checked = enabled,
onCheckedChange = { on ->
if (on) {
val micOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
} else {
viewModel.setVoiceWakeMode(VoiceWakeMode.Off)
}
},
)
},
)
}
item {
AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Foreground Only") },
supportingContent = { Text("Listens only while OpenClaw is open.") },
trailingContent = {
RadioButton(
selected = voiceWakeMode == VoiceWakeMode.Foreground,
onClick = {
val micOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
},
)
},
)
ListItem(
headlineContent = { Text("Always") },
supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") },
trailingContent = {
RadioButton(
selected = voiceWakeMode == VoiceWakeMode.Always,
onClick = {
val micOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
viewModel.setVoiceWakeMode(VoiceWakeMode.Always)
},
)
},
)
}
}
}
item {
OutlinedTextField(
value = wakeWordsText,
onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") },
modifier =
Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (focusState.isFocused) {
wakeWordsHadFocus = true
} else if (wakeWordsHadFocus) {
wakeWordsHadFocus = false
commitWakeWords()
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
commitWakeWords()
focusManager.clearFocus()
},
),
)
}
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
item {
Text(
if (isConnected) {
"Any node can edit wake words. Changes sync via the gateway."
} else {
"Connect to a gateway to sync wake words globally."
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { HorizontalDivider() }
// Camera
item { Text("Camera", style = MaterialTheme.typography.titleSmall) }
item {
ListItem(
headlineContent = { Text("Allow Camera") },
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") },
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
)
}
item {
Text(
"Tip: grant Microphone permission for video clips with audio.",
style = mobileCallout,
color = mobileTextSecondary,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { HorizontalDivider(color = mobileBorder) }
item { HorizontalDivider() }
// Messaging
item {
Text(
"MESSAGING",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { Text("Messaging", style = MaterialTheme.typography.titleSmall) }
item {
val buttonLabel =
when {
@@ -402,9 +594,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
else -> "Grant"
}
ListItem(
modifier = settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("SMS Permission", style = mobileHeadline) },
headlineContent = { Text("SMS Permission") },
supportingContent = {
Text(
if (smsPermissionAvailable) {
@@ -412,7 +602,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
} else {
"SMS requires a device with telephony hardware."
},
style = mobileCallout,
)
},
trailingContent = {
@@ -426,125 +615,91 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
},
enabled = smsPermissionAvailable,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
Text(buttonLabel)
}
},
)
}
item { HorizontalDivider(color = mobileBorder) }
item { HorizontalDivider() }
// Location
item {
Text(
"LOCATION",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
item {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Off") },
supportingContent = { Text("Disable location sharing.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Off,
onClick = { viewModel.setLocationMode(LocationMode.Off) },
)
},
)
ListItem(
headlineContent = { Text("While Using") },
supportingContent = { Text("Only while OpenClaw is open.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.WhileUsing,
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
)
},
)
ListItem(
headlineContent = { Text("Always") },
supportingContent = { Text("Allow background location (requires system permission).") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Always,
onClick = { requestLocationPermissions(LocationMode.Always) },
)
},
)
}
item {
Column(modifier = settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) {
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Off", style = mobileHeadline) },
supportingContent = { Text("Disable location sharing.", style = mobileCallout) },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Off,
onClick = { viewModel.setLocationMode(LocationMode.Off) },
)
},
}
item {
ListItem(
headlineContent = { Text("Precise Location") },
supportingContent = { Text("Use precise GPS when available.") },
trailingContent = {
Switch(
checked = locationPreciseEnabled,
onCheckedChange = ::setPreciseLocationChecked,
enabled = locationMode != LocationMode.Off,
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("While Using", style = mobileHeadline) },
supportingContent = { Text("Only while OpenClaw is open.", style = mobileCallout) },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.WhileUsing,
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
)
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Always", style = mobileHeadline) },
supportingContent = { Text("Allow background location (requires system permission).", style = mobileCallout) },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Always,
onClick = { requestLocationPermissions(LocationMode.Always) },
)
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Precise Location", style = mobileHeadline) },
supportingContent = { Text("Use precise GPS when available.", style = mobileCallout) },
trailingContent = {
Switch(
checked = locationPreciseEnabled,
onCheckedChange = ::setPreciseLocationChecked,
enabled = locationMode != LocationMode.Off,
)
},
)
}
}
},
)
}
item {
Text(
"Always may require Android Settings to allow background location.",
style = mobileCallout,
color = mobileTextSecondary,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { HorizontalDivider(color = mobileBorder) }
item { HorizontalDivider() }
// Screen
item {
Text(
"SCREEN",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
item {
ListItem(
modifier = settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Prevent Sleep", style = mobileHeadline) },
supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) },
headlineContent = { Text("Prevent Sleep") },
supportingContent = { Text("Keeps the screen awake while OpenClaw is open.") },
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
)
}
item { HorizontalDivider(color = mobileBorder) }
item { HorizontalDivider() }
// Debug
item {
Text(
"DEBUG",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { Text("Debug", style = MaterialTheme.typography.titleSmall) }
item {
ListItem(
modifier = settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) },
supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) },
headlineContent = { Text("Debug Canvas Status") },
supportingContent = { Text("Show status text in the canvas when debug is enabled.") },
trailingContent = {
Switch(
checked = canvasDebugStatusEnabled,
@@ -554,47 +709,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
item { Spacer(modifier = Modifier.height(24.dp)) }
}
item { Spacer(modifier = Modifier.height(20.dp)) }
}
}
@Composable
private fun settingsTextFieldColors() =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = mobileSurface,
unfocusedContainerColor = mobileSurface,
focusedBorderColor = mobileAccent,
unfocusedBorderColor = mobileBorder,
focusedTextColor = mobileText,
unfocusedTextColor = mobileText,
cursorColor = mobileAccent,
)
private fun settingsRowModifier() =
Modifier
.fillMaxWidth()
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
.background(Color.White, RoundedCornerShape(14.dp))
@Composable
private fun settingsPrimaryButtonColors() =
ButtonDefaults.buttonColors(
containerColor = mobileAccent,
contentColor = Color.White,
disabledContainerColor = mobileAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
@Composable
private fun settingsDangerButtonColors() =
ButtonDefaults.buttonColors(
containerColor = mobileDanger,
contentColor = Color.White,
disabledContainerColor = mobileDanger.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
private fun openAppSettings(context: Context) {
val intent =
Intent(

View File

@@ -0,0 +1,114 @@
package ai.openclaw.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun StatusPill(
gateway: GatewayState,
voiceEnabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
activity: StatusActivity? = null,
) {
Surface(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(14.dp),
color = overlayContainerColor(),
tonalElevation = 3.dp,
shadowElevation = 0.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(9.dp),
shape = CircleShape,
color = gateway.color,
) {}
Text(
text = gateway.title,
style = MaterialTheme.typography.labelLarge,
)
}
VerticalDivider(
modifier = Modifier.height(14.dp).alpha(0.35f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (activity != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = activity.icon,
contentDescription = activity.contentDescription,
tint = activity.tint ?: overlayIconColor(),
modifier = Modifier.size(18.dp),
)
Text(
text = activity.title,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
} else {
Icon(
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
tint =
if (voiceEnabled) {
overlayIconColor()
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(18.dp),
)
}
Spacer(modifier = Modifier.width(2.dp))
}
}
}
data class StatusActivity(
val title: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val tint: Color? = null,
)
enum class GatewayState(val title: String, val color: Color) {
Connected("Connected", Color(0xFF2ECC71)),
Connecting("Connecting…", Color(0xFFF1C40F)),
Error("Error", Color(0xFFE74C3C)),
Disconnected("Offline", Color(0xFF9E9E9E)),
}

View File

@@ -1,451 +0,0 @@
package ai.openclaw.android.ui
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.android.MainViewModel
import ai.openclaw.android.voice.VoiceConversationEntry
import ai.openclaw.android.voice.VoiceConversationRole
import kotlin.math.PI
import kotlin.math.max
import kotlin.math.sin
@Composable
fun VoiceTabScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val activity = remember(context) { context.findActivity() }
val listState = rememberLazyListState()
val isConnected by viewModel.isConnected.collectAsState()
val gatewayStatus by viewModel.statusText.collectAsState()
val micEnabled by viewModel.micEnabled.collectAsState()
val micStatusText by viewModel.micStatusText.collectAsState()
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
val micConversation by viewModel.micConversation.collectAsState()
val micInputLevel by viewModel.micInputLevel.collectAsState()
val micIsSending by viewModel.micIsSending.collectAsState()
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
val showThinkingBubble = micIsSending && !hasStreamingAssistant
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
var pendingMicEnable by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner, context) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasMicPermission = context.hasRecordAudioPermission()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val requestMicPermission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasMicPermission = granted
if (granted && pendingMicEnable) {
viewModel.setMicEnabled(true)
}
pendingMicEnable = false
}
LaunchedEffect(micConversation.size, showThinkingBubble) {
val total = micConversation.size + if (showThinkingBubble) 1 else 0
if (total > 0) {
listState.animateScrollToItem(total - 1)
}
}
Column(
modifier =
Modifier
.fillMaxSize()
.background(mobileBackgroundGradient)
.imePadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"VOICE",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
Text("Voice mode", style = mobileTitle2, color = mobileText)
}
Surface(
shape = RoundedCornerShape(999.dp),
color = if (isConnected) mobileAccentSoft else mobileSurfaceStrong,
border = BorderStroke(1.dp, if (isConnected) mobileAccent.copy(alpha = 0.25f) else mobileBorderStrong),
) {
Text(
if (isConnected) "Connected" else "Offline",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = mobileCaption1,
color = if (isConnected) mobileAccent else mobileTextSecondary,
)
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (micConversation.isEmpty() && !showThinkingBubble) {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Tap the mic and speak. Each pause sends a turn automatically.",
style = mobileCallout,
color = mobileTextSecondary,
)
}
}
}
items(items = micConversation, key = { it.id }) { entry ->
VoiceTurnBubble(entry = entry)
}
if (showThinkingBubble) {
item {
VoiceThinkingBubble()
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = Color.White,
border = BorderStroke(1.dp, mobileBorder),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
) {
val queueCount = micQueuedMessages.size
val stateText =
when {
queueCount > 0 -> "$queueCount queued"
micIsSending -> "Sending"
micEnabled -> "Listening"
else -> "Mic off"
}
Text(
"$gatewayStatus · $stateText",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
style = mobileCaption1,
color = mobileTextSecondary,
)
}
if (!micLiveTranscript.isNullOrBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileAccentSoft,
border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
) {
Text(
micLiveTranscript!!.trim(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = mobileCallout,
color = mobileText,
)
}
}
MicWaveform(level = micInputLevel, active = micEnabled)
Button(
onClick = {
if (micEnabled) {
viewModel.setMicEnabled(false)
return@Button
}
if (hasMicPermission) {
viewModel.setMicEnabled(true)
} else {
pendingMicEnable = true
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
}
},
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(86.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = if (micEnabled) mobileDanger else mobileAccent,
contentColor = Color.White,
),
) {
Icon(
imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
modifier = Modifier.size(30.dp),
)
}
Text(
if (micEnabled) "Tap to stop" else "Tap to speak",
style = mobileCallout,
color = mobileTextSecondary,
)
if (!hasMicPermission) {
val showRationale =
if (activity == null) {
false
} else {
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
}
Text(
if (showRationale) {
"Microphone permission is required for voice mode."
} else {
"Microphone blocked. Open app settings to enable it."
},
style = mobileCaption1,
color = mobileWarning,
textAlign = TextAlign.Center,
)
Button(
onClick = { openAppSettings(context) },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
) {
Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
}
}
Text(
micStatusText,
style = mobileCaption1,
color = mobileTextTertiary,
)
}
}
}
}
@Composable
private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
val isUser = entry.role == VoiceConversationRole.User
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Surface(
modifier = Modifier.fillMaxWidth(0.90f),
shape = RoundedCornerShape(14.dp),
color = if (isUser) mobileAccentSoft else mobileSurface,
border = BorderStroke(1.dp, if (isUser) mobileAccent.copy(alpha = 0.2f) else mobileBorder),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
if (isUser) "You" else "OpenClaw",
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
color = mobileTextSecondary,
)
Text(
if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text,
style = mobileCallout,
color = mobileText,
)
}
}
}
}
@Composable
private fun VoiceThinkingBubble() {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
modifier = Modifier.fillMaxWidth(0.68f),
shape = RoundedCornerShape(14.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ThinkingDots(color = mobileTextSecondary)
Text("OpenClaw is thinking…", style = mobileCallout, color = mobileTextSecondary)
}
}
}
}
@Composable
private fun ThinkingDots(color: Color) {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
ThinkingDot(alpha = 0.38f, color = color)
ThinkingDot(alpha = 0.62f, color = color)
ThinkingDot(alpha = 0.90f, color = color)
}
}
@Composable
private fun ThinkingDot(alpha: Float, color: Color) {
Surface(
modifier = Modifier.size(6.dp).alpha(alpha),
shape = CircleShape,
color = color,
) {}
}
@Composable
private fun MicWaveform(level: Float, active: Boolean) {
val transition = rememberInfiniteTransition(label = "voiceWave")
val phase by
transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(animation = tween(1_000, easing = LinearEasing), repeatMode = RepeatMode.Restart),
label = "voiceWavePhase",
)
val effective = if (active) level.coerceIn(0f, 1f) else 0f
val base = max(effective, if (active) 0.05f else 0f)
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(16) { index ->
val pulse =
if (!active) {
0f
} else {
((sin(((phase * 2f * PI) + (index * 0.55f)).toDouble()) + 1.0) * 0.5).toFloat()
}
val barHeight = 6.dp + (24.dp * (base * pulse))
Box(
modifier =
Modifier
.width(5.dp)
.height(barHeight)
.background(if (active) mobileAccent else mobileBorderStrong, RoundedCornerShape(999.dp)),
)
}
}
}
private fun Context.hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun Context.findActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
private fun openAppSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
context.startActivity(intent)
}

View File

@@ -1,36 +1,31 @@
package ai.openclaw.android.ui.chat
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -42,168 +37,149 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileAccentSoft
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileBorderStrong
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileHeadline
import ai.openclaw.android.ui.mobileSurface
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
import ai.openclaw.android.ui.mobileTextTertiary
import ai.openclaw.android.chat.ChatSessionEntry
@Composable
fun ChatComposer(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
healthOk: Boolean,
thinkingLevel: String,
pendingRunCount: Int,
errorText: String?,
attachments: List<PendingImageAttachment>,
onPickImages: () -> Unit,
onRemoveAttachment: (id: String) -> Unit,
onSetThinkingLevel: (level: String) -> Unit,
onSelectSession: (sessionKey: String) -> Unit,
onRefresh: () -> Unit,
onAbort: () -> Unit,
onSend: (text: String) -> Unit,
) {
var input by rememberSaveable { mutableStateOf("") }
var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) }
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val currentSessionLabel = friendlySessionName(
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
)
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
val sendBusy = pendingRunCount > 0
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(modifier = Modifier.weight(1f)) {
Surface(
onClick = { showThinkingMenu = true },
shape = RoundedCornerShape(14.dp),
color = mobileAccentSoft,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
FilledTonalButton(
onClick = { showSessionMenu = true },
contentPadding = ButtonDefaults.ContentPadding,
) {
Text(
text = "Thinking: ${thinkingLabel(thinkingLevel)}",
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
color = mobileText,
)
Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary)
Text(currentSessionLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
for (entry in sessionOptions) {
DropdownMenuItem(
text = { Text(friendlySessionName(entry.displayName ?: entry.key)) },
onClick = {
onSelectSession(entry.key)
showSessionMenu = false
},
trailingIcon = {
if (entry.key == sessionKey) {
Text("")
} else {
Spacer(modifier = Modifier.width(10.dp))
}
},
)
}
}
}
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
Box {
FilledTonalButton(
onClick = { showThinkingMenu = true },
contentPadding = ButtonDefaults.ContentPadding,
) {
Text("🧠 ${thinkingLabel(thinkingLevel)}", maxLines = 1)
}
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
}
}
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.AttachFile, contentDescription = "Add image")
}
}
SecondaryActionButton(
label = "Attach",
icon = Icons.Default.AttachFile,
enabled = true,
onClick = onPickImages,
)
}
if (attachments.isNotEmpty()) {
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
HorizontalDivider(color = mobileBorder)
Text(
text = "MESSAGE",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp),
color = mobileTextSecondary,
)
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth().height(92.dp),
placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) },
minLines = 2,
maxLines = 5,
textStyle = mobileBodyStyle().copy(color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = chatTextFieldColors(),
)
if (!healthOk) {
Text(
text = "Gateway is offline. Connect first in the Connect tab.",
style = mobileCallout,
color = ai.openclaw.android.ui.mobileWarning,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SecondaryActionButton(
label = "Refresh",
icon = Icons.Default.Refresh,
enabled = true,
compact = true,
onClick = onRefresh,
)
SecondaryActionButton(
label = "Abort",
icon = Icons.Default.Stop,
enabled = pendingRunCount > 0,
compact = true,
onClick = onAbort,
)
if (attachments.isNotEmpty()) {
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
Button(
onClick = {
val text = input
input = ""
onSend(text)
},
enabled = canSend,
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = mobileAccent,
contentColor = Color.White,
disabledContainerColor = mobileBorderStrong,
disabledContentColor = mobileTextTertiary,
),
border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong),
) {
if (sendBusy) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White)
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Message OpenClaw…") },
minLines = 2,
maxLines = 6,
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
Spacer(modifier = Modifier.weight(1f))
if (pendingRunCount > 0) {
FilledTonalIconButton(
onClick = onAbort,
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color(0x33E74C3C),
contentColor = Color(0xFFE74C3C),
),
) {
Icon(Icons.Default.Stop, contentDescription = "Abort")
}
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
FilledTonalIconButton(onClick = {
val text = input
input = ""
onSend(text)
}, enabled = canSend) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Send")
}
}
Spacer(modifier = Modifier.width(8.dp))
}
if (!errorText.isNullOrBlank()) {
Text(
text = "Send",
style = mobileHeadline.copy(fontWeight = FontWeight.Bold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = errorText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
)
}
}
@@ -211,35 +187,26 @@ fun ChatComposer(
}
@Composable
private fun SecondaryActionButton(
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
enabled: Boolean,
compact: Boolean = false,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = if (compact) Modifier.size(44.dp) else Modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = mobileTextSecondary,
disabledContainerColor = Color.White,
disabledContentColor = mobileTextTertiary,
),
border = BorderStroke(1.dp, mobileBorderStrong),
contentPadding = if (compact) PaddingValues(0.dp) else ButtonDefaults.ContentPadding,
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
) {
Icon(icon, contentDescription = label, modifier = Modifier.size(14.dp))
if (!compact) {
Spacer(modifier = Modifier.width(5.dp))
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
modifier = Modifier.size(7.dp),
shape = androidx.compose.foundation.shape.CircleShape,
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
) {}
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
Text(
text = label,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
color = if (enabled) mobileTextSecondary else mobileTextTertiary,
if (healthOk) "Connected" else "Connecting…",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -253,14 +220,14 @@ private fun ThinkingMenuItem(
onDismiss: () -> Unit,
) {
DropdownMenuItem(
text = { Text(thinkingLabel(value), style = mobileCallout, color = mobileText) },
text = { Text(thinkingLabel(value)) },
onClick = {
onSet(value)
onDismiss()
},
trailingIcon = {
if (value == current.trim().lowercase()) {
Text("", style = mobileCallout, color = mobileAccent)
Text("")
} else {
Spacer(modifier = Modifier.width(10.dp))
}
@@ -299,55 +266,20 @@ private fun AttachmentsStrip(
private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
Surface(
shape = RoundedCornerShape(999.dp),
color = mobileAccentSoft,
border = BorderStroke(1.dp, mobileBorderStrong),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = fileName,
style = mobileCaption1,
color = mobileText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Surface(
Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1)
FilledTonalIconButton(
onClick = onRemove,
shape = RoundedCornerShape(999.dp),
color = Color.White,
border = BorderStroke(1.dp, mobileBorderStrong),
modifier = Modifier.size(30.dp),
) {
Text(
text = "×",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold),
color = mobileTextSecondary,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
Text("×")
}
}
}
}
@Composable
private fun chatTextFieldColors() =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = mobileSurface,
unfocusedContainerColor = mobileSurface,
focusedBorderColor = mobileAccent,
unfocusedBorderColor = mobileBorder,
focusedTextColor = mobileText,
unfocusedTextColor = mobileText,
cursorColor = mobileAccent,
)
@Composable
private fun mobileBodyStyle() =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = ai.openclaw.android.ui.mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 22.sp,
)

View File

@@ -3,21 +3,12 @@ package ai.openclaw.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -25,534 +16,167 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCodeBg
import ai.openclaw.android.ui.mobileCodeText
import ai.openclaw.android.ui.mobileTextSecondary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.commonmark.Extension
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.Strikethrough
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TableBlock
import org.commonmark.ext.gfm.tables.TableBody
import org.commonmark.ext.gfm.tables.TableCell
import org.commonmark.ext.gfm.tables.TableHead
import org.commonmark.ext.gfm.tables.TableRow
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.ext.task.list.items.TaskListItemMarker
import org.commonmark.ext.task.list.items.TaskListItemsExtension
import org.commonmark.node.BlockQuote
import org.commonmark.node.BulletList
import org.commonmark.node.Code
import org.commonmark.node.Document
import org.commonmark.node.Emphasis
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.Heading
import org.commonmark.node.HardLineBreak
import org.commonmark.node.HtmlBlock
import org.commonmark.node.HtmlInline
import org.commonmark.node.Image as MarkdownImage
import org.commonmark.node.IndentedCodeBlock
import org.commonmark.node.Link
import org.commonmark.node.ListItem
import org.commonmark.node.Node
import org.commonmark.node.OrderedList
import org.commonmark.node.Paragraph
import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text as MarkdownTextNode
import org.commonmark.node.ThematicBreak
import org.commonmark.parser.Parser
private const val LIST_INDENT_DP = 14
private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$")
private val markdownParser: Parser by lazy {
val extensions: List<Extension> =
listOf(
AutolinkExtension.create(),
StrikethroughExtension.create(),
TablesExtension.create(),
TaskListItemsExtension.create(),
)
Parser.builder()
.extensions(extensions)
.build()
}
@Composable
fun ChatMarkdown(text: String, textColor: Color) {
val document = remember(text) { markdownParser.parse(text) as Document }
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText)
val blocks = remember(text) { splitMarkdown(text) }
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RenderMarkdownBlocks(
start = document.firstChild,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = 0,
)
}
}
@Composable
private fun RenderMarkdownBlocks(
start: Node?,
textColor: Color,
inlineStyles: InlineStyles,
listDepth: Int,
) {
var node = start
while (node != null) {
val current = node
when (current) {
is Paragraph -> {
RenderParagraph(current, textColor = textColor, inlineStyles = inlineStyles)
}
is Heading -> {
val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) }
Text(
text = headingText,
style = headingStyle(current.level),
color = textColor,
)
}
is FencedCodeBlock -> {
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
ChatCodeBlock(code = current.literal.orEmpty(), language = current.info?.trim()?.ifEmpty { null })
}
}
is IndentedCodeBlock -> {
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
ChatCodeBlock(code = current.literal.orEmpty(), language = null)
}
}
is BlockQuote -> {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.width(2.dp)
.fillMaxHeight()
.background(mobileTextSecondary.copy(alpha = 0.35f)),
for (b in blocks) {
when (b) {
is ChatMarkdownBlock.Text -> {
val trimmed = b.text.trimEnd()
if (trimmed.isEmpty()) continue
Text(
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
RenderMarkdownBlocks(
start = current.firstChild,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
}
is ChatMarkdownBlock.Code -> {
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
ChatCodeBlock(code = b.code, language = b.language)
}
}
}
is BulletList -> {
RenderBulletList(
list = current,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
}
is OrderedList -> {
RenderOrderedList(
list = current,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
}
is TableBlock -> {
RenderTableBlock(
table = current,
textColor = textColor,
inlineStyles = inlineStyles,
)
}
is ThematicBreak -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(mobileTextSecondary.copy(alpha = 0.25f)),
)
}
is HtmlBlock -> {
val literal = current.literal.orEmpty().trim()
if (literal.isNotEmpty()) {
Text(
text = literal,
style = mobileCallout.copy(fontFamily = FontFamily.Monospace),
color = textColor,
)
}
}
}
node = current.next
}
}
@Composable
private fun RenderParagraph(
paragraph: Paragraph,
textColor: Color,
inlineStyles: InlineStyles,
) {
val standaloneImage = remember(paragraph) { standaloneDataImage(paragraph) }
if (standaloneImage != null) {
InlineBase64Image(base64 = standaloneImage.base64, mimeType = standaloneImage.mimeType)
return
}
val annotated = remember(paragraph) { buildInlineMarkdown(paragraph.firstChild, inlineStyles) }
if (annotated.text.trimEnd().isEmpty()) {
return
}
Text(
text = annotated,
style = mobileCallout,
color = textColor,
)
}
@Composable
private fun RenderBulletList(
list: BulletList,
textColor: Color,
inlineStyles: InlineStyles,
listDepth: Int,
) {
Column(
modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
var item = list.firstChild
while (item != null) {
if (item is ListItem) {
RenderListItem(
item = item,
markerText = "",
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
}
item = item.next
}
}
}
@Composable
private fun RenderOrderedList(
list: OrderedList,
textColor: Color,
inlineStyles: InlineStyles,
listDepth: Int,
) {
Column(
modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
var index = list.markerStartNumber ?: 1
var item = list.firstChild
while (item != null) {
if (item is ListItem) {
RenderListItem(
item = item,
markerText = "$index.",
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
index += 1
}
item = item.next
}
}
}
@Composable
private fun RenderListItem(
item: ListItem,
markerText: String,
textColor: Color,
inlineStyles: InlineStyles,
listDepth: Int,
) {
var contentStart = item.firstChild
var marker = markerText
val task = contentStart as? TaskListItemMarker
if (task != null) {
marker = if (task.isChecked) "" else ""
contentStart = task.next
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = marker,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
color = textColor,
modifier = Modifier.width(24.dp),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
RenderMarkdownBlocks(
start = contentStart,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth + 1,
)
}
}
}
@Composable
private fun RenderTableBlock(
table: TableBlock,
textColor: Color,
inlineStyles: InlineStyles,
) {
val rows = remember(table) { buildTableRows(table, inlineStyles) }
if (rows.isEmpty()) return
val maxCols = rows.maxOf { row -> row.cells.size }.coerceAtLeast(1)
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(scrollState)
.border(1.dp, mobileTextSecondary.copy(alpha = 0.25f)),
) {
for (row in rows) {
Row(
modifier = Modifier.fillMaxWidth(),
) {
for (index in 0 until maxCols) {
val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
Text(
text = cell,
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout,
color = textColor,
modifier = Modifier
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
.padding(horizontal = 8.dp, vertical = 6.dp)
.width(160.dp),
)
is ChatMarkdownBlock.InlineImage -> {
InlineBase64Image(base64 = b.base64, mimeType = b.mimeType)
}
}
}
}
}
private fun buildTableRows(table: TableBlock, inlineStyles: InlineStyles): List<TableRenderRow> {
val rows = mutableListOf<TableRenderRow>()
var child = table.firstChild
while (child != null) {
when (child) {
is TableHead -> rows.addAll(readTableSection(child, isHeader = true, inlineStyles = inlineStyles))
is TableBody -> rows.addAll(readTableSection(child, isHeader = false, inlineStyles = inlineStyles))
is TableRow -> rows.add(readTableRow(child, isHeader = false, inlineStyles = inlineStyles))
private sealed interface ChatMarkdownBlock {
data class Text(val text: String) : ChatMarkdownBlock
data class Code(val code: String, val language: String?) : ChatMarkdownBlock
data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock
}
private fun splitMarkdown(raw: String): List<ChatMarkdownBlock> {
if (raw.isEmpty()) return emptyList()
val out = ArrayList<ChatMarkdownBlock>()
var idx = 0
while (idx < raw.length) {
val fenceStart = raw.indexOf("```", startIndex = idx)
if (fenceStart < 0) {
out.addAll(splitInlineImages(raw.substring(idx)))
break
}
child = child.next
}
return rows
}
private fun readTableSection(section: Node, isHeader: Boolean, inlineStyles: InlineStyles): List<TableRenderRow> {
val rows = mutableListOf<TableRenderRow>()
var row = section.firstChild
while (row != null) {
if (row is TableRow) {
rows.add(readTableRow(row, isHeader = isHeader, inlineStyles = inlineStyles))
if (fenceStart > idx) {
out.addAll(splitInlineImages(raw.substring(idx, fenceStart)))
}
row = row.next
}
return rows
}
private fun readTableRow(row: TableRow, isHeader: Boolean, inlineStyles: InlineStyles): TableRenderRow {
val cells = mutableListOf<AnnotatedString>()
var cellNode = row.firstChild
while (cellNode != null) {
if (cellNode is TableCell) {
cells.add(buildInlineMarkdown(cellNode.firstChild, inlineStyles))
val langLineStart = fenceStart + 3
val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it }
val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null }
val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd
val fenceEnd = raw.indexOf("```", startIndex = codeStart)
if (fenceEnd < 0) {
out.addAll(splitInlineImages(raw.substring(fenceStart)))
break
}
cellNode = cellNode.next
val code = raw.substring(codeStart, fenceEnd)
out.add(ChatMarkdownBlock.Code(code = code, language = language))
idx = fenceEnd + 3
}
return TableRenderRow(isHeader = isHeader, cells = cells)
return out
}
private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): AnnotatedString {
return buildAnnotatedString {
appendInlineNode(
node = start,
inlineCodeBg = inlineStyles.inlineCodeBg,
inlineCodeColor = inlineStyles.inlineCodeColor,
)
}
}
private fun splitInlineImages(text: String): List<ChatMarkdownBlock> {
if (text.isEmpty()) return emptyList()
val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)")
val out = ArrayList<ChatMarkdownBlock>()
private fun AnnotatedString.Builder.appendInlineNode(
node: Node?,
inlineCodeBg: Color,
inlineCodeColor: Color,
) {
var current = node
while (current != null) {
when (current) {
is MarkdownTextNode -> append(current.literal)
is SoftLineBreak -> append('\n')
is HardLineBreak -> append('\n')
is Code -> {
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = inlineCodeBg,
color = inlineCodeColor,
),
) {
append(current.literal)
}
}
is Emphasis -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is StrongEmphasis -> {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is Strikethrough -> {
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is Link -> {
withStyle(
SpanStyle(
color = mobileAccent,
textDecoration = TextDecoration.Underline,
),
) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is MarkdownImage -> {
val alt = buildPlainText(current.firstChild)
if (alt.isNotBlank()) {
append(alt)
} else {
append("image")
}
}
is HtmlInline -> {
if (!current.literal.isNullOrBlank()) {
append(current.literal)
}
}
else -> {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
var idx = 0
while (idx < text.length) {
val m = regex.find(text, startIndex = idx) ?: break
val start = m.range.first
val end = m.range.last + 1
if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start)))
val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png")
val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty()
if (b64.isNotEmpty()) {
out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64))
}
current = current.next
idx = end
}
if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx)))
return out
}
private fun buildPlainText(start: Node?): String {
val sb = StringBuilder()
var node = start
while (node != null) {
when (node) {
is MarkdownTextNode -> sb.append(node.literal)
is SoftLineBreak, is HardLineBreak -> sb.append('\n')
else -> sb.append(buildPlainText(node.firstChild))
private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString {
if (text.isEmpty()) return AnnotatedString("")
val out = buildAnnotatedString {
var i = 0
while (i < text.length) {
if (text.startsWith("**", startIndex = i)) {
val end = text.indexOf("**", startIndex = i + 2)
if (end > i + 2) {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(text.substring(i + 2, end))
}
i = end + 2
continue
}
}
if (text[i] == '`') {
val end = text.indexOf('`', startIndex = i + 1)
if (end > i + 1) {
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = inlineCodeBg,
),
) {
append(text.substring(i + 1, end))
}
i = end + 1
continue
}
}
if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) {
val end = text.indexOf('*', startIndex = i + 1)
if (end > i + 1) {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(text.substring(i + 1, end))
}
i = end + 1
continue
}
}
append(text[i])
i += 1
}
node = node.next
}
return sb.toString()
return out
}
private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? {
val only = paragraph.firstChild as? MarkdownImage ?: return null
if (only.next != null) return null
return parseDataImageDestination(only.destination)
}
private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
val raw = destination?.trim().orEmpty()
if (raw.isEmpty()) return null
val match = dataImageRegex.matchEntire(raw) ?: return null
val subtype = match.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png"
val base64 = match.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty()
if (base64.isEmpty()) return null
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
}
private fun headingStyle(level: Int): TextStyle {
return when (level.coerceIn(1, 6)) {
1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold)
}
}
private data class InlineStyles(
val inlineCodeBg: Color,
val inlineCodeColor: Color,
)
private data class TableRenderRow(
val isHeader: Boolean,
val cells: List<AnnotatedString>,
)
private data class ParsedDataImage(
val mimeType: String,
val base64: String,
)
@Composable
private fun InlineBase64Image(base64: String, mimeType: String?) {
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
@@ -584,8 +208,8 @@ private fun InlineBase64Image(base64: String, mimeType: String?) {
Text(
text = "Image unavailable",
modifier = Modifier.padding(vertical = 2.dp),
style = mobileCaption1,
color = mobileTextSecondary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -2,26 +2,26 @@ package ai.openclaw.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowCircleDown
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import ai.openclaw.android.chat.ChatMessage
import ai.openclaw.android.chat.ChatPendingToolCall
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileHeadline
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
@Composable
fun ChatMessageListCard(
@@ -29,7 +29,6 @@ fun ChatMessageListCard(
pendingRunCount: Int,
pendingToolCalls: List<ChatPendingToolCall>,
streamingAssistantText: String?,
healthOk: Boolean,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
@@ -39,70 +38,73 @@ fun ChatMessageListCard(
listState.animateScrollToItem(index = 0)
}
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 8.dp),
) {
// With reverseLayout = true, index 0 renders at the BOTTOM.
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
Card(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp),
) {
// With reverseLayout = true, index 0 renders at the BOTTOM.
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
val stream = streamingAssistantText?.trim()
if (!stream.isNullOrEmpty()) {
item(key = "stream") {
ChatStreamingAssistantBubble(text = stream)
val stream = streamingAssistantText?.trim()
if (!stream.isNullOrEmpty()) {
item(key = "stream") {
ChatStreamingAssistantBubble(text = stream)
}
}
if (pendingToolCalls.isNotEmpty()) {
item(key = "tools") {
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
}
}
if (pendingRunCount > 0) {
item(key = "typing") {
ChatTypingIndicatorBubble()
}
}
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
ChatMessageBubble(message = messages[messages.size - 1 - idx])
}
}
if (pendingToolCalls.isNotEmpty()) {
item(key = "tools") {
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
}
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
EmptyChatHint(modifier = Modifier.align(Alignment.Center))
}
if (pendingRunCount > 0) {
item(key = "typing") {
ChatTypingIndicatorBubble()
}
}
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
ChatMessageBubble(message = messages[messages.size - 1 - idx])
}
}
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
EmptyChatHint(modifier = Modifier.align(Alignment.Center), healthOk = healthOk)
}
}
}
@Composable
private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder),
private fun EmptyChatHint(modifier: Modifier = Modifier) {
Row(
modifier = modifier.alpha(0.7f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
androidx.compose.foundation.layout.Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("No messages yet", style = mobileHeadline, color = mobileText)
Text(
text =
if (healthOk) {
"Send the first prompt to start this session."
} else {
"Connect gateway first, then return to chat."
},
style = mobileCallout,
color = mobileTextSecondary,
)
}
Icon(
imageVector = Icons.Default.ArrowCircleDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Message OpenClaw…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -2,8 +2,7 @@ package ai.openclaw.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -24,93 +24,55 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.Image
import ai.openclaw.android.chat.ChatMessage
import ai.openclaw.android.chat.ChatMessageContent
import ai.openclaw.android.chat.ChatPendingToolCall
import ai.openclaw.android.tools.ToolDisplayRegistry
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileAccentSoft
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileBorderStrong
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCaption2
import ai.openclaw.android.ui.mobileCodeBg
import ai.openclaw.android.ui.mobileCodeText
import ai.openclaw.android.ui.mobileHeadline
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
import ai.openclaw.android.ui.mobileWarning
import ai.openclaw.android.ui.mobileWarningSoft
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private data class ChatBubbleStyle(
val alignEnd: Boolean,
val containerColor: Color,
val borderColor: Color,
val roleColor: Color,
)
import androidx.compose.ui.platform.LocalContext
@Composable
fun ChatMessageBubble(message: ChatMessage) {
val role = message.role.trim().lowercase(Locale.US)
val style = bubbleStyle(role)
val isUser = message.role.lowercase() == "user"
// Filter to only displayable content parts (text with content, or base64 images).
val displayableContent =
message.content.filter { part ->
when (part.type) {
"text" -> !part.text.isNullOrBlank()
else -> part.base64 != null
}
// Filter to only displayable content parts (text with content, or base64 images)
val displayableContent = message.content.filter { part ->
when (part.type) {
"text" -> !part.text.isNullOrBlank()
else -> part.base64 != null
}
}
// Skip rendering entirely if no displayable content
if (displayableContent.isEmpty()) return
ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) {
ChatMessageBody(content = displayableContent, textColor = mobileText)
}
}
@Composable
private fun ChatBubbleContainer(
style: ChatBubbleStyle,
roleLabel: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = if (style.alignEnd) Arrangement.End else Arrangement.Start,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Surface(
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, style.borderColor),
color = style.containerColor,
shape = RoundedCornerShape(16.dp),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
modifier = Modifier.fillMaxWidth(0.90f),
color = Color.Transparent,
modifier = Modifier.fillMaxWidth(0.92f),
) {
Column(
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
Box(
modifier =
Modifier
.background(bubbleBackground(isUser))
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(
text = roleLabel,
style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
color = style.roleColor,
)
content()
val textColor = textColorOverBubble(isUser)
ChatMessageBody(content = displayableContent, textColor = textColor)
}
}
}
@@ -118,7 +80,7 @@ private fun ChatBubbleContainer(
@Composable
private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
for (part in content) {
when (part.type) {
"text" -> {
@@ -136,16 +98,19 @@ private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color)
@Composable
fun ChatTypingIndicatorBubble() {
ChatBubbleContainer(
style = bubbleStyle("assistant"),
roleLabel = roleLabel("assistant"),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
DotPulse(color = mobileTextSecondary)
Text("Thinking...", style = mobileCallout, color = mobileTextSecondary)
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
DotPulse()
Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@@ -157,37 +122,38 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
remember(toolCalls, context) {
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
}
ChatBubbleContainer(
style = bubbleStyle("assistant"),
roleLabel = "TOOLS",
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
for (display in displays.take(6)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"${display.emoji} ${display.label}",
style = mobileCallout,
color = mobileTextSecondary,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Running tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (display in displays.take(6)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
detail,
style = mobileCaption1,
color = mobileTextSecondary,
"${display.emoji} ${display.label}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Text(
detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
if (toolCalls.size > 6) {
Text(
text = "... +${toolCalls.size - 6} more",
style = mobileCaption1,
color = mobileTextSecondary,
)
if (toolCalls.size > 6) {
Text(
"… +${toolCalls.size - 6} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -195,47 +161,37 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
@Composable
fun ChatStreamingAssistantBubble(text: String) {
ChatBubbleContainer(
style = bubbleStyle("assistant").copy(borderColor = mobileAccent),
roleLabel = "ASSISTANT · LIVE",
) {
ChatMarkdown(text = text, textColor = mobileText)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface)
}
}
}
}
private fun bubbleStyle(role: String): ChatBubbleStyle {
return when (role) {
"user" ->
ChatBubbleStyle(
alignEnd = true,
containerColor = mobileAccentSoft,
borderColor = mobileAccent,
roleColor = mobileAccent,
)
"system" ->
ChatBubbleStyle(
alignEnd = false,
containerColor = mobileWarningSoft,
borderColor = mobileWarning.copy(alpha = 0.45f),
roleColor = mobileWarning,
)
else ->
ChatBubbleStyle(
alignEnd = false,
containerColor = Color.White,
borderColor = mobileBorderStrong,
roleColor = mobileTextSecondary,
)
@Composable
private fun bubbleBackground(isUser: Boolean): Brush {
return if (isUser) {
Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)),
)
} else {
Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh),
)
}
}
private fun roleLabel(role: String): String {
return when (role) {
"user" -> "USER"
"system" -> "SYSTEM"
else -> "ASSISTANT"
@Composable
private fun textColorOverBubble(isUser: Boolean): Color {
return if (isUser) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
}
}
@@ -260,64 +216,48 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
}
if (image != null) {
Surface(
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, mobileBorder),
color = Color.White,
Image(
bitmap = image!!,
contentDescription = mimeType ?: "attachment",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
) {
Image(
bitmap = image!!,
contentDescription = mimeType ?: "attachment",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
}
)
} else if (failed) {
Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary)
Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun DotPulse(color: Color) {
private fun DotPulse() {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
PulseDot(alpha = 0.38f, color = color)
PulseDot(alpha = 0.62f, color = color)
PulseDot(alpha = 0.90f, color = color)
PulseDot(alpha = 0.38f)
PulseDot(alpha = 0.62f)
PulseDot(alpha = 0.90f)
}
}
@Composable
private fun PulseDot(alpha: Float, color: Color) {
private fun PulseDot(alpha: Float) {
Surface(
modifier = Modifier.size(6.dp).alpha(alpha),
shape = CircleShape,
color = color,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) {}
}
@Composable
fun ChatCodeBlock(code: String, language: String?) {
Surface(
shape = RoundedCornerShape(8.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLowest,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (!language.isNullOrBlank()) {
Text(
text = language.uppercase(Locale.US),
style = mobileCaption2.copy(letterSpacing = 0.4.sp),
color = mobileTextSecondary,
)
}
Text(
text = code.trimEnd(),
fontFamily = FontFamily.Monospace,
style = mobileCallout,
color = mobileCodeText,
)
}
Text(
text = code.trimEnd(),
modifier = Modifier.padding(10.dp),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}

View File

@@ -0,0 +1,92 @@
package ai.openclaw.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ai.openclaw.android.chat.ChatSessionEntry
@Composable
fun ChatSessionsDialog(
currentSessionKey: String,
sessions: List<ChatSessionEntry>,
onDismiss: () -> Unit,
onRefresh: () -> Unit,
onSelect: (sessionKey: String) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {},
title = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text("Sessions", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
FilledTonalIconButton(onClick = onRefresh) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
},
text = {
if (sessions.isEmpty()) {
Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(sessions, key = { it.key }) { entry ->
SessionRow(
entry = entry,
isCurrent = entry.key == currentSessionKey,
onClick = { onSelect(entry.key) },
)
}
}
}
},
)
}
@Composable
private fun SessionRow(
entry: ChatSessionEntry,
isCurrent: Boolean,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
color =
if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceContainer
},
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.weight(1f))
if (isCurrent) {
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}

View File

@@ -5,19 +5,10 @@ import android.net.Uri
import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -26,28 +17,10 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ai.openclaw.android.MainViewModel
import ai.openclaw.android.chat.ChatSessionEntry
import ai.openclaw.android.chat.OutgoingAttachment
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileBorderStrong
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCaption2
import ai.openclaw.android.ui.mobileDanger
import ai.openclaw.android.ui.mobileSuccess
import ai.openclaw.android.ui.mobileSuccessSoft
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
import ai.openclaw.android.ui.mobileWarning
import ai.openclaw.android.ui.mobileWarningSoft
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -99,160 +72,52 @@ fun ChatSheetContent(viewModel: MainViewModel) {
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
ChatThreadSelector(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
healthOk = healthOk,
onSelectSession = { key -> viewModel.switchChatSession(key) },
)
if (!errorText.isNullOrBlank()) {
ChatErrorRail(errorText = errorText!!)
}
ChatMessageListCard(
messages = messages,
pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingAssistantText,
healthOk = healthOk,
modifier = Modifier.weight(1f, fill = true),
)
Row(modifier = Modifier.fillMaxWidth().imePadding()) {
ChatComposer(
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,
attachments = attachments,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
onRefresh = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 200)
},
onAbort = { viewModel.abortChat() },
onSend = { text ->
val outgoing =
attachments.map { att ->
OutgoingAttachment(
type = "image",
mimeType = att.mimeType,
fileName = att.fileName,
base64 = att.base64,
)
}
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
attachments.clear()
},
)
}
}
}
@Composable
private fun ChatThreadSelector(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
healthOk: Boolean,
onSelectSession: (String) -> Unit,
) {
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val currentSessionLabel =
friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey)
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Text(
text = "SESSION",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp),
color = mobileTextSecondary,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Text(
text = currentSessionLabel,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
color = mobileText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ChatConnectionPill(healthOk = healthOk)
}
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (entry in sessionOptions) {
val active = entry.key == sessionKey
Surface(
onClick = { onSelectSession(entry.key) },
shape = RoundedCornerShape(14.dp),
color = if (active) mobileAccent else Color.White,
border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Text(
text = friendlySessionName(entry.displayName ?: entry.key),
style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold),
color = if (active) Color.White else mobileText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
}
}
@Composable
private fun ChatConnectionPill(healthOk: Boolean) {
Surface(
shape = RoundedCornerShape(999.dp),
color = if (healthOk) mobileSuccessSoft else mobileWarningSoft,
border = BorderStroke(1.dp, if (healthOk) mobileSuccess.copy(alpha = 0.35f) else mobileWarning.copy(alpha = 0.35f)),
) {
Text(
text = if (healthOk) "Connected" else "Offline",
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
color = if (healthOk) mobileSuccess else mobileWarning,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
ChatComposer(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,
errorText = errorText,
attachments = attachments,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
onSelectSession = { key -> viewModel.switchChatSession(key) },
onRefresh = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 200)
},
onAbort = { viewModel.abortChat() },
onSend = { text ->
val outgoing =
attachments.map { att ->
OutgoingAttachment(
type = "image",
mimeType = att.mimeType,
fileName = att.fileName,
base64 = att.base64,
)
}
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
attachments.clear()
},
)
}
}
@Composable
private fun ChatErrorRail(errorText: String) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = androidx.compose.ui.graphics.Color.White,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = "CHAT ERROR",
style = mobileCaption2.copy(letterSpacing = 0.6.sp),
color = mobileDanger,
)
Text(text = errorText, style = mobileCallout, color = mobileText)
}
}
}
data class PendingImageAttachment(
val id: String,
val fileName: String,

View File

@@ -1,523 +0,0 @@
package ai.openclaw.android.voice
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.core.content.ContextCompat
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
enum class VoiceConversationRole {
User,
Assistant,
}
data class VoiceConversationEntry(
val id: String,
val role: VoiceConversationRole,
val text: String,
val isStreaming: Boolean = false,
)
class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
private val sendToGateway: suspend (String) -> String?,
) {
companion object {
private const val speechMinSessionMs = 30_000L
private const val speechCompleteSilenceMs = 1_500L
private const val speechPossibleSilenceMs = 900L
private const val maxConversationEntries = 40
private const val pendingRunTimeoutMs = 45_000L
}
private data class QueuedUtterance(
val text: String,
)
private val mainHandler = Handler(Looper.getMainLooper())
private val json = Json { ignoreUnknownKeys = true }
private val _micEnabled = MutableStateFlow(false)
val micEnabled: StateFlow<Boolean> = _micEnabled
private val _isListening = MutableStateFlow(false)
val isListening: StateFlow<Boolean> = _isListening
private val _statusText = MutableStateFlow("Mic off")
val statusText: StateFlow<String> = _statusText
private val _liveTranscript = MutableStateFlow<String?>(null)
val liveTranscript: StateFlow<String?> = _liveTranscript
private val _queuedMessages = MutableStateFlow<List<String>>(emptyList())
val queuedMessages: StateFlow<List<String>> = _queuedMessages
private val _conversation = MutableStateFlow<List<VoiceConversationEntry>>(emptyList())
val conversation: StateFlow<List<VoiceConversationEntry>> = _conversation
private val _inputLevel = MutableStateFlow(0f)
val inputLevel: StateFlow<Float> = _inputLevel
private val _isSending = MutableStateFlow(false)
val isSending: StateFlow<Boolean> = _isSending
private val messageQueue = ArrayDeque<QueuedUtterance>()
private val sessionSegments = mutableListOf<String>()
private var lastFinalSegment: String? = null
private var pendingRunId: String? = null
private var pendingAssistantEntryId: String? = null
private var gatewayConnected = false
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var pendingRunTimeoutJob: Job? = null
private var stopRequested = false
fun setMicEnabled(enabled: Boolean) {
if (_micEnabled.value == enabled) return
_micEnabled.value = enabled
if (enabled) {
start()
sendQueuedIfIdle()
} else {
stop()
flushSessionToQueue()
sendQueuedIfIdle()
}
}
fun onGatewayConnectionChanged(connected: Boolean) {
gatewayConnected = connected
if (connected) {
sendQueuedIfIdle()
return
}
if (messageQueue.isNotEmpty()) {
_statusText.value = queuedWaitingStatus()
}
}
fun handleGatewayEvent(event: String, payloadJson: String?) {
if (event != "chat") return
if (payloadJson.isNullOrBlank()) return
val payload =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val runId = pendingRunId ?: return
val eventRunId = payload["runId"].asStringOrNull() ?: return
if (eventRunId != runId) return
when (payload["state"].asStringOrNull()) {
"delta" -> {
val deltaText = parseAssistantText(payload)
if (!deltaText.isNullOrBlank()) {
upsertPendingAssistant(text = deltaText.trim(), isStreaming = true)
}
}
"final" -> {
val finalText = parseAssistantText(payload)?.trim().orEmpty()
if (finalText.isNotEmpty()) {
upsertPendingAssistant(text = finalText, isStreaming = false)
} else if (pendingAssistantEntryId != null) {
updateConversationEntry(pendingAssistantEntryId!!, text = null, isStreaming = false)
}
completePendingTurn()
}
"error" -> {
val errorMessage = payload["errorMessage"].asStringOrNull()?.trim().orEmpty().ifEmpty { "Voice request failed" }
upsertPendingAssistant(text = errorMessage, isStreaming = false)
completePendingTurn()
}
"aborted" -> {
upsertPendingAssistant(text = "Response aborted", isStreaming = false)
completePendingTurn()
}
}
}
private fun start() {
stopRequested = false
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
_statusText.value = "Speech recognizer unavailable"
_micEnabled.value = false
return
}
if (!hasMicPermission()) {
_statusText.value = "Microphone permission required"
_micEnabled.value = false
return
}
mainHandler.post {
try {
if (recognizer == null) {
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
}
startListeningSession()
} catch (err: Throwable) {
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
_micEnabled.value = false
}
}
}
private fun stop() {
stopRequested = true
restartJob?.cancel()
restartJob = null
_isListening.value = false
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
_inputLevel.value = 0f
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
}
private fun startListeningSession() {
val recognizerInstance = recognizer ?: return
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs)
putExtra(
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
speechPossibleSilenceMs,
)
}
_statusText.value =
when {
_isSending.value -> "Listening · sending queued voice"
messageQueue.isNotEmpty() -> "Listening · ${messageQueue.size} queued"
else -> "Listening"
}
_isListening.value = true
recognizerInstance.startListening(intent)
}
private fun scheduleRestart(delayMs: Long = 300L) {
if (stopRequested) return
if (!_micEnabled.value) return
restartJob?.cancel()
restartJob =
scope.launch {
delay(delayMs)
mainHandler.post {
if (stopRequested || !_micEnabled.value) return@post
try {
startListeningSession()
} catch (_: Throwable) {
// retry through onError
}
}
}
}
private fun flushSessionToQueue() {
val message = sessionSegments.joinToString(" ").trim()
sessionSegments.clear()
_liveTranscript.value = null
lastFinalSegment = null
if (message.isEmpty()) return
appendConversation(
role = VoiceConversationRole.User,
text = message,
)
messageQueue.addLast(QueuedUtterance(text = message))
publishQueue()
}
private fun publishQueue() {
_queuedMessages.value = messageQueue.map { it.text }
}
private fun sendQueuedIfIdle() {
if (_isSending.value) return
if (messageQueue.isEmpty()) {
if (_micEnabled.value) {
_statusText.value = "Listening"
} else {
_statusText.value = "Mic off"
}
return
}
if (!gatewayConnected) {
_statusText.value = queuedWaitingStatus()
return
}
val next = messageQueue.first()
_isSending.value = true
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
_statusText.value = if (_micEnabled.value) "Listening · sending queued voice" else "Sending queued voice"
scope.launch {
try {
val runId = sendToGateway(next.text)
pendingRunId = runId
if (runId == null) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
messageQueue.removeFirst()
publishQueue()
_isSending.value = false
pendingAssistantEntryId = null
sendQueuedIfIdle()
} else {
armPendingRunTimeout(runId)
}
} catch (err: Throwable) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
_isSending.value = false
pendingRunId = null
pendingAssistantEntryId = null
_statusText.value =
if (!gatewayConnected) {
queuedWaitingStatus()
} else {
"Send failed: ${err.message ?: err::class.simpleName}"
}
}
}
}
private fun armPendingRunTimeout(runId: String) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob =
scope.launch {
delay(pendingRunTimeoutMs)
if (pendingRunId != runId) return@launch
pendingRunId = null
pendingAssistantEntryId = null
_isSending.value = false
_statusText.value =
if (gatewayConnected) {
"Voice reply timed out; retrying queued turn"
} else {
queuedWaitingStatus()
}
sendQueuedIfIdle()
}
}
private fun completePendingTurn() {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
if (messageQueue.isNotEmpty()) {
messageQueue.removeFirst()
publishQueue()
}
pendingRunId = null
pendingAssistantEntryId = null
_isSending.value = false
sendQueuedIfIdle()
}
private fun queuedWaitingStatus(): String {
return "${messageQueue.size} queued · waiting for gateway"
}
private fun appendConversation(
role: VoiceConversationRole,
text: String,
isStreaming: Boolean = false,
): String {
val id = UUID.randomUUID().toString()
_conversation.value =
(_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming))
.takeLast(maxConversationEntries)
return id
}
private fun updateConversationEntry(id: String, text: String?, isStreaming: Boolean) {
val current = _conversation.value
_conversation.value =
current.map { entry ->
if (entry.id == id) {
val updatedText = text ?: entry.text
entry.copy(text = updatedText, isStreaming = isStreaming)
} else {
entry
}
}
}
private fun upsertPendingAssistant(text: String, isStreaming: Boolean) {
val currentId = pendingAssistantEntryId
if (currentId == null) {
pendingAssistantEntryId =
appendConversation(
role = VoiceConversationRole.Assistant,
text = text,
isStreaming = isStreaming,
)
return
}
updateConversationEntry(id = currentId, text = text, isStreaming = isStreaming)
}
private fun onFinalTranscript(text: String) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
_liveTranscript.value = trimmed
if (lastFinalSegment == trimmed) return
lastFinalSegment = trimmed
sessionSegments.add(trimmed)
}
private fun disableMic(status: String) {
stopRequested = true
restartJob?.cancel()
restartJob = null
_micEnabled.value = false
_isListening.value = false
_inputLevel.value = 0f
_statusText.value = status
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
}
private fun hasMicPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun parseAssistantText(payload: JsonObject): String? {
val message = payload["message"].asObjectOrNull() ?: return null
if (message["role"].asStringOrNull() != "assistant") return null
val content = message["content"] as? JsonArray ?: return null
val parts =
content.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
if (obj["type"].asStringOrNull() != "text") return@mapNotNull null
obj["text"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
}
if (parts.isEmpty()) return null
return parts.joinToString("\n")
}
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
_isListening.value = true
}
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {
val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f)
_inputLevel.value = level
}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
_inputLevel.value = 0f
scheduleRestart()
}
override fun onError(error: Int) {
if (stopRequested) return
_isListening.value = false
_inputLevel.value = 0f
val status =
when (error) {
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
SpeechRecognizer.ERROR_CLIENT -> "Client error"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
SpeechRecognizer.ERROR_SERVER -> "Server error"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device"
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device"
SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected"
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying"
else -> "Speech error ($error)"
}
_statusText.value = status
if (
error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ||
error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED ||
error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE
) {
disableMic(status)
return
}
val restartDelayMs =
when (error) {
SpeechRecognizer.ERROR_NO_MATCH,
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
-> 1_200L
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L
else -> 600L
}
scheduleRestart(delayMs = restartDelayMs)
}
override fun onResults(results: Bundle?) {
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
onFinalTranscript(text)
flushSessionToQueue()
sendQueuedIfIdle()
}
scheduleRestart()
}
override fun onPartialResults(partialResults: Bundle?) {
val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
_liveTranscript.value = text.trim()
}
}
override fun onEvent(eventType: Int, params: Bundle?) {}
}
}
private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? =
this as? JsonObject
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -54,47 +54,6 @@ class TalkModeManager(
private const val tag = "TalkMode"
private const val defaultModelIdFallback = "eleven_v3"
private const val defaultOutputFormatFallback = "pcm_24000"
private const val defaultTalkProvider = "elevenlabs"
internal data class TalkProviderConfigSelection(
val provider: String,
val config: JsonObject,
val normalizedPayload: Boolean,
)
private fun normalizeTalkProviderId(raw: String?): String? {
val trimmed = raw?.trim()?.lowercase().orEmpty()
return trimmed.takeIf { it.isNotEmpty() }
}
internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
if (talk == null) return null
val rawProvider = talk["provider"].asStringOrNull()
val rawProviders = talk["providers"].asObjectOrNull()
val hasNormalizedPayload = rawProvider != null || rawProviders != null
if (hasNormalizedPayload) {
val providers =
rawProviders?.entries?.mapNotNull { (key, value) ->
val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null
val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null
providerId to providerConfig
}?.toMap().orEmpty()
val providerId =
normalizeTalkProviderId(rawProvider)
?: providers.keys.sorted().firstOrNull()
?: defaultTalkProvider
return TalkProviderConfigSelection(
provider = providerId,
config = providers[providerId] ?: buildJsonObject {},
normalizedPayload = true,
)
}
return TalkProviderConfigSelection(
provider = defaultTalkProvider,
config = talk,
normalizedPayload = false,
)
}
}
private val mainHandler = Handler(Looper.getMainLooper())
@@ -385,12 +344,12 @@ class TalkModeManager(
val key = sessionKey.trim()
if (key.isEmpty()) return
if (chatSubscribedSessionKey == key) return
val sent = session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
if (sent) {
try {
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
chatSubscribedSessionKey = key
Log.d(tag, "chat.subscribe ok sessionKey=$key")
} else {
Log.w(tag, "chat.subscribe failed sessionKey=$key")
} catch (err: Throwable) {
Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}")
}
}
@@ -859,49 +818,30 @@ class TalkModeManager(
val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull()
val talk = config?.get("talk").asObjectOrNull()
val selection = selectTalkProviderConfig(talk)
val activeProvider = selection?.provider ?: defaultTalkProvider
val activeConfig = selection?.config
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val aliases =
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
}?.toMap().orEmpty()
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val outputFormat =
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val model = talk?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val outputFormat = talk?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
if (!isCanonicalMainSessionKey(mainSessionKey)) {
mainSessionKey = mainKey
}
defaultVoiceId =
if (activeProvider == defaultTalkProvider) {
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
} else {
voice
}
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
voiceAliases = aliases
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
defaultModelId = model ?: defaultModelIdFallback
if (!modelOverrideActive) currentModelId = defaultModelId
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback
apiKey =
if (activeProvider == defaultTalkProvider) {
key ?: envKey?.takeIf { it.isNotEmpty() }
} else {
null
}
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() }
if (interrupt != null) interruptOnSpeech = interrupt
if (activeProvider != defaultTalkProvider) {
Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback")
} else if (selection?.normalizedPayload == true) {
Log.d(tag, "talk config provider=elevenlabs")
}
} catch (_: Throwable) {
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
defaultModelId = defaultModelIdFallback

Some files were not shown because too many files have changed in this diff Show More