Compare commits
16 Commits
codex/aa-0
...
feat/slash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cc816969 | ||
|
|
b0d0914ca0 | ||
|
|
b33334620a | ||
|
|
ebc4fe57d7 | ||
|
|
5909cf36b9 | ||
|
|
e3a97b96d9 | ||
|
|
4942f3af0f | ||
|
|
ad44e32910 | ||
|
|
b0b77aae88 | ||
|
|
007f8c9222 | ||
|
|
dbd5a76cb0 | ||
|
|
630b256aee | ||
|
|
e170bc397d | ||
|
|
d2f033aee5 | ||
|
|
8f12b47b6d | ||
|
|
e8b382e8ed |
@@ -1,380 +0,0 @@
|
||||
---
|
||||
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
|
||||
---
|
||||
|
||||
# Clawdbot Upstream Sync Workflow
|
||||
|
||||
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Check divergence status
|
||||
git fetch upstream && git rev-list --left-right --count main...upstream/main
|
||||
|
||||
# Full sync (rebase preferred)
|
||||
git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh
|
||||
|
||||
# Check for Swift 6.2 issues after sync
|
||||
grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Assess Divergence
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git log --oneline --left-right main...upstream/main | head -20
|
||||
```
|
||||
|
||||
This shows:
|
||||
|
||||
- `<` = your local commits (ahead)
|
||||
- `>` = upstream commits you're missing (behind)
|
||||
|
||||
**Decision point:**
|
||||
|
||||
- Few local commits, many upstream → **Rebase** (cleaner history)
|
||||
- Many local commits or shared branch → **Merge** (preserves history)
|
||||
|
||||
---
|
||||
|
||||
## Step 2A: Rebase Strategy (Preferred)
|
||||
|
||||
Replays your commits on top of upstream. Results in linear history.
|
||||
|
||||
```bash
|
||||
# Ensure working tree is clean
|
||||
git status
|
||||
|
||||
# Rebase onto upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
### Handling Rebase Conflicts
|
||||
|
||||
```bash
|
||||
# When conflicts occur:
|
||||
# 1. Fix conflicts in the listed files
|
||||
# 2. Stage resolved files
|
||||
git add <resolved-files>
|
||||
|
||||
# 3. Continue rebase
|
||||
git rebase --continue
|
||||
|
||||
# If a commit is no longer needed (already in upstream):
|
||||
git rebase --skip
|
||||
|
||||
# To abort and return to original state:
|
||||
git rebase --abort
|
||||
```
|
||||
|
||||
### Common Conflict Patterns
|
||||
|
||||
| File | Resolution |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| `package.json` | Take upstream deps, keep local scripts if needed |
|
||||
| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` |
|
||||
| `*.patch` files | Usually take upstream version |
|
||||
| Source files | Merge logic carefully, prefer upstream structure |
|
||||
|
||||
---
|
||||
|
||||
## Step 2B: Merge Strategy (Alternative)
|
||||
|
||||
Preserves all history with a merge commit.
|
||||
|
||||
```bash
|
||||
git merge upstream/main --no-edit
|
||||
```
|
||||
|
||||
Resolve conflicts same as rebase, then:
|
||||
|
||||
```bash
|
||||
git add <resolved-files>
|
||||
git commit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Rebuild Everything
|
||||
|
||||
After sync completes:
|
||||
|
||||
```bash
|
||||
# Install dependencies (regenerates lock if needed)
|
||||
pnpm install
|
||||
|
||||
# Build TypeScript
|
||||
pnpm build
|
||||
|
||||
# Build UI assets
|
||||
pnpm ui:build
|
||||
|
||||
# Run diagnostics
|
||||
pnpm clawdbot doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Rebuild macOS App
|
||||
|
||||
```bash
|
||||
# Full rebuild, sign, and launch
|
||||
./scripts/restart-mac.sh
|
||||
|
||||
# Or just package without restart
|
||||
pnpm mac:package
|
||||
```
|
||||
|
||||
### Install to /Applications
|
||||
|
||||
```bash
|
||||
# Kill running app
|
||||
pkill -x "Clawdbot" || true
|
||||
|
||||
# Move old version
|
||||
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
|
||||
|
||||
# Install new build
|
||||
cp -R dist/Clawdbot.app /Applications/
|
||||
|
||||
# Launch
|
||||
open /Applications/Clawdbot.app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4A: Verify macOS App & Agent
|
||||
|
||||
After rebuilding the macOS app, always verify it works correctly:
|
||||
|
||||
```bash
|
||||
# Check gateway health
|
||||
pnpm clawdbot health
|
||||
|
||||
# Verify no zombie processes
|
||||
ps aux | grep -E "(clawdbot|gateway)" | grep -v grep
|
||||
|
||||
# Test agent functionality by sending a verification message
|
||||
pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID
|
||||
|
||||
# Confirm the message was received on Telegram
|
||||
# (Check your Telegram chat with the bot)
|
||||
```
|
||||
|
||||
**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync)
|
||||
|
||||
Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging:
|
||||
|
||||
### Analyze-Mode Investigation
|
||||
|
||||
```bash
|
||||
# Gather context with parallel agents
|
||||
morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
```
|
||||
|
||||
### Common Swift 6.2 Fixes
|
||||
|
||||
**FileManager.default Deprecation:**
|
||||
|
||||
```bash
|
||||
# Search for deprecated usage
|
||||
grep -r "FileManager\.default" src/ apps/ --include="*.swift"
|
||||
|
||||
# Replace with proper initialization
|
||||
# OLD: FileManager.default
|
||||
# NEW: FileManager()
|
||||
```
|
||||
|
||||
**Thread.isMainThread Deprecation:**
|
||||
|
||||
```bash
|
||||
# Search for deprecated usage
|
||||
grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift"
|
||||
|
||||
# Replace with modern concurrency check
|
||||
# OLD: Thread.isMainThread
|
||||
# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... }
|
||||
```
|
||||
|
||||
### Peekaboo Submodule Fixes
|
||||
|
||||
```bash
|
||||
# Check Peekaboo for concurrency issues
|
||||
cd src/canvas-host/a2ui
|
||||
grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift"
|
||||
|
||||
# Fix and rebuild submodule
|
||||
cd /Volumes/Main SSD/Developer/clawdis
|
||||
pnpm canvas:a2ui:bundle
|
||||
```
|
||||
|
||||
### macOS App Concurrency Fixes
|
||||
|
||||
```bash
|
||||
# Check macOS app for issues
|
||||
grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift"
|
||||
|
||||
# Clean and rebuild after fixes
|
||||
cd apps/macos && rm -rf .build .swiftpm
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### Model Configuration Updates
|
||||
|
||||
If upstream introduced new model configurations:
|
||||
|
||||
```bash
|
||||
# Check for OpenRouter API key requirements
|
||||
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
|
||||
|
||||
# Update clawdbot.json with fallback chains
|
||||
# Add model fallback configurations as needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Verify & Push
|
||||
|
||||
```bash
|
||||
# Verify everything works
|
||||
pnpm clawdbot health
|
||||
pnpm test
|
||||
|
||||
# Push (force required after rebase)
|
||||
git push origin main --force-with-lease
|
||||
|
||||
# Or regular push after merge
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Fails After Sync
|
||||
|
||||
```bash
|
||||
# Clean and rebuild
|
||||
rm -rf node_modules dist
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Type Errors (Bun/Node Incompatibility)
|
||||
|
||||
Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`.
|
||||
|
||||
### macOS App Crashes on Launch
|
||||
|
||||
Usually resource bundle mismatch. Full rebuild required:
|
||||
|
||||
```bash
|
||||
cd apps/macos && rm -rf .build .swiftpm
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### Patch Failures
|
||||
|
||||
```bash
|
||||
# Check patch status
|
||||
pnpm install 2>&1 | grep -i patch
|
||||
|
||||
# If patches fail, they may need updating for new dep versions
|
||||
# Check patches/ directory against package.json patchedDependencies
|
||||
```
|
||||
|
||||
### Swift 6.2 / macOS 26 SDK Build Failures
|
||||
|
||||
**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread`
|
||||
|
||||
**Search-Mode Investigation:**
|
||||
|
||||
```bash
|
||||
# Exhaustive search for deprecated APIs
|
||||
morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
```
|
||||
|
||||
**Quick Fix Commands:**
|
||||
|
||||
```bash
|
||||
# Find all affected files
|
||||
find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \;
|
||||
|
||||
# Replace FileManager.default with FileManager()
|
||||
find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \;
|
||||
|
||||
# For Thread.isMainThread, need manual review of each usage
|
||||
grep -rn "Thread\.isMainThread" --include="*.swift" .
|
||||
```
|
||||
|
||||
**Rebuild After Fixes:**
|
||||
|
||||
```bash
|
||||
# Clean all build artifacts
|
||||
rm -rf apps/macos/.build apps/macos/.swiftpm
|
||||
rm -rf src/canvas-host/a2ui/.build
|
||||
|
||||
# Rebuild Peekaboo bundle
|
||||
pnpm canvas:a2ui:bundle
|
||||
|
||||
# Full macOS rebuild
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automation Script
|
||||
|
||||
Save as `scripts/sync-upstream.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "==> Fetching upstream..."
|
||||
git fetch upstream
|
||||
|
||||
echo "==> Current divergence:"
|
||||
git rev-list --left-right --count main...upstream/main
|
||||
|
||||
echo "==> Rebasing onto upstream/main..."
|
||||
git rebase upstream/main
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
pnpm install
|
||||
|
||||
echo "==> Building..."
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
echo "==> Running doctor..."
|
||||
pnpm clawdbot doctor
|
||||
|
||||
echo "==> Rebuilding macOS app..."
|
||||
./scripts/restart-mac.sh
|
||||
|
||||
echo "==> Verifying gateway health..."
|
||||
pnpm clawdbot health
|
||||
|
||||
echo "==> Checking for Swift 6.2 compatibility issues..."
|
||||
if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then
|
||||
echo "⚠️ Found potential Swift 6.2 deprecated API usage"
|
||||
echo " Run manual fixes or use analyze-mode investigation"
|
||||
else
|
||||
echo "✅ No obvious Swift deprecation issues found"
|
||||
fi
|
||||
|
||||
echo "==> Testing agent functionality..."
|
||||
# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID
|
||||
pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message"
|
||||
|
||||
echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready."
|
||||
```
|
||||
@@ -1,181 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,304 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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."
|
||||
@@ -1,336 +0,0 @@
|
||||
---
|
||||
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`.
|
||||
@@ -1,4 +0,0 @@
|
||||
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."
|
||||
@@ -1,253 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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."
|
||||
@@ -1,249 +0,0 @@
|
||||
# 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 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 (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.
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## 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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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."
|
||||
@@ -1,345 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,131 +0,0 @@
|
||||
---
|
||||
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 with required subject format and validate it.
|
||||
|
||||
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
|
||||
|
||||
Required commit subject format:
|
||||
|
||||
- `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`
|
||||
|
||||
Use explicit file list:
|
||||
|
||||
```sh
|
||||
source .local/pr-meta.env
|
||||
scripts/committer "fix: <summary> (openclaw#$PR_NUMBER) thanks @$PR_AUTHOR" <file1> <file2> ...
|
||||
```
|
||||
|
||||
Validate commit subject:
|
||||
|
||||
```sh
|
||||
scripts/pr-prepare validate-commit <PR>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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."
|
||||
@@ -1,141 +0,0 @@
|
||||
---
|
||||
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>`.
|
||||
|
||||
## 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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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."
|
||||
@@ -1,30 +0,0 @@
|
||||
# detect-secrets exclusion patterns (regex)
|
||||
#
|
||||
# Note: detect-secrets does not read this file by default. If you want these
|
||||
# applied, wire them into your scan command (e.g. translate to --exclude-files
|
||||
# / --exclude-lines) or into a baseline's filters_used.
|
||||
|
||||
[exclude-files]
|
||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
||||
pattern = (^|/)pnpm-lock\.yaml$
|
||||
# Generated output and vendored assets.
|
||||
pattern = (^|/)(dist|vendor)/
|
||||
# Local config file with allowlist patterns.
|
||||
pattern = (^|/)\.detect-secrets\.cfg$
|
||||
|
||||
[exclude-lines]
|
||||
# Fastlane checks for private key marker; not a real key.
|
||||
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
|
||||
# UI label string for Anthropic auth mode.
|
||||
pattern = case \.apiKeyEnv: "API key \(env var\)"
|
||||
# CodingKeys mapping uses apiKey literal.
|
||||
pattern = case apikey = "apiKey"
|
||||
# Schema labels referencing password fields (not actual secrets).
|
||||
pattern = "gateway\.remote\.password"
|
||||
pattern = "gateway\.auth\.password"
|
||||
# Schema label for talk API key (label text only).
|
||||
pattern = "talk\.apiKey"
|
||||
# checking for typeof is not something we care about.
|
||||
pattern = === "string"
|
||||
# specific optional-chaining password check that didn't match the line above.
|
||||
pattern = typeof remote\?\.password === "string"
|
||||
@@ -46,15 +46,3 @@ Swabble/
|
||||
Core/
|
||||
Users/
|
||||
vendor/
|
||||
|
||||
# Needed for building the Canvas A2UI bundle during Docker image builds.
|
||||
# Keep the rest of apps/ and vendor/ excluded to avoid a large build context.
|
||||
!apps/shared/
|
||||
!apps/shared/OpenClawKit/
|
||||
!apps/shared/OpenClawKit/Tools/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/**
|
||||
!vendor/a2ui/
|
||||
!vendor/a2ui/renderers/
|
||||
!vendor/a2ui/renderers/lit/
|
||||
!vendor/a2ui/renderers/lit/**
|
||||
|
||||
75
.env.example
75
.env.example
@@ -1,70 +1,5 @@
|
||||
# OpenClaw .env example
|
||||
#
|
||||
# Quick start:
|
||||
# 1) Copy this file to `.env` (for local runs from this repo), OR to `~/.openclaw/.env` (for launchd/systemd daemons).
|
||||
# 2) Fill only the values you use.
|
||||
# 3) Keep real secrets out of git.
|
||||
#
|
||||
# Env-source precedence for environment variables (highest -> lowest):
|
||||
# process env, ./.env, ~/.openclaw/.env, then openclaw.json `env` block.
|
||||
# Existing non-empty process env vars are not overridden by dotenv/config env loading.
|
||||
# Note: direct config keys (for example `gateway.auth.token` or channel tokens in openclaw.json)
|
||||
# are resolved separately from env loading and often take precedence over env fallbacks.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Gateway auth + paths
|
||||
# -----------------------------------------------------------------------------
|
||||
# Recommended if the gateway binds beyond loopback.
|
||||
OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token
|
||||
# Example generator: openssl rand -hex 32
|
||||
|
||||
# Optional alternative auth mode (use token OR password).
|
||||
# OPENCLAW_GATEWAY_PASSWORD=change-me-to-a-strong-password
|
||||
|
||||
# Optional path overrides (defaults shown for reference).
|
||||
# OPENCLAW_STATE_DIR=~/.openclaw
|
||||
# OPENCLAW_CONFIG_PATH=~/.openclaw/openclaw.json
|
||||
# OPENCLAW_HOME=~
|
||||
|
||||
# Optional: import missing keys from your login shell profile.
|
||||
# OPENCLAW_LOAD_SHELL_ENV=1
|
||||
# OPENCLAW_SHELL_ENV_TIMEOUT_MS=15000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Model provider API keys (set at least one)
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# GEMINI_API_KEY=...
|
||||
# OPENROUTER_API_KEY=sk-or-...
|
||||
|
||||
# Optional additional providers
|
||||
# ZAI_API_KEY=...
|
||||
# AI_GATEWAY_API_KEY=...
|
||||
# MINIMAX_API_KEY=...
|
||||
# SYNTHETIC_API_KEY=...
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Channels (only set what you enable)
|
||||
# -----------------------------------------------------------------------------
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABCDEF...
|
||||
# DISCORD_BOT_TOKEN=...
|
||||
# SLACK_BOT_TOKEN=xoxb-...
|
||||
# SLACK_APP_TOKEN=xapp-...
|
||||
|
||||
# Optional channel env fallbacks
|
||||
# MATTERMOST_BOT_TOKEN=...
|
||||
# MATTERMOST_URL=https://chat.example.com
|
||||
# ZALO_BOT_TOKEN=...
|
||||
# OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:...
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tools + voice/media (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# BRAVE_API_KEY=...
|
||||
# PERPLEXITY_API_KEY=pplx-...
|
||||
# FIRECRAWL_API_KEY=...
|
||||
|
||||
# ELEVENLABS_API_KEY=...
|
||||
# XI_API_KEY=... # alias for ElevenLabs
|
||||
# DEEPGRAM_API_KEY=...
|
||||
# Copy to .env and fill with your Twilio credentials
|
||||
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
TWILIO_AUTH_TOKEN=your_auth_token_here
|
||||
# Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp:
|
||||
TWILIO_WHATSAPP_FROM=whatsapp:+17343367101
|
||||
|
||||
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1 +0,0 @@
|
||||
custom: ["https://github.com/sponsors/steipete"]
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -6,29 +6,23 @@ labels: bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
What went wrong?
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
What did you expect to happen?
|
||||
|
||||
## Actual behavior
|
||||
|
||||
What actually happened?
|
||||
|
||||
## Environment
|
||||
|
||||
- Clawdbot version:
|
||||
- OS:
|
||||
- Install method (pnpm/npx/docker/etc):
|
||||
|
||||
## Logs or screenshots
|
||||
|
||||
Paste relevant logs or add screenshots (redact secrets).
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Onboarding
|
||||
url: https://discord.gg/clawd
|
||||
about: New to OpenClaw? Join Discord for setup guidance from Krill in \#help.
|
||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
|
||||
- name: Support
|
||||
url: https://discord.gg/clawd
|
||||
about: Get help from Krill and the community on Discord in \#help.
|
||||
about: Get help from Krill and the community on Discord in #help.
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -6,17 +6,13 @@ labels: enhancement
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Describe the problem you are trying to solve or the opportunity you see.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
What would you like Clawdbot to do?
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Any other approaches you have considered?
|
||||
|
||||
## Additional context
|
||||
|
||||
Links, screenshots, or related issues.
|
||||
|
||||
17
.github/actionlint.yaml
vendored
17
.github/actionlint.yaml
vendored
@@ -1,17 +0,0 @@
|
||||
# actionlint configuration
|
||||
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
||||
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
# Blacksmith CI runners
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-4vcpu-windows-2025
|
||||
|
||||
# Ignore patterns for known issues
|
||||
paths:
|
||||
.github/workflows/**/*.yml:
|
||||
ignore:
|
||||
# Ignore shellcheck warnings (we run shellcheck separately)
|
||||
- "shellcheck reported issue.+"
|
||||
# Ignore intentional if: false for disabled jobs
|
||||
- 'constant expression "false" in condition'
|
||||
53
.github/actions/detect-docs-changes/action.yml
vendored
53
.github/actions/detect-docs-changes/action.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Detect docs-only changes
|
||||
description: >
|
||||
Outputs docs_only=true when all changed files are under docs/ or are
|
||||
markdown (.md/.mdx). Fail-safe: if detection fails, outputs false (run
|
||||
everything). Uses git diff — no API calls, no extra permissions needed.
|
||||
|
||||
outputs:
|
||||
docs_only:
|
||||
description: "'true' if all changes are docs/markdown, 'false' otherwise"
|
||||
value: ${{ steps.check.outputs.docs_only }}
|
||||
docs_changed:
|
||||
description: "'true' if any changed file is under docs/ or is markdown"
|
||||
value: ${{ steps.check.outputs.docs_changed }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
# Use the exact base SHA from the event payload — stable regardless
|
||||
# of base branch movement (avoids origin/<ref> drift).
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
# Fail-safe: if we can't diff, assume non-docs (run everything)
|
||||
CHANGED=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")
|
||||
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if any changed file is a doc
|
||||
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -n "$DOCS" ]; then
|
||||
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Check if all changed files are docs or markdown
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -z "$NON_DOCS" ]; then
|
||||
echo "docs_only=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs-only change detected — skipping heavy jobs"
|
||||
else
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
83
.github/actions/setup-node-env/action.yml
vendored
83
.github/actions/setup-node-env/action.yml
vendored
@@ -1,83 +0,0 @@
|
||||
name: Setup Node environment
|
||||
description: >
|
||||
Initialize submodules with retry, install Node 22, pnpm, optionally Bun,
|
||||
and run pnpm install. Requires actions/checkout to run first.
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version to install.
|
||||
required: false
|
||||
default: "22.x"
|
||||
pnpm-version:
|
||||
description: pnpm version for corepack.
|
||||
required: false
|
||||
default: "10.23.0"
|
||||
install-bun:
|
||||
description: Whether to install Bun alongside Node.
|
||||
required: false
|
||||
default: "true"
|
||||
frozen-lockfile:
|
||||
description: Whether to use --frozen-lockfile for install.
|
||||
required: false
|
||||
default: "true"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Checkout submodules (retry)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: ${{ inputs.pnpm-version }}
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
shell: bash
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
if command -v bun &>/dev/null; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
shell: bash
|
||||
run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
env:
|
||||
CI: "true"
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
LOCKFILE_FLAG=""
|
||||
if [ "${{ inputs.frozen-lockfile }}" = "true" ]; then
|
||||
LOCKFILE_FLAG="--frozen-lockfile"
|
||||
fi
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Setup pnpm + store cache
|
||||
description: Prepare pnpm via corepack and restore pnpm store cache.
|
||||
inputs:
|
||||
pnpm-version:
|
||||
description: pnpm version to activate via corepack.
|
||||
required: false
|
||||
default: "10.23.0"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the cache key.
|
||||
required: false
|
||||
default: "node22"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm (corepack retry)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare "pnpm@${{ inputs.pnpm-version }}" --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-
|
||||
113
.github/dependabot.yml
vendored
113
.github/dependabot.yml
vendored
@@ -1,113 +0,0 @@
|
||||
# Dependabot configuration
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
|
||||
registries:
|
||||
npm-npmjs:
|
||||
type: npm-registry
|
||||
url: https://registry.npmjs.org
|
||||
replaces-base: true
|
||||
|
||||
updates:
|
||||
# npm dependencies (root)
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
production:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
development:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 10
|
||||
registries:
|
||||
- npm-npmjs
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - macOS app
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/macos
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - shared MoltbotKit
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/shared/MoltbotKit
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - Swabble
|
||||
- package-ecosystem: swift
|
||||
directory: /Swabble
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Gradle - Android app
|
||||
- package-ecosystem: gradle
|
||||
directory: /apps/android
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
android-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
64
.github/instructions/copilot.instructions.md
vendored
64
.github/instructions/copilot.instructions.md
vendored
@@ -1,64 +0,0 @@
|
||||
# OpenClaw Codebase Patterns
|
||||
|
||||
**Always reuse existing code - no redundancy!**
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node 22+ (Bun also supported for dev/scripts)
|
||||
- **Language**: TypeScript (ESM, strict mode)
|
||||
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
|
||||
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
|
||||
- **Tests**: Vitest with V8 coverage
|
||||
- **CLI Framework**: Commander + clack/prompts
|
||||
- **Build**: tsdown (outputs to `dist/`)
|
||||
|
||||
## Anti-Redundancy Rules
|
||||
|
||||
- Avoid files that just re-export from another file. Import directly from the original source.
|
||||
- If a function already exists, import it - do NOT create a duplicate in another file.
|
||||
- Before creating any formatter, utility, or helper, search for existing implementations first.
|
||||
|
||||
## Source of Truth Locations
|
||||
|
||||
### Formatting Utilities (`src/infra/`)
|
||||
|
||||
- **Time formatting**: `src\infra\format-time`
|
||||
|
||||
**NEVER create local `formatAge`, `formatDuration`, `formatElapsedTime` functions - import from centralized modules.**
|
||||
|
||||
### Terminal Output (`src/terminal/`)
|
||||
|
||||
- Tables: `src/terminal/table.ts` (`renderTable`)
|
||||
- Themes/colors: `src/terminal/theme.ts` (`theme.success`, `theme.muted`, etc.)
|
||||
- Progress: `src/cli/progress.ts` (spinners, progress bars)
|
||||
|
||||
### CLI Patterns
|
||||
|
||||
- CLI option wiring: `src/cli/`
|
||||
- Commands: `src/commands/`
|
||||
- Dependency injection via `createDefaultDeps`
|
||||
|
||||
## Import Conventions
|
||||
|
||||
- Use `.js` extension for cross-package imports (ESM)
|
||||
- Direct imports only - no re-export wrapper files
|
||||
- Types: `import type { X }` for type-only imports
|
||||
|
||||
## Code Quality
|
||||
|
||||
- TypeScript (ESM), strict typing, avoid `any`
|
||||
- Keep files under ~700 LOC - extract helpers when larger
|
||||
- Colocated tests: `*.test.ts` next to source files
|
||||
- Run `pnpm check` before commits (lint + format)
|
||||
- Run `pnpm tsgo` for type checking
|
||||
|
||||
## Stack & Commands
|
||||
|
||||
- **Package manager**: pnpm (`pnpm install`)
|
||||
- **Dev**: `pnpm openclaw ...` or `pnpm dev`
|
||||
- **Type-check**: `pnpm tsgo`
|
||||
- **Lint/format**: `pnpm check`
|
||||
- **Tests**: `pnpm test`
|
||||
- **Build**: `pnpm build`
|
||||
|
||||
If you are coding together with a human, do NOT use scripts/committer, but git directly and run the above commands manually to ensure quality.
|
||||
254
.github/labeler.yml
vendored
254
.github/labeler.yml
vendored
@@ -1,254 +0,0 @@
|
||||
"channel: bluebubbles":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/bluebubbles/**"
|
||||
- "docs/channels/bluebubbles.md"
|
||||
"channel: discord":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/discord/**"
|
||||
- "extensions/discord/**"
|
||||
- "docs/channels/discord.md"
|
||||
"channel: irc":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/irc/**"
|
||||
- "docs/channels/irc.md"
|
||||
"channel: feishu":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/feishu/**"
|
||||
- "extensions/feishu/**"
|
||||
- "docs/channels/feishu.md"
|
||||
"channel: googlechat":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/googlechat/**"
|
||||
- "docs/channels/googlechat.md"
|
||||
"channel: imessage":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/imessage/**"
|
||||
- "extensions/imessage/**"
|
||||
- "docs/channels/imessage.md"
|
||||
"channel: line":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/line/**"
|
||||
- "docs/channels/line.md"
|
||||
"channel: matrix":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/matrix/**"
|
||||
- "docs/channels/matrix.md"
|
||||
"channel: mattermost":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/mattermost/**"
|
||||
- "docs/channels/mattermost.md"
|
||||
"channel: msteams":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/msteams/**"
|
||||
- "docs/channels/msteams.md"
|
||||
"channel: nextcloud-talk":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nextcloud-talk/**"
|
||||
- "docs/channels/nextcloud-talk.md"
|
||||
"channel: nostr":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nostr/**"
|
||||
- "docs/channels/nostr.md"
|
||||
"channel: signal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/signal/**"
|
||||
- "extensions/signal/**"
|
||||
- "docs/channels/signal.md"
|
||||
"channel: slack":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/slack/**"
|
||||
- "extensions/slack/**"
|
||||
- "docs/channels/slack.md"
|
||||
"channel: telegram":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/telegram/**"
|
||||
- "extensions/telegram/**"
|
||||
- "docs/channels/telegram.md"
|
||||
"channel: tlon":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/tlon/**"
|
||||
- "docs/channels/tlon.md"
|
||||
"channel: twitch":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/twitch/**"
|
||||
- "docs/channels/twitch.md"
|
||||
"channel: voice-call":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/voice-call/**"
|
||||
"channel: whatsapp-web":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/web/**"
|
||||
- "extensions/whatsapp/**"
|
||||
- "docs/channels/whatsapp.md"
|
||||
"channel: zalo":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/zalo/**"
|
||||
- "docs/channels/zalo.md"
|
||||
"channel: zalouser":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/zalouser/**"
|
||||
- "docs/channels/zalouser.md"
|
||||
|
||||
"app: android":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "apps/android/**"
|
||||
- "docs/platforms/android.md"
|
||||
"app: ios":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "apps/ios/**"
|
||||
- "docs/platforms/ios.md"
|
||||
"app: macos":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "apps/macos/**"
|
||||
- "docs/platforms/macos.md"
|
||||
- "docs/platforms/mac/**"
|
||||
"app: web-ui":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "ui/**"
|
||||
- "src/gateway/control-ui.ts"
|
||||
- "src/gateway/control-ui-shared.ts"
|
||||
- "src/gateway/protocol/**"
|
||||
- "src/gateway/server-methods/chat.ts"
|
||||
- "src/infra/control-ui-assets.ts"
|
||||
|
||||
"gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/gateway/**"
|
||||
- "src/daemon/**"
|
||||
- "docs/gateway/**"
|
||||
|
||||
"docs":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "docs/**"
|
||||
- "docs.acp.md"
|
||||
|
||||
"cli":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/cli/**"
|
||||
|
||||
"commands":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/commands/**"
|
||||
|
||||
"scripts":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "scripts/**"
|
||||
|
||||
"docker":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.*"
|
||||
- "docker-compose.yml"
|
||||
- "docker-setup.sh"
|
||||
- ".dockerignore"
|
||||
- "scripts/**/*docker*"
|
||||
- "scripts/**/Dockerfile*"
|
||||
- "scripts/sandbox-*.sh"
|
||||
- "src/agents/sandbox*.ts"
|
||||
- "src/commands/sandbox*.ts"
|
||||
- "src/cli/sandbox-cli.ts"
|
||||
- "src/docker-setup.test.ts"
|
||||
- "src/config/**/*sandbox*"
|
||||
- "docs/cli/sandbox.md"
|
||||
- "docs/gateway/sandbox*.md"
|
||||
- "docs/install/docker.md"
|
||||
- "docs/multi-agent-sandbox-tools.md"
|
||||
|
||||
"agents":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/agents/**"
|
||||
|
||||
"security":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "docs/cli/security.md"
|
||||
- "docs/gateway/security.md"
|
||||
|
||||
"extensions: copilot-proxy":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/copilot-proxy/**"
|
||||
"extensions: diagnostics-otel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/diagnostics-otel/**"
|
||||
"extensions: google-antigravity-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-antigravity-auth/**"
|
||||
"extensions: google-gemini-cli-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-gemini-cli-auth/**"
|
||||
"extensions: llm-task":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/llm-task/**"
|
||||
"extensions: lobster":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/lobster/**"
|
||||
"extensions: memory-core":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/memory-core/**"
|
||||
"extensions: memory-lancedb":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/memory-lancedb/**"
|
||||
"extensions: open-prose":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/open-prose/**"
|
||||
"extensions: qwen-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qwen-portal-auth/**"
|
||||
"extensions: device-pair":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/device-pair/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/minimax-portal-auth/**"
|
||||
"extensions: phone-control":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/phone-control/**"
|
||||
"extensions: talk-voice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/talk-voice/**"
|
||||
183
.github/workflows/auto-response.yml
vendored
183
.github/workflows/auto-response.yml
vendored
@@ -1,183 +0,0 @@
|
||||
name: Auto response
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
auto-response:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Handle labeled items
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
// Labels prefixed with "r:" are auto-response triggers.
|
||||
const rules = [
|
||||
{
|
||||
label: "r: skill",
|
||||
close: true,
|
||||
message:
|
||||
"Thanks for the contribution! New skills should be published to [Clawhub](https://clawhub.ai) for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
|
||||
},
|
||||
{
|
||||
label: "r: support",
|
||||
close: true,
|
||||
message:
|
||||
"Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
|
||||
},
|
||||
{
|
||||
label: "r: testflight",
|
||||
close: true,
|
||||
message: "Not available, build from source.",
|
||||
},
|
||||
{
|
||||
label: "r: third-party-extension",
|
||||
close: true,
|
||||
message:
|
||||
"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",
|
||||
close: true,
|
||||
lock: true,
|
||||
lockReason: "off-topic",
|
||||
message:
|
||||
"OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
|
||||
},
|
||||
];
|
||||
|
||||
const triggerLabel = "trigger-response";
|
||||
const target = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelSet = new Set(
|
||||
(target.labels ?? [])
|
||||
.map((label) => (typeof label === "string" ? label : label?.name))
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
const hasTriggerLabel = labelSet.has(triggerLabel);
|
||||
if (hasTriggerLabel) {
|
||||
labelSet.delete(triggerLabel);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: target.number,
|
||||
name: triggerLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTriggerLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = context.payload.issue;
|
||||
if (issue) {
|
||||
const title = issue.title ?? "";
|
||||
const body = issue.body ?? "";
|
||||
const haystack = `${title}\n${body}`.toLowerCase();
|
||||
const hasMoltbookLabel = labelSet.has("r: moltbook");
|
||||
const hasTestflightLabel = labelSet.has("r: testflight");
|
||||
const hasSecurityLabel = labelSet.has("security");
|
||||
if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["security"],
|
||||
});
|
||||
labelSet.add("security");
|
||||
}
|
||||
if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["r: testflight"],
|
||||
});
|
||||
labelSet.add("r: testflight");
|
||||
}
|
||||
if (haystack.includes("moltbook") && !hasMoltbookLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["r: moltbook"],
|
||||
});
|
||||
labelSet.add("r: moltbook");
|
||||
}
|
||||
}
|
||||
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (pullRequest) {
|
||||
const labelCount = labelSet.size;
|
||||
if (labelCount > 20) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: "Closing this PR because it has more than 20 labels, which usually means the branch is too noisy. Please recreate the PR from a clean branch.",
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rule = rules.find((item) => labelSet.has(item.label));
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueNumber = target.number;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: rule.message,
|
||||
});
|
||||
|
||||
if (rule.close) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: "closed",
|
||||
});
|
||||
}
|
||||
|
||||
if (rule.lock) {
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
lock_reason: rule.lockReason ?? "resolved",
|
||||
});
|
||||
}
|
||||
608
.github/workflows/ci.yml
vendored
608
.github/workflows/ci.yml
vendored
@@ -2,295 +2,147 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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: ubuntu-latest
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
docs_changed: ${{ steps.check.outputs.docs_changed }}
|
||||
install-check:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
|
||||
# Push to main keeps broad coverage.
|
||||
changed-scope:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run_node: ${{ steps.scope.outputs.run_node }}
|
||||
run_macos: ${{ steps.scope.outputs.run_macos }}
|
||||
run_android: ${{ steps.scope.outputs.run_android }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- name: Detect changed scopes
|
||||
id: scope
|
||||
shell: bash
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
|
||||
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
|
||||
# Fail-safe: run broad checks if detection fails.
|
||||
echo "run_node=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_node=false
|
||||
run_macos=false
|
||||
run_android=false
|
||||
has_non_docs=false
|
||||
has_non_native_non_docs=false
|
||||
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
case "$path" in
|
||||
docs/*|*.md|*.mdx)
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
has_non_docs=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
# Generated protocol models are already covered by protocol:check and
|
||||
# should not force the full native macOS lane.
|
||||
apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*)
|
||||
;;
|
||||
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
|
||||
run_macos=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/shared/*)
|
||||
run_android=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc)
|
||||
run_node=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml)
|
||||
;;
|
||||
*)
|
||||
has_non_native_non_docs=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$CHANGED"
|
||||
|
||||
# If there are non-doc files outside native app trees, keep Node checks enabled.
|
||||
if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then
|
||||
run_node=true
|
||||
fi
|
||||
|
||||
echo "run_node=${run_node}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=${run_android}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream 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-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
submodules: false
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
# Validate npm pack contents after build (only on push to main, not PRs).
|
||||
release-check:
|
||||
needs: [docs-scope, build-artifacts]
|
||||
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true'
|
||||
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: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Check release contents
|
||||
run: pnpm release:check
|
||||
- name: Install dependencies (frozen)
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
checks:
|
||||
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-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: build
|
||||
command: pnpm build
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: lint
|
||||
command: bunx biome check src
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
||||
command: bunx vitest run
|
||||
- runtime: bun
|
||||
task: build
|
||||
command: bunx tsc -p tsconfig.json
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Configure vitest JSON reports
|
||||
if: matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Summarize slowest tests
|
||||
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: matrix.task == 'test' && matrix.runtime == 'node'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
|
||||
path: |
|
||||
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
|
||||
${{ runner.temp }}/vitest-slowest.md
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
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
|
||||
|
||||
- name: Check types and lint and oxfmt
|
||||
run: pnpm check
|
||||
|
||||
# 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-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
|
||||
secrets:
|
||||
runs-on: blacksmith-4vcpu-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 detect-secrets
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install detect-secrets==1.5.0
|
||||
|
||||
- name: Detect secrets
|
||||
run: |
|
||||
if ! detect-secrets scan --baseline .secrets.baseline; then
|
||||
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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-4vcpu-windows-2025
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Keep total concurrency predictable on the 4 vCPU runner:
|
||||
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
|
||||
OPENCLAW_TEST_WORKERS: 2
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -303,7 +155,10 @@ jobs:
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: build
|
||||
command: pnpm build
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
@@ -313,38 +168,18 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
# Defender sometimes intercepts process spawning (vitest workers). If this fails
|
||||
# (eg hardened images), keep going and rely on worker limiting above.
|
||||
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
|
||||
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
|
||||
Write-Host "Defender exclusions applied."
|
||||
} catch {
|
||||
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
- name: Download dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Verify dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/index.js
|
||||
test -s dist/plugin-sdk/index.js
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -352,12 +187,6 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -368,11 +197,16 @@ jobs:
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
@@ -383,61 +217,130 @@ 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 vitest JSON reports
|
||||
if: matrix.task == 'test'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Summarize slowest tests
|
||||
if: matrix.task == 'test'
|
||||
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: matrix.task == 'test'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
|
||||
path: |
|
||||
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
|
||||
${{ runner.temp }}/vitest-slowest.md
|
||||
|
||||
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
|
||||
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
|
||||
# running 4 separate jobs per PR (as before) starved the queue. One job
|
||||
# per PR allows 5 PRs to run macOS checks simultaneously.
|
||||
macos:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
|
||||
checks-macos:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
install-bun: "false"
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
# --- Run all checks sequentially (fast gates first) ---
|
||||
- name: TS tests (macOS)
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: pnpm test
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
- task: build
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
- task: test
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
# --- Xcode/Swift setup ---
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
run: |
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
@@ -445,43 +348,8 @@ jobs:
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Swift lint
|
||||
run: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Cache SwiftPM
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/org.swift.swiftpm
|
||||
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-swiftpm-
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Swift test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
@@ -491,6 +359,19 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
@@ -643,8 +524,6 @@ jobs:
|
||||
PY
|
||||
|
||||
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-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -660,6 +539,19 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
@@ -673,8 +565,6 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: 8.11.1
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
|
||||
149
.github/workflows/docker-release.yml
vendored
149
.github/workflows/docker-release.yml
vendored
@@ -1,149 +0,0 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
- ".agents/**"
|
||||
- "skills/**"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Build amd64 image
|
||||
build-amd64:
|
||||
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
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=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
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
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
|
||||
push: true
|
||||
|
||||
# Build arm64 image
|
||||
build-arm64:
|
||||
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
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=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
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
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
|
||||
push: true
|
||||
|
||||
# Create multi-platform manifest
|
||||
create-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs: [build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 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
|
||||
run: |
|
||||
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 }}
|
||||
138
.github/workflows/formal-conformance.yml
vendored
138
.github/workflows/formal-conformance.yml
vendored
@@ -1,138 +0,0 @@
|
||||
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'
|
||||
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
|
||||
45
.github/workflows/install-smoke.yml
vendored
45
.github/workflows/install-smoke.yml
vendored
@@ -6,56 +6,27 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docs-scope:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
install-smoke:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
|
||||
CLAWDBOT_INSTALL_URL: https://clawd.bot/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||
CLAWDBOT_NO_ONBOARD: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
|
||||
run: pnpm test:install:smoke
|
||||
|
||||
519
.github/workflows/labeler.yml
vendored
519
.github/workflows/labeler.yml
vendored
@@ -1,519 +0,0 @@
|
||||
name: Labeler
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
issues:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_prs:
|
||||
description: "Maximum number of open PRs to process (0 = all)"
|
||||
required: false
|
||||
default: "200"
|
||||
per_page:
|
||||
description: "PRs per page (1-100)"
|
||||
required: false
|
||||
default: "50"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
sync-labels: true
|
||||
- name: Apply PR size label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const labelColor = "b76e79";
|
||||
|
||||
for (const label of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
color: labelColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||
const totalChangedLines = files.reduce((total, file) => {
|
||||
const path = file.filename ?? "";
|
||||
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||
}, 0);
|
||||
|
||||
let targetSizeLabel = "size: XL";
|
||||
if (totalChangedLines < 50) {
|
||||
targetSizeLabel = "size: XS";
|
||||
} else if (totalChangedLines < 200) {
|
||||
targetSizeLabel = "size: S";
|
||||
} else if (totalChangedLines < 500) {
|
||||
targetSizeLabel = "size: M";
|
||||
} else if (totalChangedLines < 1000) {
|
||||
targetSizeLabel = "size: L";
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const label of currentLabels) {
|
||||
const name = label.name ?? "";
|
||||
if (!sizeLabels.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
if (name === targetSizeLabel) {
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.pull_request?.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const trustedLabel = "trusted-contributor";
|
||||
const experiencedLabel = "experienced-contributor";
|
||||
const trustedThreshold = 4;
|
||||
const experiencedThreshold = 10;
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ["maintainer"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
|
||||
let mergedCount = 0;
|
||||
try {
|
||||
const merged = await github.rest.search.issuesAndPullRequests({
|
||||
q: mergedQuery,
|
||||
per_page: 1,
|
||||
});
|
||||
mergedCount = merged?.data?.total_count ?? 0;
|
||||
} catch (error) {
|
||||
if (error?.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
core.warning(`Skipping merged search for ${login}; treating as 0.`);
|
||||
}
|
||||
|
||||
if (mergedCount >= experiencedThreshold) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: [experiencedLabel],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mergedCount >= trustedThreshold) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: [trustedLabel],
|
||||
});
|
||||
}
|
||||
|
||||
backfill-pr-labels:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Backfill PR labels
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const repoFull = `${owner}/${repo}`;
|
||||
const inputs = context.payload.inputs ?? {};
|
||||
const maxPrsInput = inputs.max_prs ?? "200";
|
||||
const perPageInput = inputs.per_page ?? "50";
|
||||
const parsedMaxPrs = Number.parseInt(maxPrsInput, 10);
|
||||
const parsedPerPage = Number.parseInt(perPageInput, 10);
|
||||
const maxPrs = Number.isFinite(parsedMaxPrs) ? parsedMaxPrs : 200;
|
||||
const perPage = Number.isFinite(parsedPerPage) ? Math.min(100, Math.max(1, parsedPerPage)) : 50;
|
||||
const processAll = maxPrs <= 0;
|
||||
const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs);
|
||||
|
||||
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const labelColor = "b76e79";
|
||||
const trustedLabel = "trusted-contributor";
|
||||
const experiencedLabel = "experienced-contributor";
|
||||
const trustedThreshold = 4;
|
||||
const experiencedThreshold = 10;
|
||||
|
||||
const contributorCache = new Map();
|
||||
|
||||
async function ensureSizeLabels() {
|
||||
for (const label of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
color: labelColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveContributorLabel(login) {
|
||||
if (contributorCache.has(login)) {
|
||||
return contributorCache.get(login);
|
||||
}
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
contributorCache.set(login, "maintainer");
|
||||
return "maintainer";
|
||||
}
|
||||
|
||||
const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`;
|
||||
let mergedCount = 0;
|
||||
try {
|
||||
const merged = await github.rest.search.issuesAndPullRequests({
|
||||
q: mergedQuery,
|
||||
per_page: 1,
|
||||
});
|
||||
mergedCount = merged?.data?.total_count ?? 0;
|
||||
} catch (error) {
|
||||
if (error?.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
core.warning(`Skipping merged search for ${login}; treating as 0.`);
|
||||
}
|
||||
|
||||
let label = null;
|
||||
if (mergedCount >= experiencedThreshold) {
|
||||
label = experiencedLabel;
|
||||
} else if (mergedCount >= trustedThreshold) {
|
||||
label = trustedLabel;
|
||||
}
|
||||
|
||||
contributorCache.set(login, label);
|
||||
return label;
|
||||
}
|
||||
|
||||
async function applySizeLabel(pullRequest, currentLabels, labelNames) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||
const totalChangedLines = files.reduce((total, file) => {
|
||||
const path = file.filename ?? "";
|
||||
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||
}, 0);
|
||||
|
||||
let targetSizeLabel = "size: XL";
|
||||
if (totalChangedLines < 50) {
|
||||
targetSizeLabel = "size: XS";
|
||||
} else if (totalChangedLines < 200) {
|
||||
targetSizeLabel = "size: S";
|
||||
} else if (totalChangedLines < 500) {
|
||||
targetSizeLabel = "size: M";
|
||||
} else if (totalChangedLines < 1000) {
|
||||
targetSizeLabel = "size: L";
|
||||
}
|
||||
|
||||
for (const label of currentLabels) {
|
||||
const name = label.name ?? "";
|
||||
if (!sizeLabels.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
if (name === targetSizeLabel) {
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
name,
|
||||
});
|
||||
labelNames.delete(name);
|
||||
}
|
||||
|
||||
if (!labelNames.has(targetSizeLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
labelNames.add(targetSizeLabel);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyContributorLabel(pullRequest, labelNames) {
|
||||
const login = pullRequest.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = await resolveContributorLabel(login);
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (labelNames.has(label)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [label],
|
||||
});
|
||||
labelNames.add(label);
|
||||
}
|
||||
|
||||
await ensureSizeLabels();
|
||||
|
||||
let page = 1;
|
||||
let processed = 0;
|
||||
|
||||
while (processed < maxCount) {
|
||||
const remaining = maxCount - processed;
|
||||
const pageSize = processAll ? perPage : Math.min(perPage, remaining);
|
||||
const { data: pullRequests } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: pageSize,
|
||||
page,
|
||||
});
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const pullRequest of pullRequests) {
|
||||
if (!processAll && processed >= maxCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const labelNames = new Set(
|
||||
currentLabels.map((label) => label.name).filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
await applySizeLabel(pullRequest, currentLabels, labelNames);
|
||||
await applyContributorLabel(pullRequest, labelNames);
|
||||
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
if (pullRequests.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
core.info(`Processed ${processed} pull requests.`);
|
||||
|
||||
label-issues:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.issue?.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const trustedLabel = "trusted-contributor";
|
||||
const experiencedLabel = "experienced-contributor";
|
||||
const trustedThreshold = 4;
|
||||
const experiencedThreshold = 10;
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: ["maintainer"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
|
||||
let mergedCount = 0;
|
||||
try {
|
||||
const merged = await github.rest.search.issuesAndPullRequests({
|
||||
q: mergedQuery,
|
||||
per_page: 1,
|
||||
});
|
||||
mergedCount = merged?.data?.total_count ?? 0;
|
||||
} catch (error) {
|
||||
if (error?.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
core.warning(`Skipping merged search for ${login}; treating as 0.`);
|
||||
}
|
||||
|
||||
if (mergedCount >= experiencedThreshold) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: [experiencedLabel],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mergedCount >= trustedThreshold) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: [trustedLabel],
|
||||
});
|
||||
}
|
||||
51
.github/workflows/stale.yml
vendored
51
.github/workflows/stale.yml
vendored
@@ -1,51 +0,0 @@
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Mark stale issues and pull requests
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 500
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
5
.github/workflows/workflow-sanity.yml
vendored
5
.github/workflows/workflow-sanity.yml
vendored
@@ -3,11 +3,6 @@ name: Workflow Sanity
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
|
||||
38
.gitignore
vendored
38
.gitignore
vendored
@@ -1,15 +1,12 @@
|
||||
node_modules
|
||||
**/node_modules/
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
coverage
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
@@ -18,37 +15,25 @@ ui/src/ui/__screenshots__/
|
||||
ui/playwright-report/
|
||||
ui/test-results/
|
||||
|
||||
# Android build artifacts
|
||||
apps/android/.gradle/
|
||||
apps/android/app/build/
|
||||
apps/android/.cxx/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/MoltbotKit/.build/
|
||||
apps/shared/ClawdbotKit/.build/
|
||||
**/ModuleCache/
|
||||
bin/
|
||||
bin/clawdbot-mac
|
||||
bin/docs-list
|
||||
apps/macos/.build-local/
|
||||
apps/macos/.swiftpm/
|
||||
apps/shared/MoltbotKit/.swiftpm/
|
||||
apps/shared/ClawdbotKit/.swiftpm/
|
||||
Core/
|
||||
apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
vendor/
|
||||
apps/ios/Clawdbot.xcodeproj/
|
||||
apps/ios/Clawdbot.xcodeproj/**
|
||||
apps/macos/.build/**
|
||||
**/*.bun-build
|
||||
apps/ios/*.xcfilelist
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
src/canvas-host/a2ui/*.bundle.js
|
||||
src/canvas-host/a2ui/*.map
|
||||
.bundle.hash
|
||||
|
||||
# fastlane (iOS)
|
||||
@@ -58,7 +43,6 @@ apps/ios/fastlane/Preview.html
|
||||
apps/ios/fastlane/screenshots/
|
||||
apps/ios/fastlane/test_output/
|
||||
apps/ios/fastlane/logs/
|
||||
apps/ios/fastlane/.env
|
||||
|
||||
# fastlane build artifacts (local)
|
||||
apps/ios/*.ipa
|
||||
@@ -66,20 +50,8 @@ apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
|
||||
# Local untracked files
|
||||
.local/
|
||||
docs/.local/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
.idea
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
# Agent credentials and memory (NEVER COMMIT)
|
||||
/memory/
|
||||
.agent/*.json
|
||||
!.agent/workflows/
|
||||
local/
|
||||
.vscode/
|
||||
|
||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
branch = main
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"globs": ["docs/**/*.md", "docs/**/*.mdx", "README.md"],
|
||||
"ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**", "**/.local/**"],
|
||||
"config": {
|
||||
"default": true,
|
||||
|
||||
"MD013": false,
|
||||
"MD025": false,
|
||||
"MD029": false,
|
||||
|
||||
"MD033": {
|
||||
"allowed_elements": [
|
||||
"Note",
|
||||
"Info",
|
||||
"Tip",
|
||||
"Warning",
|
||||
"Card",
|
||||
"CardGroup",
|
||||
"Columns",
|
||||
"Steps",
|
||||
"Step",
|
||||
"Tabs",
|
||||
"Tab",
|
||||
"Accordion",
|
||||
"AccordionGroup",
|
||||
"CodeGroup",
|
||||
"Frame",
|
||||
"Callout",
|
||||
"ParamField",
|
||||
"ResponseField",
|
||||
"RequestExample",
|
||||
"ResponseExample",
|
||||
"img",
|
||||
"a",
|
||||
"br",
|
||||
"details",
|
||||
"summary",
|
||||
"p",
|
||||
"strong",
|
||||
"picture",
|
||||
"source",
|
||||
"Tooltip",
|
||||
"Check",
|
||||
],
|
||||
},
|
||||
|
||||
"MD036": false,
|
||||
"MD040": false,
|
||||
"MD041": false,
|
||||
"MD046": false,
|
||||
},
|
||||
}
|
||||
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"experimentalSortImports": {
|
||||
"newlinesBetween": false,
|
||||
},
|
||||
"experimentalSortPackageJson": {
|
||||
"sortScripts": true,
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"apps/",
|
||||
"assets/",
|
||||
"dist/",
|
||||
"docs/_layouts/",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml/",
|
||||
"Swabble/",
|
||||
"vendor/",
|
||||
],
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["unicorn", "typescript", "oxc"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"perf": "error",
|
||||
"suspicious": "error"
|
||||
},
|
||||
"rules": {
|
||||
"curly": "error",
|
||||
"eslint-plugin-unicorn/prefer-array-find": "off",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
"eslint/no-new": "off",
|
||||
"oxc/no-accumulating-spread": "off",
|
||||
"oxc/no-async-endpoint-handlers": "off",
|
||||
"oxc/no-map-spread": "off",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-extraneous-class": "off",
|
||||
"typescript/no-unsafe-type-assertion": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/require-post-message-target-origin": "off"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"assets/",
|
||||
"dist/",
|
||||
"docs/_layouts/",
|
||||
"extensions/",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml/",
|
||||
"skills/",
|
||||
"src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"Swabble/",
|
||||
"vendor/"
|
||||
]
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* Diff Extension
|
||||
*
|
||||
* /diff command shows modified/deleted/new files from git status and opens
|
||||
* the selected file in VS Code's diff view.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
interface FileInfo {
|
||||
status: string;
|
||||
statusLabel: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("diff", {
|
||||
description: "Show git changes and open in VS Code diff view",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("No UI available", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get changed files from git status
|
||||
const result = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
|
||||
|
||||
if (result.code !== 0) {
|
||||
ctx.ui.notify(`git status failed: ${result.stderr}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.stdout || !result.stdout.trim()) {
|
||||
ctx.ui.notify("No changes in working tree", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse git status output
|
||||
// Format: XY filename (where XY is two-letter status, then space, then filename)
|
||||
const lines = result.stdout.split("\n");
|
||||
const files: FileInfo[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length < 4) {
|
||||
continue;
|
||||
} // Need at least "XY f"
|
||||
|
||||
const status = line.slice(0, 2);
|
||||
const file = line.slice(2).trimStart();
|
||||
|
||||
// Translate status codes to short labels
|
||||
let statusLabel: string;
|
||||
if (status.includes("M")) {
|
||||
statusLabel = "M";
|
||||
} else if (status.includes("A")) {
|
||||
statusLabel = "A";
|
||||
} else if (status.includes("D")) {
|
||||
statusLabel = "D";
|
||||
} else if (status.includes("?")) {
|
||||
statusLabel = "?";
|
||||
} else if (status.includes("R")) {
|
||||
statusLabel = "R";
|
||||
} else if (status.includes("C")) {
|
||||
statusLabel = "C";
|
||||
} else {
|
||||
statusLabel = status.trim() || "~";
|
||||
}
|
||||
|
||||
files.push({ status: statusLabel, statusLabel, file });
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
ctx.ui.notify("No changes found", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const openSelected = async (fileInfo: FileInfo): Promise<void> => {
|
||||
try {
|
||||
// Open in VS Code diff view.
|
||||
// For untracked files, git difftool won't work, so fall back to just opening the file.
|
||||
if (fileInfo.status === "?") {
|
||||
await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd });
|
||||
return;
|
||||
}
|
||||
|
||||
const diffResult = await pi.exec(
|
||||
"git",
|
||||
["difftool", "-y", "--tool=vscode", fileInfo.file],
|
||||
{
|
||||
cwd: ctx.cwd,
|
||||
},
|
||||
);
|
||||
if (diffResult.code !== 0) {
|
||||
await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Failed to open ${fileInfo.file}: ${message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Show file picker with SelectList
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0));
|
||||
|
||||
// Build select items with colored status
|
||||
const items: SelectItem[] = files.map((f) => {
|
||||
let statusColor: string;
|
||||
switch (f.status) {
|
||||
case "M":
|
||||
statusColor = theme.fg("warning", f.status);
|
||||
break;
|
||||
case "A":
|
||||
statusColor = theme.fg("success", f.status);
|
||||
break;
|
||||
case "D":
|
||||
statusColor = theme.fg("error", f.status);
|
||||
break;
|
||||
case "?":
|
||||
statusColor = theme.fg("muted", f.status);
|
||||
break;
|
||||
default:
|
||||
statusColor = theme.fg("dim", f.status);
|
||||
}
|
||||
return {
|
||||
value: f,
|
||||
label: `${statusColor} ${f.file}`,
|
||||
};
|
||||
});
|
||||
|
||||
const visibleRows = Math.min(files.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(items, visibleRows, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => t, // Keep existing colors
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => {
|
||||
void openSelected(item.value as FileInfo);
|
||||
};
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
// Help text
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (w) => container.render(w),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
// Add paging with left/right
|
||||
if (matchesKey(data, Key.left)) {
|
||||
// Page up - clamp to 0
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
// Page down - clamp to last
|
||||
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Files Extension
|
||||
*
|
||||
* /files command lists all files the model has read/written/edited in the active session branch,
|
||||
* coalesced by path and sorted newest first. Selecting a file opens it in VS Code.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
operations: Set<"read" | "write" | "edit">;
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
type FileToolName = "read" | "write" | "edit";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("files", {
|
||||
description: "Show files read/written/edited in this session",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("No UI available", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current branch (path from leaf to root)
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
|
||||
// First pass: collect tool calls (id -> {path, name}) from assistant messages
|
||||
const toolCalls = new Map<string, { path: string; name: FileToolName; timestamp: number }>();
|
||||
|
||||
for (const entry of branch) {
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
const msg = entry.message;
|
||||
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "toolCall") {
|
||||
const name = block.name;
|
||||
if (name === "read" || name === "write" || name === "edit") {
|
||||
const path = block.arguments?.path;
|
||||
if (path && typeof path === "string") {
|
||||
toolCalls.set(block.id, { path, name, timestamp: msg.timestamp });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: match tool results to get the actual execution timestamp
|
||||
const fileMap = new Map<string, FileEntry>();
|
||||
|
||||
for (const entry of branch) {
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
const msg = entry.message;
|
||||
|
||||
if (msg.role === "toolResult") {
|
||||
const toolCall = toolCalls.get(msg.toolCallId);
|
||||
if (!toolCall) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { path, name } = toolCall;
|
||||
const timestamp = msg.timestamp;
|
||||
|
||||
const existing = fileMap.get(path);
|
||||
if (existing) {
|
||||
existing.operations.add(name);
|
||||
if (timestamp > existing.lastTimestamp) {
|
||||
existing.lastTimestamp = timestamp;
|
||||
}
|
||||
} else {
|
||||
fileMap.set(path, {
|
||||
path,
|
||||
operations: new Set([name]),
|
||||
lastTimestamp: timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileMap.size === 0) {
|
||||
ctx.ui.notify("No files read/written/edited in this session", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by most recent first
|
||||
const files = Array.from(fileMap.values()).toSorted(
|
||||
(a, b) => b.lastTimestamp - a.lastTimestamp,
|
||||
);
|
||||
|
||||
const openSelected = async (file: FileEntry): Promise<void> => {
|
||||
try {
|
||||
await pi.exec("code", ["-g", file.path], { cwd: ctx.cwd });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Failed to open ${file.path}: ${message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Show file picker with SelectList
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
|
||||
|
||||
// Build select items with colored operations
|
||||
const items: SelectItem[] = files.map((f) => {
|
||||
const ops: string[] = [];
|
||||
if (f.operations.has("read")) {
|
||||
ops.push(theme.fg("muted", "R"));
|
||||
}
|
||||
if (f.operations.has("write")) {
|
||||
ops.push(theme.fg("success", "W"));
|
||||
}
|
||||
if (f.operations.has("edit")) {
|
||||
ops.push(theme.fg("warning", "E"));
|
||||
}
|
||||
const opsLabel = ops.join("");
|
||||
return {
|
||||
value: f,
|
||||
label: `${opsLabel} ${f.path}`,
|
||||
};
|
||||
});
|
||||
|
||||
const visibleRows = Math.min(files.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(items, visibleRows, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => t, // Keep existing colors
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => {
|
||||
void openSelected(item.value as FileEntry);
|
||||
};
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
// Help text
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (w) => container.render(w),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
// Add paging with left/right
|
||||
if (matchesKey(data, Key.left)) {
|
||||
// Page up - clamp to 0
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
// Page down - clamp to last
|
||||
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import {
|
||||
DynamicBorder,
|
||||
type ExtensionAPI,
|
||||
type ExtensionContext,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Text } from "@mariozechner/pi-tui";
|
||||
|
||||
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
||||
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
||||
|
||||
type PromptMatch = {
|
||||
kind: "pr" | "issue";
|
||||
url: string;
|
||||
};
|
||||
|
||||
type GhMetadata = {
|
||||
title?: string;
|
||||
author?: {
|
||||
login?: string;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||
if (prMatch?.[1]) {
|
||||
return { kind: "pr", url: prMatch[1].trim() };
|
||||
}
|
||||
|
||||
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
||||
if (issueMatch?.[1]) {
|
||||
return { kind: "issue", url: issueMatch[1].trim() };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchGhMetadata(
|
||||
pi: ExtensionAPI,
|
||||
kind: PromptMatch["kind"],
|
||||
url: string,
|
||||
): Promise<GhMetadata | undefined> {
|
||||
const args =
|
||||
kind === "pr"
|
||||
? ["pr", "view", url, "--json", "title,author"]
|
||||
: ["issue", "view", url, "--json", "title,author"];
|
||||
|
||||
try {
|
||||
const result = await pi.exec("gh", args);
|
||||
if (result.code !== 0 || !result.stdout) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(result.stdout) as GhMetadata;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
||||
if (!author) {
|
||||
return undefined;
|
||||
}
|
||||
const name = author.name?.trim();
|
||||
const login = author.login?.trim();
|
||||
if (name && login) {
|
||||
return `${name} (@${login})`;
|
||||
}
|
||||
if (login) {
|
||||
return `@${login}`;
|
||||
}
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
const setWidget = (
|
||||
ctx: ExtensionContext,
|
||||
match: PromptMatch,
|
||||
title?: string,
|
||||
authorText?: string,
|
||||
) => {
|
||||
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
||||
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
|
||||
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
|
||||
const urlLine = thm.fg("dim", match.url);
|
||||
|
||||
const lines = [titleText];
|
||||
if (authorLine) {
|
||||
lines.push(authorLine);
|
||||
}
|
||||
lines.push(urlLine);
|
||||
|
||||
const container = new Container();
|
||||
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
|
||||
container.addChild(new Text(lines.join("\n"), 1, 0));
|
||||
return container;
|
||||
});
|
||||
};
|
||||
|
||||
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
||||
const label = match.kind === "pr" ? "PR" : "Issue";
|
||||
const trimmedTitle = title?.trim();
|
||||
const fallbackName = `${label}: ${match.url}`;
|
||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
||||
const currentName = pi.getSessionName()?.trim();
|
||||
if (!currentName) {
|
||||
pi.setSessionName(desiredName);
|
||||
return;
|
||||
}
|
||||
if (currentName === match.url || currentName === fallbackName) {
|
||||
pi.setSessionName(desiredName);
|
||||
}
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
const match = extractPromptMatch(event.prompt);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
|
||||
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return (
|
||||
content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n") ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
const rebuildFromSession = (ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const lastMatch = [...entries].toReversed().find((entry) => {
|
||||
if (entry.type !== "message" || entry.message.role !== "user") {
|
||||
return false;
|
||||
}
|
||||
const text = getUserText(entry.message.content);
|
||||
return !!extractPromptMatch(text);
|
||||
});
|
||||
|
||||
const content =
|
||||
lastMatch?.type === "message" && lastMatch.message.role === "user"
|
||||
? lastMatch.message.content
|
||||
: undefined;
|
||||
const text = getUserText(content);
|
||||
const match = text ? extractPromptMatch(text) : undefined;
|
||||
if (!match) {
|
||||
ctx.ui.setWidget("prompt-url", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Redraws Extension
|
||||
*
|
||||
* Exposes /tui to show TUI redraw stats.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("tui", {
|
||||
description: "Show TUI stats",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
let redraws = 0;
|
||||
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
||||
redraws = tui.fullRedraws;
|
||||
done(undefined);
|
||||
return new Text("", 0, 0);
|
||||
});
|
||||
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
2
.pi/git/.gitignore
vendored
2
.pi/git/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
|
||||
Audit changelog entries for all commits since the last release.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Find the last release tag:**
|
||||
|
||||
```bash
|
||||
git tag --sort=-version:refname | head -1
|
||||
```
|
||||
|
||||
2. **List all commits since that tag:**
|
||||
|
||||
```bash
|
||||
git log <tag>..HEAD --oneline
|
||||
```
|
||||
|
||||
3. **Read each package's [Unreleased] section:**
|
||||
- packages/ai/CHANGELOG.md
|
||||
- packages/tui/CHANGELOG.md
|
||||
- packages/coding-agent/CHANGELOG.md
|
||||
|
||||
4. **For each commit, check:**
|
||||
- Skip: changelog updates, doc-only changes, release housekeeping
|
||||
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
|
||||
- Verify a changelog entry exists in the affected package(s)
|
||||
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
|
||||
|
||||
5. **Cross-package duplication rule:**
|
||||
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
|
||||
|
||||
6. **Add New Features section after changelog fixes:**
|
||||
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
|
||||
- Propose the top new features to the user for confirmation before writing them.
|
||||
- Link to relevant docs and sections whenever possible.
|
||||
|
||||
7. **Report:**
|
||||
- List commits with missing entries
|
||||
- List entries that need cross-package duplication
|
||||
- Add any missing entries directly
|
||||
|
||||
## Changelog Format Reference
|
||||
|
||||
Sections (in order):
|
||||
|
||||
- `### Breaking Changes` - API changes requiring migration
|
||||
- `### Added` - New features
|
||||
- `### Changed` - Changes to existing functionality
|
||||
- `### Fixed` - Bug fixes
|
||||
- `### Removed` - Removed features
|
||||
|
||||
Attribution:
|
||||
|
||||
- Internal: `Fixed foo ([#123](https://github.com/badlogic/pi-mono/issues/123))`
|
||||
- External: `Added bar ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@user](https://github.com/user))`
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
---
|
||||
|
||||
Analyze GitHub issue(s): $ARGUMENTS
|
||||
|
||||
For each issue:
|
||||
|
||||
1. Read the issue in full, including all comments and linked issues/PRs.
|
||||
|
||||
2. **For bugs**:
|
||||
- Ignore any root cause analysis in the issue (likely wrong)
|
||||
- Read all related code files in full (no truncation)
|
||||
- Trace the code path and identify the actual root cause
|
||||
- Propose a fix
|
||||
|
||||
3. **For feature requests**:
|
||||
- Read all related code files in full (no truncation)
|
||||
- Propose the most concise implementation approach
|
||||
- List affected files and changes needed
|
||||
|
||||
Do NOT implement unless explicitly asked. Analyze and propose only.
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
description: Land a PR (merge with proper workflow)
|
||||
---
|
||||
|
||||
Input
|
||||
|
||||
- PR: $1 <number|url>
|
||||
- If missing: use the most recent PR mentioned in the conversation.
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (end-to-end)
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
|
||||
|
||||
1. Assign PR to self:
|
||||
- `gh pr edit <PR> --add-assignee @me`
|
||||
2. Repo clean: `git status`.
|
||||
3. Identify PR meta (author + head branch):
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
```
|
||||
|
||||
4. Fast-forward base:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
5. Create temp base branch from main:
|
||||
- `git checkout -b temp/landpr-<ts-or-pr>`
|
||||
6. Check out PR branch locally:
|
||||
- `gh pr checkout <PR>`
|
||||
7. Rebase PR branch onto temp base:
|
||||
- `git rebase temp/landpr-<ts-or-pr>`
|
||||
- Fix conflicts; keep history tidy.
|
||||
8. Fix + tests + changelog:
|
||||
- Implement fixes + add/adjust tests
|
||||
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
|
||||
9. Decide merge strategy:
|
||||
- Rebase if we want to preserve commit history
|
||||
- Squash if we want a single clean commit
|
||||
- If unclear, ask
|
||||
10. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
11. Commit via committer (include # + contributor in commit message):
|
||||
- `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
|
||||
- `land_sha=$(git rev-parse HEAD)`
|
||||
12. Push updated PR branch (rebase => usually needs force):
|
||||
|
||||
```sh
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
13. Merge PR (must show MERGED on GitHub):
|
||||
- Rebase: `gh pr merge <PR> --rebase`
|
||||
- Squash: `gh pr merge <PR> --squash`
|
||||
- Never `gh pr close` (closing is wrong)
|
||||
14. Sync main:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
15. Comment on PR with what we did + SHAs + thanks:
|
||||
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
gh pr comment <PR> --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!"
|
||||
```
|
||||
|
||||
16. Verify PR state == MERGED:
|
||||
- `gh pr view <PR> --json state --jq .state`
|
||||
17. Delete temp branch:
|
||||
- `git branch -D temp/landpr-<ts-or-pr>`
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
description: Review a PR thoroughly without merging
|
||||
---
|
||||
|
||||
Input
|
||||
|
||||
- PR: $1 <number|url>
|
||||
- If missing: use the most recent PR mentioned in the conversation.
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (review-only)
|
||||
Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
|
||||
|
||||
1. Identify PR meta + context
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length}'
|
||||
```
|
||||
|
||||
2. Read the PR description carefully
|
||||
- Summarize the stated goal, scope, and any "why now?" rationale.
|
||||
- Call out any missing context: motivation, alternatives considered, rollout/compat notes, risk.
|
||||
|
||||
3. Read the diff thoroughly (prefer full diff)
|
||||
|
||||
```sh
|
||||
gh pr diff <PR>
|
||||
# If you need more surrounding context for files:
|
||||
gh pr checkout <PR> # optional; still review-only
|
||||
git show --stat
|
||||
```
|
||||
|
||||
4. Validate the change is needed / valuable
|
||||
- What user/customer/dev pain does this solve?
|
||||
- Is this change the smallest reasonable fix?
|
||||
- Are we introducing complexity for marginal benefit?
|
||||
- Are we changing behavior/contract in a way that needs docs or a release note?
|
||||
|
||||
5. Evaluate implementation quality + optimality
|
||||
- Correctness: edge cases, error handling, null/undefined, concurrency, ordering.
|
||||
- Design: is the abstraction/architecture appropriate or over/under-engineered?
|
||||
- Performance: hot paths, allocations, queries, network, N+1s, caching.
|
||||
- Security/privacy: authz/authn, input validation, secrets, logging PII.
|
||||
- Backwards compatibility: public APIs, config, migrations.
|
||||
- Style consistency: formatting, naming, patterns used elsewhere.
|
||||
|
||||
6. Tests & verification
|
||||
- Identify what's covered by tests (unit/integration/e2e).
|
||||
- Are there regression tests for the bug fixed / scenario added?
|
||||
- Missing tests? Call out exact cases that should be added.
|
||||
- If tests are present, do they actually assert the important behavior (not just snapshots / happy path)?
|
||||
|
||||
7. Follow-up refactors / cleanup suggestions
|
||||
- Any code that should be simplified before merge?
|
||||
- Any TODOs that should be tickets vs addressed now?
|
||||
- Any deprecations, docs, types, or lint rules we should adjust?
|
||||
|
||||
8. Key questions to answer explicitly
|
||||
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
|
||||
- Any blocking concerns (must-fix before merge)?
|
||||
- Is this PR ready to land, or does it need work?
|
||||
|
||||
9. Output (structured)
|
||||
Produce a review with these sections:
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION
|
||||
- 1–3 sentence rationale.
|
||||
|
||||
B) What changed
|
||||
|
||||
- Brief bullet summary of the diff/behavioral changes.
|
||||
|
||||
C) What's good
|
||||
|
||||
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
|
||||
|
||||
D) Concerns / questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as:
|
||||
- BLOCKER (must fix before merge)
|
||||
- IMPORTANT (should fix before merge)
|
||||
- NIT (optional)
|
||||
- For each: point to the file/area and propose a concrete fix or alternative.
|
||||
|
||||
E) Tests
|
||||
|
||||
- What exists.
|
||||
- What's missing (specific scenarios).
|
||||
|
||||
F) Follow-ups (optional)
|
||||
|
||||
- Non-blocking refactors/tickets to open later.
|
||||
|
||||
G) Suggested PR comment (optional)
|
||||
|
||||
- Offer: "Want me to draft a PR comment to the author?"
|
||||
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.
|
||||
|
||||
Rules / Guardrails
|
||||
|
||||
- Review only: do not merge (`gh pr merge`), do not push branches, do not edit code.
|
||||
- If you need clarification, ask questions rather than guessing.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Pre-commit hooks for openclaw
|
||||
# Install: prek install
|
||||
# Run manually: prek run --all-files
|
||||
#
|
||||
# See https://pre-commit.com for more information
|
||||
|
||||
repos:
|
||||
# Basic file hygiene
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||
- id: end-of-file-fixer
|
||||
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||
- id: check-yaml
|
||||
args: [--allow-multiple-documents]
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=500]
|
||||
- id: check-merge-conflict
|
||||
|
||||
# Secret detection (same as CI)
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
args:
|
||||
- --baseline
|
||||
- .secrets.baseline
|
||||
- --exclude-files
|
||||
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
|
||||
- --exclude-lines
|
||||
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
|
||||
- --exclude-lines
|
||||
- 'case \.apiKeyEnv: "API key \(env var\)"'
|
||||
- --exclude-lines
|
||||
- 'case apikey = "apiKey"'
|
||||
- --exclude-lines
|
||||
- '"gateway\.remote\.password"'
|
||||
- --exclude-lines
|
||||
- '"gateway\.auth\.password"'
|
||||
- --exclude-lines
|
||||
- '"talk\.apiKey"'
|
||||
- --exclude-lines
|
||||
- '=== "string"'
|
||||
- --exclude-lines
|
||||
- 'typeof remote\?\.password === "string"'
|
||||
|
||||
# Shell script linting
|
||||
- repo: https://github.com/koalaman/shellcheck-precommit
|
||||
rev: v0.11.0
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
args: [--severity=error] # Only fail on errors, not warnings/info
|
||||
# Exclude vendor and scripts with embedded code or known issues
|
||||
exclude: "^(vendor/|scripts/e2e/)"
|
||||
|
||||
# GitHub Actions linting
|
||||
- repo: https://github.com/rhysd/actionlint
|
||||
rev: v1.7.10
|
||||
hooks:
|
||||
- id: actionlint
|
||||
|
||||
# GitHub Actions security audit
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.22.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
|
||||
exclude: "^(vendor/|Swabble/)"
|
||||
|
||||
# Project checks (same commands as CI)
|
||||
- repo: local
|
||||
hooks:
|
||||
# oxlint --type-aware src test
|
||||
- id: oxlint
|
||||
name: oxlint
|
||||
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types_or: [javascript, jsx, ts, tsx]
|
||||
|
||||
# oxfmt --check src test
|
||||
- id: oxfmt
|
||||
name: oxfmt
|
||||
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types_or: [javascript, jsx, ts, tsx]
|
||||
|
||||
# swiftlint (same as CI)
|
||||
- id: swiftlint
|
||||
name: swiftlint
|
||||
entry: swiftlint --config .swiftlint.yml
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [swift]
|
||||
|
||||
# swiftformat --lint (same as CI)
|
||||
- id: swiftformat
|
||||
name: swiftformat
|
||||
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [swift]
|
||||
2191
.secrets.baseline
2191
.secrets.baseline
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
# ShellCheck configuration
|
||||
# https://www.shellcheck.net/wiki/
|
||||
|
||||
# Disable common false positives and style suggestions
|
||||
|
||||
# SC2034: Variable appears unused (often exported or used indirectly)
|
||||
disable=SC2034
|
||||
|
||||
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
|
||||
disable=SC2155
|
||||
|
||||
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
|
||||
disable=SC2295
|
||||
|
||||
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
|
||||
disable=SC1012
|
||||
|
||||
# SC2026: Word outside quotes (info-level, often intentional)
|
||||
disable=SC2026
|
||||
|
||||
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
|
||||
disable=SC2016
|
||||
|
||||
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
|
||||
disable=SC2129
|
||||
@@ -23,7 +23,7 @@
|
||||
# Whitespace
|
||||
--trimwhitespace always
|
||||
--emptybraces no-space
|
||||
--nospaceoperators ...,..<
|
||||
--nospaceoperators ...,..<
|
||||
--ranges no-space
|
||||
--someAny true
|
||||
--voidtype void
|
||||
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
||||
|
||||
@@ -18,7 +18,7 @@ excluded:
|
||||
- coverage
|
||||
- "*.playground"
|
||||
# Generated (protocol-gen-swift.ts)
|
||||
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
|
||||
- apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
|
||||
|
||||
analyzer_rules:
|
||||
- unused_declaration
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["oxc.oxc-vscode"]
|
||||
}
|
||||
22
.vscode/settings.json
vendored
22
.vscode/settings.json
vendored
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifierEnding": "js",
|
||||
"typescript.reportStyleChecksAsWarnings": false,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.experimental.useTsgo": true
|
||||
}
|
||||
152
AGENTS.md
152
AGENTS.md
@@ -1,143 +1,89 @@
|
||||
# Repository Guidelines
|
||||
|
||||
- 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".
|
||||
- Repo: https://github.com/clawdbot/clawdbot
|
||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
|
||||
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||
- Core channel docs: `docs/channels/`
|
||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||
- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors).
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
|
||||
- Docs are hosted on Mintlify (docs.openclaw.ai).
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- When working with documentation, read the mintlify skill.
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||
- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
|
||||
- When you touch docs, end the reply with the `https://docs.openclaw.ai/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub.
|
||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
## Docs i18n (zh-CN)
|
||||
|
||||
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
|
||||
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
|
||||
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
|
||||
- See `docs/.i18n/README.md`.
|
||||
- The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it.
|
||||
|
||||
## exe.dev VM ops (general)
|
||||
|
||||
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
||||
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
|
||||
- Update: `sudo npm i -g openclaw@latest` (global install needs root on `/usr/lib/node_modules`).
|
||||
- Config: use `openclaw config set ...`; ensure `gateway.mode=local` is set.
|
||||
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
|
||||
- Restart: stop old gateway and run:
|
||||
`pkill -9 -f openclaw-gateway || true; nohup openclaw gateway run --bind loopback --port 18789 --force > /tmp/openclaw-gateway.log 2>&1 &`
|
||||
- Verify: `openclaw channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/openclaw-gateway.log`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build`
|
||||
- TypeScript checks: `pnpm tsgo`
|
||||
- Lint/format: `pnpm check`
|
||||
- Format check: `pnpm format` (oxfmt --check)
|
||||
- Format fix: `pnpm format:fix` (oxfmt --write)
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- 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.
|
||||
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
|
||||
|
||||
## Release Channels (Naming)
|
||||
|
||||
- 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).
|
||||
- dev: moving head on `main` (no tag; git checkout main).
|
||||
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- 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`.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
- Full kit + what’s covered: `docs/testing.md`.
|
||||
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
|
||||
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Read this when submitting a PR: `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr))
|
||||
- Read this when submitting an issue: `docs/help/submitting-an-issue.md` ([Submitting an Issue](https://docs.openclaw.ai/help/submitting-an-issue))
|
||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
- 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.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
## Shorthand Commands
|
||||
|
||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** 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 lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
|
||||
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
|
||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
|
||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the OpenClaw subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/Clawdbot/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
@@ -150,30 +96,32 @@
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
||||
- Lint/format churn:
|
||||
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
||||
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
||||
- Only ask when changes are semantic (logic/data/behavior).
|
||||
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s 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 tool’s escaping.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
```bash
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdbot message send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
This is a Claude Code quirk, not a clawdbot bug.
|
||||
|
||||
1523
CHANGELOG.md
1523
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,11 @@
|
||||
# Contributing to OpenClaw
|
||||
# Contributing to Clawdbot
|
||||
|
||||
Welcome to the lobster tank! 🦞
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **GitHub:** https://github.com/openclaw/openclaw
|
||||
- **GitHub:** https://github.com/clawdbot/clawdbot
|
||||
- **Discord:** https://discord.gg/qkhbAGHRBT
|
||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw)
|
||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
|
||||
|
||||
## Maintainers
|
||||
|
||||
@@ -14,98 +13,30 @@ Welcome to the lobster tank! 🦞
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord + Slack subsystem
|
||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Vignesh** - Memory (QMD), formal modeling, TUI, and Lobster
|
||||
- GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh)
|
||||
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
- **Christoph Nakazawa** - JS Infra
|
||||
- GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa)
|
||||
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
|
||||
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
|
||||
|
||||
- **Maximilian Nussbaumer** - DevOps, CI, Code Sanity
|
||||
- GitHub: [@quotentiroler](https://github.com/quotentiroler) · X: [@quotentiroler](https://x.com/quotentiroler)
|
||||
|
||||
## 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
|
||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions) or ask in Discord first
|
||||
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
|
||||
- Test locally with your Clawdbot instance
|
||||
- Run linter: `npm run lint`
|
||||
- Keep PRs focused (one thing per PR)
|
||||
- Describe what & why
|
||||
|
||||
## Control UI Decorators
|
||||
|
||||
The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support
|
||||
`accessor` fields required for standard decorators). When adding reactive fields, keep the
|
||||
legacy style:
|
||||
|
||||
```ts
|
||||
@state() foo = "bar";
|
||||
@property({ type: Number }) count = 0;
|
||||
```
|
||||
|
||||
The root `tsconfig.json` is configured for legacy decorators (`experimentalDecorators: true`)
|
||||
with `useDefineForClassFields: false`. Avoid flipping these unless you are also updating the UI
|
||||
build tooling to support standard decorators.
|
||||
|
||||
## AI/Vibe-Coded PRs Welcome! 🤖
|
||||
|
||||
Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
|
||||
|
||||
Please include in your PR:
|
||||
|
||||
- [ ] Mark as AI-assisted in the PR title or description
|
||||
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||
|
||||
## Current Focus & Roadmap 🗺
|
||||
|
||||
We are currently prioritizing:
|
||||
|
||||
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
|
||||
- **UX**: Improving the onboarding wizard and error messages.
|
||||
- **Skills**: For skill contributions, head to [ClawHub](https://clawhub.ai/) — the community hub for OpenClaw skills.
|
||||
- **Performance**: Optimizing token usage and compaction logic.
|
||||
|
||||
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
|
||||
|
||||
## Report a Vulnerability
|
||||
|
||||
We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives:
|
||||
|
||||
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
|
||||
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
|
||||
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
|
||||
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
|
||||
- **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** and we'll route it.
|
||||
|
||||
### Required in Reports
|
||||
|
||||
1. **Title**
|
||||
2. **Severity Assessment**
|
||||
3. **Impact**
|
||||
4. **Affected Component**
|
||||
5. **Technical Reproduction**
|
||||
6. **Demonstrated Impact**
|
||||
7. **Environment**
|
||||
8. **Remediation Advice**
|
||||
|
||||
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.
|
||||
|
||||
25
Dockerfile
25
Dockerfile
@@ -8,10 +8,10 @@ RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
|
||||
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
ARG CLAWDBOT_DOCKER_APT_PACKAGES=""
|
||||
RUN if [ -n "$CLAWDBOT_DOCKER_APT_PACKAGES" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $CLAWDBOT_DOCKER_APT_PACKAGES && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
@@ -25,24 +25,9 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm ui:install
|
||||
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
|
||||
USER node
|
||||
|
||||
# Start gateway server with default config.
|
||||
# Binds to loopback (127.0.0.1) by default for security.
|
||||
#
|
||||
# For container platforms requiring external health checks:
|
||||
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
|
||||
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
|
||||
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
@@ -13,8 +13,4 @@ RUN apt-get update \
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
|
||||
@@ -20,13 +20,9 @@ RUN apt-get update \
|
||||
xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
|
||||
RUN chmod +x /usr/local/bin/openclaw-sandbox-browser
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdbot-sandbox-browser
|
||||
RUN chmod +x /usr/local/bin/clawdbot-sandbox-browser
|
||||
|
||||
EXPOSE 9222 5900 6080
|
||||
|
||||
CMD ["openclaw-sandbox-browser"]
|
||||
CMD ["clawdbot-sandbox-browser"]
|
||||
|
||||
1
Peekaboo
Submodule
1
Peekaboo
Submodule
Submodule Peekaboo added at c1243a7978
BIN
README-header.png
Normal file
BIN
README-header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
464
README.md
464
README.md
@@ -1,10 +1,7 @@
|
||||
# 🦞 OpenClaw — Personal AI Assistant
|
||||
# 🦞 Clawdbot — Personal AI Assistant
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
|
||||
</picture>
|
||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="Clawdbot" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -12,45 +9,43 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/openclaw/openclaw/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/openclaw/openclaw/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/openclaw/openclaw/releases"><img src="https://img.shields.io/github/v/release/openclaw/openclaw?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
[Website](https://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)
|
||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
Preferred setup: run the onboarding wizard (`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)**.
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
|
||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover)
|
||||
- Models config + CLI: [Models](https://docs.clawd.bot/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/concepts/model-failover)
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
# or: pnpm add -g openclaw@latest
|
||||
npm install -g clawdbot@latest
|
||||
# or: pnpm add -g clawdbot@latest
|
||||
|
||||
openclaw onboard --install-daemon
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
@@ -59,128 +54,112 @@ The wizard installs the Gateway daemon (launchd/systemd user service) so it stay
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
```bash
|
||||
openclaw onboard --install-daemon
|
||||
clawdbot onboard --install-daemon
|
||||
|
||||
openclaw gateway --port 18789 --verbose
|
||||
clawdbot gateway --port 18789 --verbose
|
||||
|
||||
# Send a message
|
||||
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||
openclaw agent --message "Ship checklist" --thinking high
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`).
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
pnpm clawdbot onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
|
||||
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
OpenClaw connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
|
||||
Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
|
||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `openclaw pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||
|
||||
Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
|
||||
[](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.clawd.bot/tools/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/start/wizard), and [doctor](https://docs.clawd.bot/gateway/doctor).
|
||||
- [Pi agent runtime](https://docs.clawd.bot/concepts/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||
|
||||
- [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/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/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
|
||||
### Providers
|
||||
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/providers/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
|
||||
|
||||
### Apps + nodes
|
||||
|
||||
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
|
||||
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
|
||||
- [iOS node](https://docs.clawd.bot/platforms/ios): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Voice Wake](https://docs.clawd.bot/nodes/voicewake), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.clawd.bot/platforms/android): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
|
||||
- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
- [Browser control](https://docs.clawd.bot/tools/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.clawd.bot/platforms/mac/canvas): [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.clawd.bot/automation/cron-jobs); [webhooks](https://docs.clawd.bot/automation/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
|
||||
- [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).
|
||||
- [Provider routing](https://docs.clawd.bot/concepts/provider-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
||||
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
|
||||
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
|
||||
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/providers/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
|
||||
- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs.
|
||||
- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging).
|
||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/gateway/tailscale) or [SSH tunnels](https://docs.clawd.bot/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.clawd.bot/install/nix) for declarative config; [Docker](https://docs.clawd.bot/install/docker)-based installs.
|
||||
- [Doctor](https://docs.clawd.bot/gateway/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
@@ -190,7 +169,7 @@ WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBu
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (openclaw …)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS / Android nodes
|
||||
@@ -198,39 +177,38 @@ WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBu
|
||||
|
||||
## Key subsystems
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)).
|
||||
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
|
||||
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
- **[Gateway WebSocket network](https://docs.clawd.bot/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
|
||||
- **[Tailscale exposure](https://docs.clawd.bot/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/gateway/remote)).
|
||||
- **[Browser control](https://docs.clawd.bot/tools/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.clawd.bot/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
|
||||
OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
|
||||
Notes:
|
||||
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this).
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web)
|
||||
Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the exec tool and channel connections by default.
|
||||
- **Gateway host** runs the exec tool and provider connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security)
|
||||
Details: [Remote access](https://docs.clawd.bot/gateway/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
@@ -245,33 +223,33 @@ Elevated bash (host permissions) is separate from macOS TCC:
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture)
|
||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/platforms/macos) · [Gateway protocol](https://docs.clawd.bot/concepts/architecture)
|
||||
|
||||
## Agent to Agent (sessions\_\* tools)
|
||||
## Agent to Agent (sessions_* tools)
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool)
|
||||
Details: [Session tools](https://docs.clawd.bot/concepts/session-tool)
|
||||
|
||||
## Skills registry (ClawHub)
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
[ClawHub](https://clawhub.com)
|
||||
[ClawdHub](https://ClawdHub.com)
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/usage off|tokens|full` — per-response usage footer
|
||||
- `/cost on|off` — append per-response token/cost usage lines
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
@@ -279,9 +257,14 @@ Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
If you plan to build/run companion apps, initialize submodules first:
|
||||
|
||||
### macOS (OpenClaw.app) (optional)
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### macOS (Clawdbot.app) (optional)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
@@ -294,35 +277,35 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
|
||||
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `openclaw nodes …`.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
||||
Runbook: [iOS connect](https://docs.clawd.bot/platforms/ios).
|
||||
|
||||
### Android node (optional)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
||||
- Runbook: [Android connect](https://docs.clawd.bot/platforms/android).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
|
||||
- Workspace root: `~/clawd` (configurable via `agents.defaults.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/.openclaw/workspace/skills/<skill>/SKILL.md`.
|
||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
},
|
||||
model: "anthropic/claude-opus-4-5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
|
||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/gateway/configuration)
|
||||
|
||||
## Security model (important)
|
||||
|
||||
@@ -330,69 +313,59 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration)
|
||||
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
|
||||
### [WhatsApp](https://docs.clawd.bot/providers/whatsapp)
|
||||
|
||||
- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`).
|
||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
- Link the device: `pnpm clawdbot providers login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.openclaw.ai/channels/telegram)
|
||||
### [Telegram](https://docs.clawd.bot/providers/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed.
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF",
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
### [Slack](https://docs.clawd.bot/providers/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
### [Discord](https://docs.clawd.bot/providers/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
token: "1234abcd",
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.openclaw.ai/channels/signal)
|
||||
### [Signal](https://docs.clawd.bot/providers/signal)
|
||||
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
- Requires `signal-cli` and a `signal` config section.
|
||||
|
||||
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
|
||||
### [iMessage](https://docs.clawd.bot/providers/imessage)
|
||||
|
||||
- **Recommended** iMessage integration.
|
||||
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
|
||||
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
|
||||
|
||||
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
|
||||
### [Microsoft Teams](https://docs.clawd.bot/providers/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### [WebChat](https://docs.openclaw.ai/web/webchat)
|
||||
### [WebChat](https://docs.clawd.bot/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
@@ -402,148 +375,113 @@ Browser control (optional):
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
color: "#FF4500",
|
||||
},
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
color: "#FF4500"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.openclaw.ai)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security)
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/gateway/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/providers/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.openclaw.ai/web/control-ui)
|
||||
- [Dashboard](https://docs.openclaw.ai/web/dashboard)
|
||||
- [Discovery + transports](https://docs.clawd.bot/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.clawd.bot/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.clawd.bot/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.clawd.bot/web/control-ui)
|
||||
- [Dashboard](https://docs.clawd.bot/web/dashboard)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.openclaw.ai/gateway/health)
|
||||
- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock)
|
||||
- [Background process](https://docs.openclaw.ai/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.openclaw.ai/logging)
|
||||
- [Health checks](https://docs.clawd.bot/gateway/health)
|
||||
- [Gateway lock](https://docs.clawd.bot/gateway/gateway-lock)
|
||||
- [Background process](https://docs.clawd.bot/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.clawd.bot/logging)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox)
|
||||
- [RPC adapters](https://docs.openclaw.ai/reference/rpc)
|
||||
- [Queue](https://docs.openclaw.ai/concepts/queue)
|
||||
- [Agent loop](https://docs.clawd.bot/concepts/agent-loop)
|
||||
- [Presence](https://docs.clawd.bot/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.clawd.bot/concepts/typebox)
|
||||
- [RPC adapters](https://docs.clawd.bot/reference/rpc)
|
||||
- [Queue](https://docs.clawd.bot/concepts/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.openclaw.ai/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER)
|
||||
- [Skills config](https://docs.clawd.bot/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.clawd.bot/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.clawd.bot/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.clawd.bot/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.clawd.bot/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.clawd.bot/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.clawd.bot/reference/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios)
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows)
|
||||
- [Linux app](https://docs.openclaw.ai/platforms/linux)
|
||||
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.clawd.bot/platforms/ios)
|
||||
- [Android node](https://docs.clawd.bot/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
|
||||
- [Linux app](https://docs.clawd.bot/platforms/linux)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||
|
||||
## Molty
|
||||
## Clawd
|
||||
|
||||
OpenClaw was built for **Molty**, a space lobster AI assistant. 🦞
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
|
||||
- [openclaw.ai](https://openclaw.ai)
|
||||
- [clawd.me](https://clawd.me)
|
||||
- [soul.md](https://soul.md)
|
||||
- [steipete.me](https://steipete.me)
|
||||
- [@openclaw](https://x.com/openclaw)
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
Special thanks to Adam Doppelt for lobster.bot.
|
||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||
|
||||
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/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>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
|
||||
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
|
||||
<a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
|
||||
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
|
||||
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
|
||||
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
|
||||
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
|
||||
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a>
|
||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a>
|
||||
<a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
99
SECURITY.md
99
SECURITY.md
@@ -1,99 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
If you believe you've found a security issue in OpenClaw, please report it privately.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report vulnerabilities directly to the repository where the issue lives:
|
||||
|
||||
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
|
||||
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
|
||||
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
|
||||
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
|
||||
- **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** and we'll route it.
|
||||
|
||||
For full reporting instructions see our [Trust page](https://trust.openclaw.ai).
|
||||
|
||||
### Required in Reports
|
||||
|
||||
1. **Title**
|
||||
2. **Severity Assessment**
|
||||
3. **Impact**
|
||||
4. **Affected Component**
|
||||
5. **Technical Reproduction**
|
||||
6. **Demonstrated Impact**
|
||||
7. **Environment**
|
||||
8. **Remediation Advice**
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Bug Bounties
|
||||
|
||||
OpenClaw is a labor of love. There is no bug bounty program and no budget for paid reports. Please still disclose responsibly so we can fix issues quickly.
|
||||
The best way to help the project right now is by sending PRs.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Public Internet Exposure
|
||||
- Using OpenClaw in ways that the docs recommend not to
|
||||
- Prompt injection attacks
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see:
|
||||
|
||||
- `https://docs.openclaw.ai/gateway/security`
|
||||
|
||||
### Web Interface Safety
|
||||
|
||||
OpenClaw's web interface is intended for local use only. Do **not** bind it to the public internet; it is not hardened for public exposure.
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
### Node.js Version
|
||||
|
||||
OpenClaw requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches:
|
||||
|
||||
- CVE-2025-59466: async_hooks DoS vulnerability
|
||||
- CVE-2026-21636: Permission model bypass vulnerability
|
||||
|
||||
Verify your Node.js version:
|
||||
|
||||
```bash
|
||||
node --version # Should be v22.12.0 or later
|
||||
```
|
||||
|
||||
### Docker Security
|
||||
|
||||
When running OpenClaw in Docker:
|
||||
|
||||
1. The official image runs as a non-root user (`node`) for reduced attack surface
|
||||
2. Use `--read-only` flag when possible for additional filesystem protection
|
||||
3. Limit container capabilities with `--cap-drop=ALL`
|
||||
|
||||
Example secure Docker run:
|
||||
|
||||
```bash
|
||||
docker run --read-only --cap-drop=ALL \
|
||||
-v openclaw-data:/app/data \
|
||||
openclaw/openclaw:latest
|
||||
```
|
||||
|
||||
## Security Scanning
|
||||
|
||||
This project uses `detect-secrets` for automated secret detection in CI/CD.
|
||||
See `.detect-secrets.cfg` for configuration and `.secrets.baseline` for the baseline.
|
||||
|
||||
Run locally:
|
||||
|
||||
```bash
|
||||
pip install detect-secrets==1.5.0
|
||||
detect-secrets scan --baseline .secrets.baseline
|
||||
```
|
||||
@@ -1,33 +1,15 @@
|
||||
{
|
||||
"originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72",
|
||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -45,24 +27,6 @@
|
||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||
"version" : "0.99.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||
"state" : {
|
||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textual",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/textual",
|
||||
"state" : {
|
||||
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
|
||||
"version" : "0.3.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -13,7 +13,7 @@ let package = Package(
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
@@ -101,8 +101,8 @@ Environment variables:
|
||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||
|
||||
## Development
|
||||
- Format: `./scripts/format.sh` (uses local `.swiftformat`)
|
||||
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
|
||||
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
|
||||
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
|
||||
- Tests: `swift test` (uses swift-testing package)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -34,7 +34,8 @@ extension AttributedString {
|
||||
var ranges: [Range<AttributedString.Index>] = []
|
||||
for wordRange in wordRanges {
|
||||
if let lastRange = ranges.last,
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||
{
|
||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||
} else {
|
||||
ranges.append(wordRange)
|
||||
|
||||
@@ -13,7 +13,8 @@ public actor TranscriptsStore {
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||
if let data = try? Data(contentsOf: fileURL),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ public struct WakeWordGateConfig: Sendable, Equatable {
|
||||
public init(
|
||||
triggers: [String],
|
||||
minPostTriggerGap: TimeInterval = 0.45,
|
||||
minCommandLength: Int = 1) {
|
||||
minCommandLength: Int = 1)
|
||||
{
|
||||
self.triggers = triggers
|
||||
self.minPostTriggerGap = minPostTriggerGap
|
||||
self.minCommandLength = minCommandLength
|
||||
@@ -56,12 +57,6 @@ public enum WakeWordGate {
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
private struct MatchCandidate {
|
||||
let index: Int
|
||||
let triggerEnd: TimeInterval
|
||||
let gap: TimeInterval
|
||||
}
|
||||
|
||||
public static func match(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
@@ -73,7 +68,7 @@ public enum WakeWordGate {
|
||||
let tokens = normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: MatchCandidate?
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
@@ -89,7 +84,7 @@ public enum WakeWordGate {
|
||||
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
|
||||
best = (i, triggerEnd, gap)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ enum CLIRegistry {
|
||||
subcommands: [
|
||||
descriptor(for: ServiceInstall.self),
|
||||
descriptor(for: ServiceUninstall.self),
|
||||
descriptor(for: ServiceStatus.self)
|
||||
descriptor(for: ServiceStatus.self),
|
||||
])
|
||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||
let setupDesc = descriptor(for: SetupCommand.self)
|
||||
@@ -54,7 +54,7 @@ enum CLIRegistry {
|
||||
startDesc,
|
||||
stopDesc,
|
||||
restartDesc,
|
||||
statusDesc
|
||||
statusDesc,
|
||||
])
|
||||
return [root]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
|
||||
"Label": label,
|
||||
"ProgramArguments": [executable, "serve"],
|
||||
"RunAtLoad": true,
|
||||
"KeepAlive": true
|
||||
"KeepAlive": true,
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: plistURL)
|
||||
|
||||
@@ -25,123 +25,78 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
||||
|
||||
switch first {
|
||||
case "swabble":
|
||||
try await dispatchSwabble(parsed: parsed, path: path)
|
||||
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||
let sub = path[1]
|
||||
switch sub {
|
||||
case "serve":
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "transcribe":
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "test-hook":
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "mic":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||
let micSub = path[2]
|
||||
if micSub == "list" {
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else if micSub == "set" {
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
case "service":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||
let svcSub = path[2]
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
case "doctor":
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "setup":
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "health":
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "tail-log":
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "start":
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
case "stop":
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
case "restart":
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
default:
|
||||
throw CommanderProgramError.unknownCommand(first)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
|
||||
let sub = try subcommand(path, index: 1, command: "swabble")
|
||||
switch sub {
|
||||
case "mic":
|
||||
try await dispatchMic(parsed: parsed, path: path)
|
||||
case "service":
|
||||
try await dispatchService(path: path)
|
||||
default:
|
||||
let handlers = swabbleHandlers(parsed: parsed)
|
||||
guard let handler = handlers[sub] else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
try await handler()
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
|
||||
[
|
||||
"serve": {
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"transcribe": {
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"test-hook": {
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"doctor": {
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"setup": {
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"health": {
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"tail-log": {
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"start": {
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"stop": {
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"restart": {
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"status": {
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
|
||||
let micSub = try subcommand(path, index: 2, command: "mic")
|
||||
switch micSub {
|
||||
case "list":
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "set":
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchService(path: [String]) async throws {
|
||||
let svcSub = try subcommand(path, index: 2, command: "service")
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
}
|
||||
|
||||
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
|
||||
guard path.count > index else {
|
||||
throw CommanderProgramError.missingSubcommand(command: command)
|
||||
}
|
||||
return path[index]
|
||||
}
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CONFIG="${ROOT}/.swiftformat"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
|
||||
else
|
||||
CONFIG="${ROOT}/.swiftformat"
|
||||
fi
|
||||
swiftformat --config "$CONFIG" "$ROOT/Sources"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CONFIG="${ROOT}/.swiftlint.yml"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
|
||||
else
|
||||
CONFIG="$ROOT/.swiftlint.yml"
|
||||
fi
|
||||
if ! command -v swiftlint >/dev/null; then
|
||||
echo "swiftlint not installed" >&2
|
||||
exit 1
|
||||
|
||||
212
appcast.xml
212
appcast.xml
@@ -1,208 +1,56 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.2.12</title>
|
||||
<pubDate>Fri, 13 Feb 2026 03:17:54 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9500</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.12</sparkle:shortVersionString>
|
||||
<title>2026.1.11-3</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5212</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.12</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: add <code>openclaw logs --local-time</code> to display log timestamps in local timezone. (#13818) Thanks @xialonglee.</li>
|
||||
<li>Telegram: render blockquotes as native <code><blockquote></code> tags instead of stripping them. (#14608)</li>
|
||||
<li>Config: avoid redacting <code>maxTokens</code>-like fields during config snapshot redaction, preventing round-trip validation failures in <code>/config</code>. (#14006) Thanks @constansino.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Hooks: <code>POST /hooks/agent</code> now rejects payload <code>sessionKey</code> overrides by default. To keep fixed hook context, set <code>hooks.defaultSessionKey</code> (recommended with <code>hooks.allowedSessionKeyPrefixes: ["hook:"]</code>). If you need legacy behavior, explicitly set <code>hooks.allowRequestSessionKey: true</code>. Thanks @alpernae for reporting.</li>
|
||||
</ul>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Gateway/OpenResponses: harden URL-based <code>input_file</code>/<code>input_image</code> handling with explicit SSRF deny policy, hostname allowlists (<code>files.urlAllowlist</code> / <code>images.urlAllowlist</code>), per-request URL input caps (<code>maxUrlParts</code>), blocked-fetch audit logging, and regression coverage/docs updates.</li>
|
||||
<li>Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.</li>
|
||||
<li>Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.</li>
|
||||
<li>Security/Audit: add hook session-routing hardening checks (<code>hooks.defaultSessionKey</code>, <code>hooks.allowRequestSessionKey</code>, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.</li>
|
||||
<li>Security/Sandbox: confine mirrored skill sync destinations to the sandbox <code>skills/</code> root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.</li>
|
||||
<li>Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip <code>toolResult.details</code> from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.</li>
|
||||
<li>Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (<code>429</code> + <code>Retry-After</code>). Thanks @akhmittra.</li>
|
||||
<li>Security/Browser: require auth for loopback browser control HTTP routes, auto-generate <code>gateway.auth.token</code> when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle.</li>
|
||||
<li>Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.</li>
|
||||
<li>Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.</li>
|
||||
<li>Logging/CLI: use local timezone timestamps for console prefixing, and include <code>±HH:MM</code> offsets when using <code>openclaw logs --local-time</code> to avoid ambiguity. (#14771) Thanks @0xRaini.</li>
|
||||
<li>Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.</li>
|
||||
<li>Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.</li>
|
||||
<li>Gateway: prevent <code>undefined</code>/missing token in auth config. (#13809) Thanks @asklee-klawd.</li>
|
||||
<li>Gateway: handle async <code>EPIPE</code> on stdout/stderr during shutdown. (#13414) Thanks @keshav55.</li>
|
||||
<li>Gateway/Control UI: resolve missing dashboard assets when <code>openclaw</code> is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.</li>
|
||||
<li>Cron: use requested <code>agentId</code> for isolated job auth resolution. (#13983) Thanks @0xRaini.</li>
|
||||
<li>Cron: prevent cron jobs from skipping execution when <code>nextRunAtMs</code> advances. (#14068) Thanks @WalterSumbon.</li>
|
||||
<li>Cron: pass <code>agentId</code> to <code>runHeartbeatOnce</code> for main-session jobs. (#14140) Thanks @ishikawa-pro.</li>
|
||||
<li>Cron: re-arm timers when <code>onTimer</code> fires while a job is still executing. (#14233) Thanks @tomron87.</li>
|
||||
<li>Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.</li>
|
||||
<li>Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.</li>
|
||||
<li>Cron: prevent one-shot <code>at</code> jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.</li>
|
||||
<li>Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after <code>requests-in-flight</code> skips. (#14901) Thanks @joeykrug.</li>
|
||||
<li>Cron: honor stored session model overrides for isolated-agent runs while preserving <code>hooks.gmail.model</code> precedence for Gmail hook sessions. (#14983) Thanks @shtse8.</li>
|
||||
<li>Logging/Browser: fall back to <code>os.tmpdir()/openclaw</code> for default log, browser trace, and browser download temp paths when <code>/tmp/openclaw</code> is unavailable.</li>
|
||||
<li>WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10.</li>
|
||||
<li>WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib.</li>
|
||||
<li>WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr.</li>
|
||||
<li>Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.</li>
|
||||
<li>Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.</li>
|
||||
<li>BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.</li>
|
||||
<li>Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.</li>
|
||||
<li>Slack: detect control commands when channel messages start with bot mention prefixes (for example, <code>@Bot /new</code>). (#14142) Thanks @beefiker.</li>
|
||||
<li>Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.</li>
|
||||
<li>Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.</li>
|
||||
<li>Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.</li>
|
||||
<li>Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.</li>
|
||||
<li>Signal: render mention placeholders as <code>@uuid</code>/<code>@phone</code> so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.</li>
|
||||
<li>Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.</li>
|
||||
<li>Onboarding/Providers: add Z.AI endpoint-specific auth choices (<code>zai-coding-global</code>, <code>zai-coding-cn</code>, <code>zai-global</code>, <code>zai-cn</code>) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.</li>
|
||||
<li>Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include <code>minimax-m2.5</code> in modern model filtering. (#14865) Thanks @adao-max.</li>
|
||||
<li>Ollama: use configured <code>models.providers.ollama.baseUrl</code> for model discovery and normalize <code>/v1</code> endpoints to the native Ollama API root. (#14131) Thanks @shtse8.</li>
|
||||
<li>Voice Call: pass Twilio stream auth token via <code><Parameter></code> instead of query string. (#14029) Thanks @mcwigglesmcgee.</li>
|
||||
<li>Feishu: pass <code>Buffer</code> directly to the Feishu SDK upload APIs instead of <code>Readable.from(...)</code> to avoid form-data upload failures. (#10345) Thanks @youngerstyle.</li>
|
||||
<li>Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.</li>
|
||||
<li>Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.</li>
|
||||
<li>Feishu DocX: preserve top-level converted block order using <code>firstLevelBlockIds</code> when writing/appending documents. (#13994) Thanks @Cynosure159.</li>
|
||||
<li>Feishu plugin packaging: remove <code>workspace:*</code> <code>openclaw</code> dependency from <code>extensions/feishu</code> and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.</li>
|
||||
<li>CLI/Wizard: exit with code 1 when <code>configure</code>, <code>agents add</code>, or interactive <code>onboard</code> wizards are canceled, so <code>set -e</code> automation stops correctly. (#14156) Thanks @0xRaini.</li>
|
||||
<li>Media: strip <code>MEDIA:</code> lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini.</li>
|
||||
<li>Config/Cron: exclude <code>maxTokens</code> from config redaction and honor <code>deleteAfterRun</code> on skipped cron jobs. (#13342) Thanks @niceysam.</li>
|
||||
<li>Config: ignore <code>meta</code> field changes in config file watcher. (#13460) Thanks @brandonwise.</li>
|
||||
<li>Cron: use requested <code>agentId</code> for isolated job auth resolution. (#13983) Thanks @0xRaini.</li>
|
||||
<li>Cron: pass <code>agentId</code> to <code>runHeartbeatOnce</code> for main-session jobs. (#14140) Thanks @ishikawa-pro.</li>
|
||||
<li>Cron: prevent cron jobs from skipping execution when <code>nextRunAtMs</code> advances. (#14068) Thanks @WalterSumbon.</li>
|
||||
<li>Cron: re-arm timers when <code>onTimer</code> fires while a job is still executing. (#14233) Thanks @tomron87.</li>
|
||||
<li>Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.</li>
|
||||
<li>Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.</li>
|
||||
<li>Cron: prevent one-shot <code>at</code> jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.</li>
|
||||
<li>Daemon: suppress <code>EPIPE</code> error when restarting LaunchAgent. (#14343) Thanks @0xRaini.</li>
|
||||
<li>Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic.</li>
|
||||
<li>Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26.</li>
|
||||
<li>Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002.</li>
|
||||
<li>Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi.</li>
|
||||
<li>Agents: keep followup-runner session <code>totalTokens</code> aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.</li>
|
||||
<li>Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.</li>
|
||||
<li>Hooks/Tools: dispatch <code>before_tool_call</code> and <code>after_tool_call</code> hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.</li>
|
||||
<li>Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.</li>
|
||||
<li>Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.</li>
|
||||
<li>Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.</li>
|
||||
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
|
||||
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.12/OpenClaw-2026.2.12.zip" length="22877692" type="application/octet-stream" sparkle:edSignature="TGylTM4/7Lab+qp1nuPeOAmEVV1WkafXUPub8ws0z/0mYfbVygRuiev+u3zdPjQWhLnGYTgRgKVyW+kB2+Q2BQ=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.9</title>
|
||||
<pubDate>Mon, 09 Feb 2026 13:23:25 -0600</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9194</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.9</sparkle:shortVersionString>
|
||||
<title>2026.1.11-2</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5210</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.9</h2>
|
||||
<h3>Added</h3>
|
||||
<ul>
|
||||
<li>iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.</li>
|
||||
<li>Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.</li>
|
||||
<li>Plugins: device pairing + phone control plugins (Telegram <code>/pair</code>, iOS/Android node controls). (#11755) Thanks @mbelinky.</li>
|
||||
<li>Tools: add Grok (xAI) as a <code>web_search</code> provider. (#12419) Thanks @tmchow.</li>
|
||||
<li>Gateway: add agent management RPC methods for the web UI (<code>agents.create</code>, <code>agents.update</code>, <code>agents.delete</code>). (#11045) Thanks @advaitpaliwal.</li>
|
||||
<li>Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.</li>
|
||||
<li>Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.</li>
|
||||
<li>Paths: add <code>OPENCLAW_HOME</code> for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.</li>
|
||||
</ul>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.</li>
|
||||
<li>Telegram: recover proactive sends when stale topic thread IDs are used by retrying without <code>message_thread_id</code>. (#11620)</li>
|
||||
<li>Telegram: render markdown spoilers with <code><tg-spoiler></code> HTML tags. (#11543) Thanks @ezhikkk.</li>
|
||||
<li>Telegram: truncate command registration to 100 entries to avoid <code>BOT_COMMANDS_TOO_MUCH</code> failures on startup. (#12356) Thanks @arosstale.</li>
|
||||
<li>Telegram: match DM <code>allowFrom</code> against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).</li>
|
||||
<li>Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.</li>
|
||||
<li>Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.</li>
|
||||
<li>Tools/web_search: include provider-specific settings in the web search cache key, and pass <code>inlineCitations</code> for Grok. (#12419) Thanks @tmchow.</li>
|
||||
<li>Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.</li>
|
||||
<li>Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.</li>
|
||||
<li>Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.</li>
|
||||
<li>Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session <code>parentId</code> chain so agents can remember again. (#12283) Thanks @Takhoffman.</li>
|
||||
<li>Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.</li>
|
||||
<li>Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.</li>
|
||||
<li>Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.</li>
|
||||
<li>Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.</li>
|
||||
<li>Cron tool: recover flat params when LLM omits the <code>job</code> wrapper for add requests. (#12124) Thanks @tyler6204.</li>
|
||||
<li>Gateway/CLI: when <code>gateway.bind=lan</code>, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.</li>
|
||||
<li>Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.</li>
|
||||
<li>Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.</li>
|
||||
<li>Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.</li>
|
||||
<li>Config: clamp <code>maxTokens</code> to <code>contextWindow</code> to prevent invalid model configs. (#5516) Thanks @lailoo.</li>
|
||||
<li>Thinking: allow xhigh for <code>github-copilot/gpt-5.2-codex</code> and <code>github-copilot/gpt-5.2</code>. (#11646) Thanks @LatencyTDH.</li>
|
||||
<li>Discord: support forum/media thread-create starter messages, wire <code>message thread create --message</code>, and harden routing. (#10062) Thanks @jarvis89757.</li>
|
||||
<li>Paths: structurally resolve <code>OPENCLAW_HOME</code>-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.</li>
|
||||
<li>Memory: set Voyage embeddings <code>input_type</code> for improved retrieval. (#10818) Thanks @mcinteerj.</li>
|
||||
<li>Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.</li>
|
||||
<li>Media understanding: recognize <code>.caf</code> audio attachments for transcription. (#10982) Thanks @succ985.</li>
|
||||
<li>State dir: honor <code>OPENCLAW_STATE_DIR</code> for default device identity and canvas storage paths. (#4824) Thanks @kossoy.</li>
|
||||
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li>
|
||||
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.9/OpenClaw-2026.2.9.zip" length="22872529" type="application/octet-stream" sparkle:edSignature="zvgwqlgqI7J5Gsi9VSULIQTMKqLiGE5ulC6NnRLKtOPphQsHZVdYSWm0E90+Yq8mG4lpsvbxQOSSPxpl43QTAw=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.3</title>
|
||||
<pubDate>Wed, 04 Feb 2026 17:47:10 -0800</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8900</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.3</sparkle:shortVersionString>
|
||||
<title>2026.1.11-1</title>
|
||||
<pubDate>Mon, 12 Jan 2026 09:53:46 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5207</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.3</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Telegram: remove last <code>@ts-nocheck</code> from <code>bot-handlers.ts</code>, use Grammy types directly, deduplicate <code>StickerMetadata</code>. Zero <code>@ts-nocheck</code> remaining in <code>src/telegram/</code>. (#9206)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot-message.ts</code>, type deps via <code>Omit<BuildTelegramMessageContextParams></code>, widen <code>allMedia</code> to <code>TelegramMediaRef[]</code>. (#9180)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot.ts</code>, fix duplicate <code>bot.catch</code> error handler (Grammy overrides), remove dead reaction <code>message_thread_id</code> routing, harden sticker cache guard. (#9077)</li>
|
||||
<li>Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.</li>
|
||||
<li>Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.</li>
|
||||
<li>Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.</li>
|
||||
<li>Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.</li>
|
||||
<li>Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.</li>
|
||||
<li>Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.</li>
|
||||
<li>Cron: default isolated jobs to announce delivery; accept ISO 8601 <code>schedule.at</code> in tool inputs.</li>
|
||||
<li>Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and <code>atMs</code> inputs.</li>
|
||||
<li>Cron: delete one-shot jobs after success by default; add <code>--keep-after-run</code> for CLI.</li>
|
||||
<li>Cron: suppress messaging tools during announce delivery so summaries post consistently.</li>
|
||||
<li>Cron: avoid duplicate deliveries when isolated runs send messages directly.</li>
|
||||
</ul>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-1</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.</li>
|
||||
<li>TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.</li>
|
||||
<li>Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.</li>
|
||||
<li>Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.</li>
|
||||
<li>Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman.</li>
|
||||
<li>Web UI: resolve header logo path when <code>gateway.controlUi.basePath</code> is set. (#7178) Thanks @Yeom-JinHo.</li>
|
||||
<li>Web UI: apply button styling to the new-messages indicator.</li>
|
||||
<li>Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.</li>
|
||||
<li>Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.</li>
|
||||
<li>Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.</li>
|
||||
<li>Security: gate <code>whatsapp_login</code> tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.</li>
|
||||
<li>Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.</li>
|
||||
<li>Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.</li>
|
||||
<li>Cron: accept epoch timestamps and 0ms durations in CLI <code>--at</code> parsing.</li>
|
||||
<li>Cron: reload store data when the store file is recreated or mtime changes.</li>
|
||||
<li>Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.</li>
|
||||
<li>Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.</li>
|
||||
<li>macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.</li>
|
||||
<li>Installer: include <code>patches/</code> in the npm package so postinstall patching works for npm/bun installs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.3/OpenClaw-2026.2.3.zip" length="22530161" type="application/octet-stream" sparkle:edSignature="7eHUaQC6cx87HWbcaPh9T437+LqfE9VtQBf4p9JBjIyBrqGYxxp9KPvI5unEjg55j9j2djCXhseSMeyyRmvYBg=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-1/Clawdbot-2026.1.11-1.zip" length="19860761" type="application/octet-stream" sparkle:edSignature="CXKzzha/s6cGBeF0TMz+cV8/pfqoAL9ZyNVacYRLnnHEwA1cMbOWRftpGRhYe4HknVQYYBgNQqZK2lBxpOZgBg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -1,6 +1,6 @@
|
||||
## OpenClaw Node (Android) (internal)
|
||||
## Clawdbot Node (Android) (internal)
|
||||
|
||||
Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||
|
||||
Notes:
|
||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||
@@ -25,20 +25,20 @@ cd apps/android
|
||||
|
||||
1) Start the gateway (on your “master” machine):
|
||||
```bash
|
||||
pnpm openclaw gateway --port 18789 --verbose
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
2) In the Android app:
|
||||
- Open **Settings**
|
||||
- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port).
|
||||
- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).
|
||||
|
||||
3) Approve pairing (on the gateway machine):
|
||||
```bash
|
||||
openclaw nodes pending
|
||||
openclaw nodes approve <requestId>
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
More details: `docs/platforms/android.md`.
|
||||
More details: `docs/android/connect.md`.
|
||||
|
||||
## Permissions
|
||||
|
||||
|
||||
@@ -8,21 +8,21 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.android"
|
||||
namespace = "com.clawdbot.android"
|
||||
compileSdk = 36
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources"))
|
||||
assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources"))
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ai.openclaw.android"
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602130
|
||||
versionName = "2026.2.13"
|
||||
versionCode = 202601114
|
||||
versionName = "2026.1.11-4"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -65,7 +65,7 @@ androidComponents {
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
|
||||
val outputFileName = "openclaw-${versionName}-${buildType}.apk"
|
||||
val outputFileName = "clawdbot-${versionName}-${buildType}.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,6 @@ dependencies {
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
@@ -113,14 +112,14 @@ dependencies {
|
||||
implementation("androidx.camera:camera-view:1.5.2")
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.4")
|
||||
implementation("dnsjava:dnsjava:3.6.3")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
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")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.OpenClawNode">
|
||||
android:theme="@style/Theme.ClawdbotNode">
|
||||
<service
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package ai.openclaw.android
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.util.UUID
|
||||
|
||||
class SecurePrefs(context: Context) {
|
||||
companion object {
|
||||
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
|
||||
private const val displayNameKey = "node.displayName"
|
||||
private const val voiceWakeModeKey = "voiceWake.mode"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
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 _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||
val instanceId: StateFlow<String> = _instanceId
|
||||
|
||||
private val _displayName =
|
||||
MutableStateFlow(loadOrMigrateDisplayName(context = context))
|
||||
val displayName: StateFlow<String> = _displayName
|
||||
|
||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||
|
||||
private val _locationMode =
|
||||
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
|
||||
val locationMode: StateFlow<LocationMode> = _locationMode
|
||||
|
||||
private val _locationPreciseEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
|
||||
|
||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
private val _manualEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false))
|
||||
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||
|
||||
private val _manualHost =
|
||||
MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "")
|
||||
val manualHost: StateFlow<String> = _manualHost
|
||||
|
||||
private val _manualPort =
|
||||
MutableStateFlow(prefs.getInt("gateway.manual.port", 18789))
|
||||
val manualPort: StateFlow<Int> = _manualPort
|
||||
|
||||
private val _manualTls =
|
||||
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
|
||||
val manualTls: StateFlow<Boolean> = _manualTls
|
||||
|
||||
private val _lastDiscoveredStableId =
|
||||
MutableStateFlow(
|
||||
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
||||
)
|
||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||
|
||||
private val _canvasDebugStatusEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||
|
||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||
|
||||
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString(displayNameKey, trimmed) }
|
||||
_displayName.value = trimmed
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("camera.enabled", value) }
|
||||
_cameraEnabled.value = value
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.edit { putString("location.enabledMode", mode.rawValue) }
|
||||
_locationMode.value = mode
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("location.preciseEnabled", value) }
|
||||
_locationPreciseEnabled.value = value
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
_preventSleep.value = value
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("gateway.manual.enabled", value) }
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("gateway.manual.host", trimmed) }
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.edit { putInt("gateway.manual.port", value) }
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
prefs.edit { putBoolean("gateway.manual.tls", value) }
|
||||
_manualTls.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadGatewayToken(): String? {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
val stored = prefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayToken(token: String) {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayPassword(): String? {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
val stored = prefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayPassword(password: String) {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
prefs.edit { putString(key, password.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
||||
val key = "gateway.tls.$stableId"
|
||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
|
||||
val key = "gateway.tls.$stableId"
|
||||
prefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
fun getString(key: String): String? {
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun putString(key: String, value: String) {
|
||||
prefs.edit { putString(key, value) }
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
prefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun createPrefs(context: Context, name: String): SharedPreferences {
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
name,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
prefs.edit { putString("node.instanceId", fresh) }
|
||||
return fresh
|
||||
}
|
||||
|
||||
private fun loadOrMigrateDisplayName(context: Context): String {
|
||||
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" }
|
||||
|
||||
prefs.edit { putString(displayNameKey, resolved) }
|
||||
return resolved
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||
val encoded =
|
||||
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
prefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
_wakeWords.value = sanitized
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
_voiceWakeMode.value = mode
|
||||
}
|
||||
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("talk.enabled", value) }
|
||||
_talkEnabled.value = value
|
||||
}
|
||||
|
||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||
val raw = prefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
// Default ON (foreground) when unset.
|
||||
if (raw.isNullOrBlank()) {
|
||||
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun loadWakeWords(): List<String> {
|
||||
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
|
||||
if (raw.isNullOrEmpty()) return defaultWakeWords
|
||||
return try {
|
||||
val element = json.parseToJsonElement(raw)
|
||||
val array = element as? JsonArray ?: return defaultWakeWords
|
||||
val decoded =
|
||||
array.mapNotNull { item ->
|
||||
when (item) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
WakeWords.sanitize(decoded, defaultWakeWords)
|
||||
} catch (_: Throwable) {
|
||||
defaultWakeWords
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package ai.openclaw.android
|
||||
|
||||
internal fun normalizeMainKey(raw: String?): String {
|
||||
val trimmed = raw?.trim()
|
||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
||||
}
|
||||
|
||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return false
|
||||
if (trimmed == "global") return true
|
||||
return trimmed.startsWith("agent:")
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package ai.openclaw.android
|
||||
|
||||
object WakeWords {
|
||||
const val maxWords: Int = 32
|
||||
const val maxWordLength: Int = 64
|
||||
|
||||
fun parseCommaSeparated(input: String): List<String> {
|
||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun parseIfChanged(input: String, current: List<String>): List<String>? {
|
||||
val parsed = parseCommaSeparated(input)
|
||||
return if (parsed == current) null else parsed
|
||||
}
|
||||
|
||||
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||
val cleaned =
|
||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||
return cleaned.ifEmpty { defaults }
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
}
|
||||
|
||||
fun clearToken(deviceId: String, role: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
}
|
||||
|
||||
private fun tokenKey(deviceId: String, role: String): String {
|
||||
val normalizedDevice = deviceId.trim().lowercase()
|
||||
val normalizedRole = role.trim().lowercase()
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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
|
||||
|
||||
@Serializable
|
||||
data class DeviceIdentity(
|
||||
val deviceId: String,
|
||||
val publicKeyRawBase64: String,
|
||||
val privateKeyPkcs8Base64: String,
|
||||
val createdAtMs: Long,
|
||||
)
|
||||
|
||||
class DeviceIdentityStore(context: Context) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
|
||||
@Synchronized
|
||||
fun loadOrCreate(): DeviceIdentity {
|
||||
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)
|
||||
return updated
|
||||
}
|
||||
return existing
|
||||
}
|
||||
val fresh = generate()
|
||||
save(fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
fun signPayload(payload: String, identity: DeviceIdentity): String? {
|
||||
return try {
|
||||
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
|
||||
val keyFactory = KeyFactory.getInstance("Ed25519")
|
||||
val privateKey = keyFactory.generatePrivate(keySpec)
|
||||
val signature = Signature.getInstance("Ed25519")
|
||||
signature.initSign(privateKey)
|
||||
signature.update(payload.toByteArray(Charsets.UTF_8))
|
||||
base64UrlEncode(signature.sign())
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun publicKeyBase64Url(identity: DeviceIdentity): String? {
|
||||
return try {
|
||||
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
base64UrlEncode(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(): DeviceIdentity? {
|
||||
return readIdentity(identityFile)
|
||||
}
|
||||
|
||||
private fun readIdentity(file: File): DeviceIdentity? {
|
||||
return try {
|
||||
if (!file.exists()) return null
|
||||
val raw = file.readText(Charsets.UTF_8)
|
||||
val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw)
|
||||
if (decoded.deviceId.isBlank() ||
|
||||
decoded.publicKeyRawBase64.isBlank() ||
|
||||
decoded.privateKeyPkcs8Base64.isBlank()
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(identity: DeviceIdentity) {
|
||||
try {
|
||||
identityFile.parentFile?.mkdirs()
|
||||
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
|
||||
identityFile.writeText(encoded, Charsets.UTF_8)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
private fun generate(): DeviceIdentity {
|
||||
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
|
||||
val spki = keyPair.public.encoded
|
||||
val rawPublic = stripSpkiPrefix(spki)
|
||||
val deviceId = sha256Hex(rawPublic)
|
||||
val privateKey = keyPair.private.encoded
|
||||
return DeviceIdentity(
|
||||
deviceId = deviceId,
|
||||
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
|
||||
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP),
|
||||
createdAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deriveDeviceId(publicKeyRawBase64: String): String? {
|
||||
return try {
|
||||
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
|
||||
sha256Hex(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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 = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun base64UrlEncode(data: ByteArray): String {
|
||||
return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val ED25519_SPKI_PREFIX =
|
||||
byteArrayOf(
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.DnsResolver
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.xbill.DNS.AAAARecord
|
||||
import org.xbill.DNS.ARecord
|
||||
import org.xbill.DNS.DClass
|
||||
import org.xbill.DNS.ExtendedResolver
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.Name
|
||||
import org.xbill.DNS.PTRRecord
|
||||
import org.xbill.DNS.Record
|
||||
import org.xbill.DNS.Rcode
|
||||
import org.xbill.DNS.Resolver
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.SimpleResolver
|
||||
import org.xbill.DNS.TextParseException
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class GatewayDiscovery(
|
||||
context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val serviceType = "_openclaw-gw._tcp."
|
||||
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
|
||||
private val logTag = "OpenClaw/GatewayDiscovery"
|
||||
|
||||
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Searching…")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private var unicastJob: Job? = null
|
||||
private val dnsExecutor: Executor = Executors.newCachedThreadPool()
|
||||
|
||||
@Volatile private var lastWideAreaRcode: Int? = null
|
||||
@Volatile private var lastWideAreaCount: Int = 0
|
||||
|
||||
private val discoveryListener =
|
||||
object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||
override fun onDiscoveryStarted(serviceType: String) {}
|
||||
override fun onDiscoveryStopped(serviceType: String) {}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return
|
||||
resolve(serviceInfo)
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById.remove(id)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
startLocalDiscovery()
|
||||
if (!wideAreaDomain.isNullOrBlank()) {
|
||||
startUnicastDiscovery(wideAreaDomain)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocalDiscovery() {
|
||||
try {
|
||||
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocalDiscovery() {
|
||||
try {
|
||||
nsd.stopServiceDiscovery(discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUnicastDiscovery(domain: String) {
|
||||
unicastJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (true) {
|
||||
try {
|
||||
refreshUnicast(domain)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolve(serviceInfo: NsdServiceInfo) {
|
||||
nsd.resolveService(
|
||||
serviceInfo,
|
||||
object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||
|
||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||
val host = resolved.host?.hostAddress ?: return
|
||||
val port = resolved.port
|
||||
if (port <= 0) return
|
||||
|
||||
val rawServiceName = resolved.serviceName
|
||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||
val lanHost = txt(resolved, "lanHost")
|
||||
val tailnetDns = txt(resolved, "tailnetDns")
|
||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val tlsEnabled = txtBool(resolved, "gatewayTls")
|
||||
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
GatewayEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
_gateways.value =
|
||||
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
|
||||
_statusText.value = buildStatusText()
|
||||
}
|
||||
|
||||
private fun buildStatusText(): String {
|
||||
val localCount = localById.size
|
||||
val wideRcode = lastWideAreaRcode
|
||||
val wideCount = lastWideAreaCount
|
||||
|
||||
val wide =
|
||||
when (wideRcode) {
|
||||
null -> "Wide: ?"
|
||||
Rcode.NOERROR -> "Wide: $wideCount"
|
||||
Rcode.NXDOMAIN -> "Wide: NXDOMAIN"
|
||||
else -> "Wide: ${Rcode.string(wideRcode)}"
|
||||
}
|
||||
|
||||
return when {
|
||||
localCount == 0 && wideRcode == null -> "Searching for gateways…"
|
||||
localCount == 0 -> "$wide"
|
||||
else -> "Local: $localCount • $wide"
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableId(serviceName: String, domain: String): String {
|
||||
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
|
||||
}
|
||||
|
||||
private fun normalizeName(raw: String): String {
|
||||
return raw.trim().split(Regex("\\s+")).joinToString(" ")
|
||||
}
|
||||
|
||||
private fun txt(info: NsdServiceInfo, key: String): String? {
|
||||
val bytes = info.attributes[key] ?: return null
|
||||
return try {
|
||||
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
|
||||
return txt(info, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val ptrName = "${serviceType}${domain}"
|
||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
|
||||
|
||||
val next = LinkedHashMap<String, GatewayEndpoint>()
|
||||
for (ptr in ptrRecords) {
|
||||
val instanceFqdn = ptr.target.toString()
|
||||
val srv =
|
||||
recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||
?: run {
|
||||
val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null
|
||||
recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||
}
|
||||
?: continue
|
||||
val port = srv.port
|
||||
if (port <= 0) continue
|
||||
|
||||
val targetFqdn = srv.target.toString()
|
||||
val host =
|
||||
resolveHostFromMessage(ptrMsg, targetFqdn)
|
||||
?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn)
|
||||
?: resolveHostUnicast(targetFqdn)
|
||||
?: continue
|
||||
|
||||
val txtFromPtr =
|
||||
recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)]
|
||||
.orEmpty()
|
||||
.mapNotNull { it as? TXTRecord }
|
||||
val txt =
|
||||
if (txtFromPtr.isNotEmpty()) {
|
||||
txtFromPtr
|
||||
} else {
|
||||
val msg = lookupUnicastMessage(instanceFqdn, Type.TXT)
|
||||
records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord }
|
||||
}
|
||||
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||
val lanHost = txtValue(txt, "lanHost")
|
||||
val tailnetDns = txtValue(txt, "tailnetDns")
|
||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||
val tlsEnabled = txtBoolValue(txt, "gatewayTls")
|
||||
val tlsFingerprint = txtValue(txt, "gatewayTlsSha256")
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] =
|
||||
GatewayEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
}
|
||||
|
||||
unicastById.clear()
|
||||
unicastById.putAll(next)
|
||||
lastWideAreaRcode = ptrMsg.header.rcode
|
||||
lastWideAreaCount = next.size
|
||||
publish()
|
||||
|
||||
if (next.isEmpty()) {
|
||||
Log.d(
|
||||
logTag,
|
||||
"wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeInstanceName(instanceFqdn: String, domain: String): String {
|
||||
val suffix = "${serviceType}${domain}"
|
||||
val withoutSuffix =
|
||||
if (instanceFqdn.endsWith(suffix)) {
|
||||
instanceFqdn.removeSuffix(suffix)
|
||||
} else {
|
||||
instanceFqdn.substringBefore(serviceType)
|
||||
}
|
||||
return normalizeName(stripTrailingDot(withoutSuffix))
|
||||
}
|
||||
|
||||
private fun stripTrailingDot(raw: String): String {
|
||||
return raw.removeSuffix(".")
|
||||
}
|
||||
|
||||
private suspend fun lookupUnicastMessage(name: String, type: Int): Message? {
|
||||
val query =
|
||||
try {
|
||||
Message.newQuery(
|
||||
org.xbill.DNS.Record.newRecord(
|
||||
Name.fromString(name),
|
||||
type,
|
||||
DClass.IN,
|
||||
),
|
||||
)
|
||||
} catch (_: TextParseException) {
|
||||
return null
|
||||
}
|
||||
|
||||
val system = queryViaSystemDns(query)
|
||||
if (records(system, Section.ANSWER).any { it.type == type }) return system
|
||||
|
||||
val direct = createDirectResolver() ?: return system
|
||||
return try {
|
||||
val msg = direct.send(query)
|
||||
if (records(msg, Section.ANSWER).any { it.type == type }) msg else system
|
||||
} catch (_: Throwable) {
|
||||
system
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryViaSystemDns(query: Message): Message? {
|
||||
val network = preferredDnsNetwork()
|
||||
val bytes =
|
||||
try {
|
||||
rawQuery(network, query.toWire())
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
Message(bytes)
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun records(msg: Message?, section: Int): List<Record> {
|
||||
return msg?.getSectionArray(section)?.toList() ?: emptyList()
|
||||
}
|
||||
|
||||
private fun keyName(raw: String): String {
|
||||
return raw.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun recordsByName(msg: Message, section: Int): Map<String, List<Record>> {
|
||||
val next = LinkedHashMap<String, MutableList<Record>>()
|
||||
for (r in records(msg, section)) {
|
||||
val name = r.name?.toString() ?: continue
|
||||
next.getOrPut(keyName(name)) { mutableListOf() }.add(r)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private fun recordByName(msg: Message, fqdn: String, type: Int): Record? {
|
||||
val key = keyName(fqdn)
|
||||
val byNameAnswer = recordsByName(msg, Section.ANSWER)
|
||||
val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type }
|
||||
if (fromAnswer != null) return fromAnswer
|
||||
|
||||
val byNameAdditional = recordsByName(msg, Section.ADDITIONAL)
|
||||
return byNameAdditional[key].orEmpty().firstOrNull { it.type == type }
|
||||
}
|
||||
|
||||
private fun resolveHostFromMessage(msg: Message?, hostname: String): String? {
|
||||
val m = msg ?: return null
|
||||
val key = keyName(hostname)
|
||||
val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty()
|
||||
val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress }
|
||||
val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress }
|
||||
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||
}
|
||||
|
||||
private fun preferredDnsNetwork(): android.net.Network? {
|
||||
val cm = connectivity ?: return null
|
||||
|
||||
// Prefer VPN (Tailscale) when present; otherwise use the active network.
|
||||
cm.allNetworks.firstOrNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}?.let { return it }
|
||||
|
||||
return cm.activeNetwork
|
||||
}
|
||||
|
||||
private fun createDirectResolver(): Resolver? {
|
||||
val cm = connectivity ?: return null
|
||||
|
||||
val candidateNetworks =
|
||||
buildList {
|
||||
cm.allNetworks
|
||||
.firstOrNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}?.let(::add)
|
||||
cm.activeNetwork?.let(::add)
|
||||
}.distinct()
|
||||
|
||||
val servers =
|
||||
candidateNetworks
|
||||
.asSequence()
|
||||
.flatMap { n ->
|
||||
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
|
||||
}
|
||||
.distinctBy { it.hostAddress ?: it.toString() }
|
||||
.toList()
|
||||
if (servers.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
val resolvers =
|
||||
servers.mapNotNull { addr ->
|
||||
try {
|
||||
SimpleResolver().apply {
|
||||
setAddress(InetSocketAddress(addr, 53))
|
||||
setTimeout(3)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (resolvers.isEmpty()) return null
|
||||
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
|
||||
dns.rawQuery(
|
||||
network,
|
||||
wireQuery,
|
||||
DnsResolver.FLAG_EMPTY,
|
||||
dnsExecutor,
|
||||
signal,
|
||||
object : DnsResolver.Callback<ByteArray> {
|
||||
override fun onAnswer(answer: ByteArray, rcode: Int) {
|
||||
cont.resume(answer)
|
||||
}
|
||||
|
||||
override fun onError(error: DnsResolver.DnsException) {
|
||||
cont.resumeWithException(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun txtValue(records: List<TXTRecord>, key: String): String? {
|
||||
val prefix = "$key="
|
||||
for (r in records) {
|
||||
val strings: List<String> =
|
||||
try {
|
||||
r.strings.mapNotNull { it as? String }
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
for (s in strings) {
|
||||
val trimmed = decodeDnsTxtString(s).trim()
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return trimmed.removePrefix(prefix).trim().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
|
||||
return txtValue(records, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private fun decodeDnsTxtString(raw: String): String {
|
||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||
val bytes = raw.toByteArray(Charsets.ISO_8859_1)
|
||||
val decoder =
|
||||
Charsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
return try {
|
||||
decoder.decode(ByteBuffer.wrap(bytes)).toString()
|
||||
} catch (_: Throwable) {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveHostUnicast(hostname: String): String? {
|
||||
val a =
|
||||
records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER)
|
||||
.mapNotNull { it as? ARecord }
|
||||
.mapNotNull { it.address?.hostAddress }
|
||||
val aaaa =
|
||||
records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER)
|
||||
.mapNotNull { it as? AAAARecord }
|
||||
.mapNotNull { it.address?.hostAddress }
|
||||
|
||||
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
data class GatewayEndpoint(
|
||||
val stableId: String,
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val lanHost: String? = null,
|
||||
val tailnetDns: String? = null,
|
||||
val gatewayPort: Int? = null,
|
||||
val canvasPort: Int? = null,
|
||||
val tlsEnabled: Boolean = false,
|
||||
val tlsFingerprintSha256: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): GatewayEndpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|${host.lowercase()}|$port",
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
tlsFingerprintSha256 = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
const val GATEWAY_PROTOCOL_VERSION = 3
|
||||
@@ -1,683 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import android.util.Log
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
|
||||
data class GatewayClientInfo(
|
||||
val id: String,
|
||||
val displayName: String?,
|
||||
val version: String,
|
||||
val platform: String,
|
||||
val mode: String,
|
||||
val instanceId: String?,
|
||||
val deviceFamily: String?,
|
||||
val modelIdentifier: String?,
|
||||
)
|
||||
|
||||
data class GatewayConnectOptions(
|
||||
val role: String,
|
||||
val scopes: List<String>,
|
||||
val caps: List<String>,
|
||||
val commands: List<String>,
|
||||
val permissions: Map<String, Boolean>,
|
||||
val client: GatewayClientInfo,
|
||||
val userAgent: String? = null,
|
||||
)
|
||||
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
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,
|
||||
) {
|
||||
data class InvokeRequest(
|
||||
val id: String,
|
||||
val nodeId: String,
|
||||
val command: String,
|
||||
val paramsJson: String?,
|
||||
val timeoutMs: Long?,
|
||||
)
|
||||
|
||||
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
|
||||
companion object {
|
||||
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
|
||||
fun error(code: String, message: String) =
|
||||
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
|
||||
}
|
||||
}
|
||||
|
||||
data class ErrorShape(val code: String, val message: String)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
@Volatile private var mainSessionKey: String? = null
|
||||
|
||||
private data class DesiredConnection(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val token: String?,
|
||||
val password: String?,
|
||||
val options: GatewayConnectOptions,
|
||||
val tls: GatewayTlsParams?,
|
||||
)
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private var job: Job? = null
|
||||
@Volatile private var currentConnection: Connection? = null
|
||||
|
||||
fun connect(
|
||||
endpoint: GatewayEndpoint,
|
||||
token: String?,
|
||||
password: String?,
|
||||
options: GatewayConnectOptions,
|
||||
tls: GatewayTlsParams? = null,
|
||||
) {
|
||||
desired = DesiredConnection(endpoint, token, password, options, tls)
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
desired = null
|
||||
currentConnection?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
canvasHostUrl = null
|
||||
mainSessionKey = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
|
||||
fun reconnect() {
|
||||
currentConnection?.closeQuietly()
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
fun currentMainSessionKey(): String? = mainSessionKey
|
||||
|
||||
suspend fun sendNodeEvent(event: String, payloadJson: String?) {
|
||||
val conn = currentConnection ?: return
|
||||
val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("event", JsonPrimitive(event))
|
||||
if (parsedPayload != null) {
|
||||
put("payload", parsedPayload)
|
||||
} else if (payloadJson != null) {
|
||||
put("payloadJSON", JsonPrimitive(payloadJson))
|
||||
} else {
|
||||
put("payloadJSON", JsonNull)
|
||||
}
|
||||
}
|
||||
try {
|
||||
conn.request("node.event", params, timeoutMs = 8_000)
|
||||
} catch (err: Throwable) {
|
||||
Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val params =
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
json.parseToJsonElement(paramsJson)
|
||||
}
|
||||
val res = conn.request(method, params, timeoutMs)
|
||||
if (res.ok) return res.payloadJson ?: ""
|
||||
val err = res.error
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private inner class Connection(
|
||||
private val endpoint: GatewayEndpoint,
|
||||
private val token: String?,
|
||||
private val password: String?,
|
||||
private val options: GatewayConnectOptions,
|
||||
private val tls: GatewayTlsParams?,
|
||||
) {
|
||||
private val connectDeferred = CompletableDeferred<Unit>()
|
||||
private val closedDeferred = CompletableDeferred<Unit>()
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private val connectNonceDeferred = CompletableDeferred<String?>()
|
||||
private val client: OkHttpClient = buildClient()
|
||||
private var socket: WebSocket? = null
|
||||
private val loggerTag = "OpenClawGateway"
|
||||
|
||||
val remoteAddress: String =
|
||||
if (endpoint.host.contains(":")) {
|
||||
"[${endpoint.host}]:${endpoint.port}"
|
||||
} else {
|
||||
"${endpoint.host}:${endpoint.port}"
|
||||
}
|
||||
|
||||
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()
|
||||
socket = client.newWebSocket(request, Listener())
|
||||
try {
|
||||
connectDeferred.await()
|
||||
} catch (err: Throwable) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
val frame =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (params != null) put("params", params)
|
||||
}
|
||||
sendJson(frame)
|
||||
return try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
pending.remove(id)
|
||||
throw IllegalStateException("request timeout")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendJson(obj: JsonObject) {
|
||||
val jsonString = obj.toString()
|
||||
writeLock.withLock {
|
||||
socket?.send(jsonString)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitClose() = closedDeferred.await()
|
||||
|
||||
fun closeQuietly() {
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
socket?.close(1000, "bye")
|
||||
socket = null
|
||||
closedDeferred.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildClient(): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||
}
|
||||
if (tlsConfig != null) {
|
||||
builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager)
|
||||
builder.hostnameVerifier(tlsConfig.hostnameVerifier)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private inner class Listener : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
scope.launch {
|
||||
try {
|
||||
val nonce = awaitConnectNonce()
|
||||
sendConnect(nonce)
|
||||
} catch (err: Throwable) {
|
||||
connectDeferred.completeExceptionally(err)
|
||||
closeQuietly()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
scope.launch { handleMessage(text) }
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (!connectDeferred.isCompleted) {
|
||||
connectDeferred.completeExceptionally(t)
|
||||
}
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
failPending()
|
||||
closedDeferred.complete(Unit)
|
||||
onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (!connectDeferred.isCompleted) {
|
||||
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason"))
|
||||
}
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
failPending()
|
||||
closedDeferred.complete(Unit)
|
||||
onDisconnected("Gateway closed: $reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendConnect(connectNonce: String?) {
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
||||
val trimmedToken = token?.trim().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 = 8_000)
|
||||
if (!res.ok) {
|
||||
val msg = res.error?.message ?: "connect failed"
|
||||
if (canFallbackToShared) {
|
||||
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
||||
}
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
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()
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
if (!deviceToken.isNullOrBlank()) {
|
||||
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
|
||||
}
|
||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||
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?,
|
||||
authToken: String,
|
||||
authPassword: String?,
|
||||
): JsonObject {
|
||||
val client = options.client
|
||||
val locale = Locale.getDefault().toLanguageTag()
|
||||
val clientObj =
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(client.id))
|
||||
client.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
put("version", JsonPrimitive(client.version))
|
||||
put("platform", JsonPrimitive(client.platform))
|
||||
put("mode", JsonPrimitive(client.mode))
|
||||
client.instanceId?.let { put("instanceId", JsonPrimitive(it)) }
|
||||
client.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
}
|
||||
|
||||
val password = authPassword?.trim().orEmpty()
|
||||
val authJson =
|
||||
when {
|
||||
authToken.isNotEmpty() ->
|
||||
buildJsonObject {
|
||||
put("token", JsonPrimitive(authToken))
|
||||
}
|
||||
password.isNotEmpty() ->
|
||||
buildJsonObject {
|
||||
put("password", JsonPrimitive(password))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
val payload =
|
||||
buildDeviceAuthPayload(
|
||||
deviceId = identity.deviceId,
|
||||
clientId = client.id,
|
||||
clientMode = client.mode,
|
||||
role = options.role,
|
||||
scopes = options.scopes,
|
||||
signedAtMs = signedAtMs,
|
||||
token = if (authToken.isNotEmpty()) authToken else null,
|
||||
nonce = connectNonce,
|
||||
)
|
||||
val signature = identityStore.signPayload(payload, identity)
|
||||
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||
val deviceJson =
|
||||
if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) {
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(identity.deviceId))
|
||||
put("publicKey", JsonPrimitive(publicKey))
|
||||
put("signature", JsonPrimitive(signature))
|
||||
put("signedAt", JsonPrimitive(signedAtMs))
|
||||
if (!connectNonce.isNullOrBlank()) {
|
||||
put("nonce", JsonPrimitive(connectNonce))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return buildJsonObject {
|
||||
put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
|
||||
put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
|
||||
put("client", clientObj)
|
||||
if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive)))
|
||||
if (options.commands.isNotEmpty()) put("commands", JsonArray(options.commands.map(::JsonPrimitive)))
|
||||
if (options.permissions.isNotEmpty()) {
|
||||
put(
|
||||
"permissions",
|
||||
buildJsonObject {
|
||||
options.permissions.forEach { (key, value) ->
|
||||
put(key, JsonPrimitive(value))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
put("role", JsonPrimitive(options.role))
|
||||
if (options.scopes.isNotEmpty()) put("scopes", JsonArray(options.scopes.map(::JsonPrimitive)))
|
||||
authJson?.let { put("auth", it) }
|
||||
deviceJson?.let { put("device", it) }
|
||||
put("locale", JsonPrimitive(locale))
|
||||
options.userAgent?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
put("userAgent", JsonPrimitive(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleMessage(text: String) {
|
||||
val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return
|
||||
when (frame["type"].asStringOrNull()) {
|
||||
"res" -> handleResponse(frame)
|
||||
"event" -> handleEvent(frame)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleResponse(frame: JsonObject) {
|
||||
val id = frame["id"].asStringOrNull() ?: return
|
||||
val ok = frame["ok"].asBooleanOrNull() ?: false
|
||||
val payloadJson = frame["payload"]?.let { payload -> payload.toString() }
|
||||
val error =
|
||||
frame["error"]?.asObjectOrNull()?.let { obj ->
|
||||
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
||||
ErrorShape(code, msg)
|
||||
}
|
||||
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
||||
}
|
||||
|
||||
private fun handleEvent(frame: JsonObject) {
|
||||
val event = frame["event"].asStringOrNull() ?: return
|
||||
val payloadJson =
|
||||
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
|
||||
if (event == "connect.challenge") {
|
||||
val nonce = extractConnectNonce(payloadJson)
|
||||
if (!connectNonceDeferred.isCompleted) {
|
||||
connectNonceDeferred.complete(nonce)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
|
||||
handleInvokeEvent(payloadJson)
|
||||
return
|
||||
}
|
||||
onEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectNonce(): String? {
|
||||
if (isLoopbackHost(endpoint.host)) return null
|
||||
return try {
|
||||
withTimeout(2_000) { connectNonceDeferred.await() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractConnectNonce(payloadJson: String?): String? {
|
||||
if (payloadJson.isNullOrBlank()) return null
|
||||
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
|
||||
return obj["nonce"].asStringOrNull()
|
||||
}
|
||||
|
||||
private fun handleInvokeEvent(payloadJson: String) {
|
||||
val payload =
|
||||
try {
|
||||
json.parseToJsonElement(payloadJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return
|
||||
val id = payload["id"].asStringOrNull() ?: return
|
||||
val nodeId = payload["nodeId"].asStringOrNull() ?: return
|
||||
val command = payload["command"].asStringOrNull() ?: return
|
||||
val params =
|
||||
payload["paramsJSON"].asStringOrNull()
|
||||
?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() }
|
||||
val timeoutMs = payload["timeoutMs"].asLongOrNull()
|
||||
scope.launch {
|
||||
val result =
|
||||
try {
|
||||
onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs))
|
||||
?: InvokeResult.error("UNAVAILABLE", "invoke handler missing")
|
||||
} catch (err: Throwable) {
|
||||
invokeErrorFromThrowable(err)
|
||||
}
|
||||
sendInvokeResult(id, nodeId, result)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) {
|
||||
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("nodeId", JsonPrimitive(nodeId))
|
||||
put("ok", JsonPrimitive(result.ok))
|
||||
if (parsedPayload != null) {
|
||||
put("payload", parsedPayload)
|
||||
} else if (result.payloadJson != null) {
|
||||
put("payloadJSON", JsonPrimitive(result.payloadJson))
|
||||
}
|
||||
result.error?.let { err ->
|
||||
put(
|
||||
"error",
|
||||
buildJsonObject {
|
||||
put("code", JsonPrimitive(err.code))
|
||||
put("message", JsonPrimitive(err.message))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
request("node.invoke.result", params, timeoutMs = 15_000)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||
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() {
|
||||
for ((_, waiter) in pending) {
|
||||
waiter.cancel()
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runLoop() {
|
||||
var attempt = 0
|
||||
while (scope.isActive) {
|
||||
val target = desired
|
||||
if (target == null) {
|
||||
currentConnection?.closeQuietly()
|
||||
currentConnection = null
|
||||
delay(250)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||
connectOnce(target)
|
||||
attempt = 0
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
|
||||
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
||||
delay(sleepMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
|
||||
val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls)
|
||||
currentConnection = conn
|
||||
try {
|
||||
conn.connect()
|
||||
conn.awaitClose()
|
||||
} finally {
|
||||
currentConnection = null
|
||||
canvasHostUrl = null
|
||||
mainSessionKey = null
|
||||
}
|
||||
}
|
||||
|
||||
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" }
|
||||
|
||||
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
val fallbackHost =
|
||||
endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.host.trim()
|
||||
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
|
||||
|
||||
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
|
||||
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
||||
return "$scheme://$formattedHost:$fallbackPort"
|
||||
}
|
||||
|
||||
private fun isLoopbackHost(raw: String?): Boolean {
|
||||
val host = raw?.trim()?.lowercase().orEmpty()
|
||||
if (host.isEmpty()) return false
|
||||
if (host == "localhost") return true
|
||||
if (host == "::1") return true
|
||||
if (host == "0.0.0.0" || host == "::") return true
|
||||
return host.startsWith("127.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> {
|
||||
val c = content.trim()
|
||||
when {
|
||||
c.equals("true", ignoreCase = true) -> true
|
||||
c.equals("false", ignoreCase = true) -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asLongOrNull(): Long? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun parseJsonOrNull(payload: String): JsonElement? {
|
||||
val trimmed = payload.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(trimmed)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
data class GatewayTlsParams(
|
||||
val required: Boolean,
|
||||
val expectedFingerprint: String?,
|
||||
val allowTOFU: Boolean,
|
||||
val stableId: String,
|
||||
)
|
||||
|
||||
data class GatewayTlsConfig(
|
||||
val sslSocketFactory: SSLSocketFactory,
|
||||
val trustManager: X509TrustManager,
|
||||
val hostnameVerifier: HostnameVerifier,
|
||||
)
|
||||
|
||||
fun buildGatewayTlsConfig(
|
||||
params: GatewayTlsParams?,
|
||||
onStore: ((String) -> Unit)? = null,
|
||||
): GatewayTlsConfig? {
|
||||
if (params == null) return null
|
||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||
val defaultTrust = defaultTrustManager()
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
val trustManager =
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
defaultTrust.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
||||
val fingerprint = sha256Hex(chain[0].encoded)
|
||||
if (expected != null) {
|
||||
if (fingerprint != expected) {
|
||||
throw CertificateException("gateway TLS fingerprint mismatch")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (params.allowTOFU) {
|
||||
onStore?.invoke(fingerprint)
|
||||
return
|
||||
}
|
||||
defaultTrust.checkServerTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
||||
}
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
||||
return GatewayTlsConfig(
|
||||
sslSocketFactory = context.socketFactory,
|
||||
trustManager = trustManager,
|
||||
hostnameVerifier = HostnameVerifier { _, _ -> true },
|
||||
)
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as java.security.KeyStore?)
|
||||
val trust =
|
||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
val stripped = raw.trim()
|
||||
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
|
||||
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package ai.openclaw.android.protocol
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
object OpenClawCanvasA2UIAction {
|
||||
fun extractActionName(userAction: JsonObject): String? {
|
||||
val name =
|
||||
(userAction["name"] as? JsonPrimitive)
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (name.isNotEmpty()) return name
|
||||
val action =
|
||||
(userAction["action"] as? JsonPrimitive)
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
return action.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun sanitizeTagValue(value: String): String {
|
||||
val trimmed = value.trim().ifEmpty { "-" }
|
||||
val normalized = trimmed.replace(" ", "_")
|
||||
val out = StringBuilder(normalized.length)
|
||||
for (c in normalized) {
|
||||
val ok =
|
||||
c.isLetterOrDigit() ||
|
||||
c == '_' ||
|
||||
c == '-' ||
|
||||
c == '.' ||
|
||||
c == ':'
|
||||
out.append(if (ok) c else '_')
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
fun formatAgentMessage(
|
||||
actionName: String,
|
||||
sessionKey: String,
|
||||
surfaceId: String,
|
||||
sourceComponentId: String,
|
||||
host: String,
|
||||
instanceId: String,
|
||||
contextJson: String?,
|
||||
): String {
|
||||
val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty()
|
||||
return listOf(
|
||||
"CANVAS_A2UI",
|
||||
"action=${sanitizeTagValue(actionName)}",
|
||||
"session=${sanitizeTagValue(sessionKey)}",
|
||||
"surface=${sanitizeTagValue(surfaceId)}",
|
||||
"component=${sanitizeTagValue(sourceComponentId)}",
|
||||
"host=${sanitizeTagValue(host)}",
|
||||
"instance=${sanitizeTagValue(instanceId)}$ctxSuffix",
|
||||
"default=update_canvas",
|
||||
).joinToString(separator = " ")
|
||||
}
|
||||
|
||||
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
|
||||
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
val okLiteral = if (ok) "true" else "false"
|
||||
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package ai.openclaw.android.protocol
|
||||
|
||||
enum class OpenClawCapability(val rawValue: String) {
|
||||
Canvas("canvas"),
|
||||
Camera("camera"),
|
||||
Screen("screen"),
|
||||
Sms("sms"),
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
Present("canvas.present"),
|
||||
Hide("canvas.hide"),
|
||||
Navigate("canvas.navigate"),
|
||||
Eval("canvas.eval"),
|
||||
Snapshot("canvas.snapshot"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "canvas."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasA2UICommand(val rawValue: String) {
|
||||
Push("canvas.a2ui.push"),
|
||||
PushJSONL("canvas.a2ui.pushJSONL"),
|
||||
Reset("canvas.a2ui.reset"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "canvas.a2ui."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawCameraCommand(val rawValue: String) {
|
||||
Snap("camera.snap"),
|
||||
Clip("camera.clip"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "camera."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawScreenCommand(val rawValue: String) {
|
||||
Record("screen.record"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "screen."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawSmsCommand(val rawValue: String) {
|
||||
Send("sms.send"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "sms."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawLocationCommand(val rawValue: String) {
|
||||
Get("location.get"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "location."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package ai.openclaw.android.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.ui.chat.ChatSheetContent
|
||||
|
||||
@Composable
|
||||
fun ChatSheet(viewModel: MainViewModel) {
|
||||
ChatSheetContent(viewModel = viewModel)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user