delete em all

This commit is contained in:
kumarabhirup 2026-03-04 16:34:45 -08:00
parent af45d4d17b
commit 286d0fdb48
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
2858 changed files with 253 additions and 491991 deletions

View File

@ -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."
```

View File

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

View File

@ -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"

View File

@ -1,60 +0,0 @@
.git
.worktrees
.bun-cache
.bun
.tmp
**/.tmp
.DS_Store
**/.DS_Store
*.png
*.jpg
*.jpeg
*.webp
*.gif
*.mp4
*.mov
*.wav
*.mp3
node_modules
**/node_modules
.pnpm-store
**/.pnpm-store
.turbo
**/.turbo
.cache
**/.cache
.next
**/.next
coverage
**/coverage
*.log
tmp
**/tmp
# build artifacts
dist
**/dist
apps/macos/.build
apps/ios/build
**/*.trace
# large app trees not needed for CLI build
apps/
assets/
Peekaboo/
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/**

View File

@ -1,80 +0,0 @@
# 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-...
# OPENCLAW_LIVE_OPENAI_KEY=sk-...
# OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-...
# OPENCLAW_LIVE_GEMINI_KEY=...
# OPENAI_API_KEY_1=...
# ANTHROPIC_API_KEY_1=...
# GEMINI_API_KEY_1=...
# GOOGLE_API_KEY=...
# OPENAI_API_KEYS=sk-1,sk-2
# ANTHROPIC_API_KEYS=sk-ant-1,sk-ant-2
# GEMINI_API_KEYS=key-1,key-2
# Optional additional providers
# ZAI_API_KEY=...
# 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=...

1
.gitattributes vendored
View File

@ -1 +0,0 @@
* text=auto eol=lf

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
custom: ["https://github.com/sponsors/steipete"]

View File

@ -1,95 +0,0 @@
name: Bug report
description: Report a defect or unexpected behavior in OpenClaw.
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
- type: textarea
id: summary
attributes:
label: Summary
description: One-sentence statement of what is broken.
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Provide the shortest deterministic repro path.
placeholder: |
1. Configure channel X.
2. Send message Y.
3. Run command Z.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should happen if the bug does not exist.
placeholder: Agent posts a reply in the same thread.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead, including user-visible errors.
placeholder: No reply is posted; gateway logs "reply target not found".
validations:
required: true
- type: input
id: version
attributes:
label: OpenClaw version
description: Exact version/build tested.
placeholder: <version such as 2026.2.17>
validations:
required: true
- type: input
id: os
attributes:
label: Operating system
description: OS and version where this occurs.
placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11
validations:
required: true
- type: input
id: install_method
attributes:
label: Install method
description: How OpenClaw was installed or launched.
placeholder: npm global / pnpm dev / docker / mac app
- type: textarea
id: logs
attributes:
label: Logs, screenshots, and evidence
description: Include redacted logs/screenshots/recordings that prove the behavior.
render: shell
- type: textarea
id: impact
attributes:
label: Impact and severity
description: |
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
Include:
- Affected users/systems/channels
- Severity (annoying, blocks workflow, data risk, etc.)
- Frequency (always/intermittent/edge case)
- Consequence (missed messages, failed onboarding, extra cost, etc.)
placeholder: |
Affected: Telegram group users on <version>
Severity: High (blocks replies)
Frequency: 100% repro
Consequence: Agents cannot respond in threads
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Add any context that helps triage but does not fit above.
placeholder: Regression started after upgrade from <previous-version>; temporary workaround is ...

View File

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

View File

@ -1,70 +0,0 @@
name: Feature request
description: Propose a new capability or product improvement.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Help us evaluate this request with concrete use cases and tradeoffs.
- type: textarea
id: summary
attributes:
label: Summary
description: One-line statement of the requested capability.
placeholder: Add per-channel default response prefix.
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What user pain this solves and why current behavior is insufficient.
placeholder: Agents cannot distinguish persona context in mixed channels, causing misrouted follow-ups.
validations:
required: true
- type: textarea
id: proposed_solution
attributes:
label: Proposed solution
description: Desired behavior/API/UX with as much specificity as possible.
placeholder: Support channels.<channel>.responsePrefix with default fallback and account-level override.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other approaches considered and why they are weaker.
placeholder: Manual prefixing in prompts is inconsistent and hard to enforce.
- type: textarea
id: impact
attributes:
label: Impact
description: |
Explain who is affected, severity/urgency, how often this pain occurs, and practical consequences.
Include:
- Affected users/systems/channels
- Severity (annoying, blocks workflow, etc.)
- Frequency (always/intermittent/edge case)
- Consequence (delays, errors, extra manual work, etc.)
placeholder: |
Affected: Multi-team shared channels
Severity: Medium
Frequency: Daily
Consequence: +20 minutes/day/operator and delayed alerts
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence/examples
description: Prior art, links, screenshots, logs, or metrics.
placeholder: Comparable behavior in X, sample config, and screenshot of current limitation.
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Extra context, constraints, or references not covered above.
placeholder: Must remain backward-compatible with existing config keys.

View File

@ -1,22 +0,0 @@
# actionlint configuration
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-16vcpu-windows-2025
- blacksmith-16vcpu-ubuntu-2404-arm
# 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'
# actionlint's built-in runner label allowlist lags Blacksmith additions.
- 'label "blacksmith-16vcpu-[^"]+" is unknown\.'

View File

@ -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

View File

@ -1,98 +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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
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: "1.3.9+cf6cdbbba"
- 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"
FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }}
run: |
set -euo pipefail
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
case "$FROZEN_LOCKFILE" in
true) LOCKFILE_FLAG="--frozen-lockfile" ;;
false) LOCKFILE_FLAG="" ;;
*)
echo "::error::Invalid frozen-lockfile input: '$FROZEN_LOCKFILE' (expected true or false)"
exit 2
;;
esac
install_args=(
install
--ignore-scripts=false
--config.engine-strict=false
--config.enable-pre-post-scripts=true
)
if [ -n "$LOCKFILE_FLAG" ]; then
install_args+=("$LOCKFILE_FLAG")
fi
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"

View File

@ -1,47 +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
env:
PNPM_VERSION: ${{ inputs.pnpm-version }}
run: |
set -euo pipefail
if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'"
exit 2
fi
corepack enable
for attempt in 1 2 3; do
if corepack prepare "pnpm@$PNPM_VERSION" --activate; then
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 }}-

View File

@ -1,65 +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
# Docker base images (root Dockerfiles)
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
docker-images:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5

View File

@ -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.

258
.github/labeler.yml vendored
View File

@ -1,258 +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: denchclaw-auth":
- changed-files:
- any-glob-to-any-file:
- "extensions/denchclaw-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/**"

View File

@ -1,108 +0,0 @@
## Summary
Describe the problem and fix in 25 bullets:
- Problem:
- Why it matters:
- What changed:
- What did NOT change (scope boundary):
## Change Type (select all)
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Docs
- [ ] Security hardening
- [ ] Chore/infra
## Scope (select all touched areas)
- [ ] Gateway / orchestration
- [ ] Skills / tool execution
- [ ] Auth / tokens
- [ ] Memory / storage
- [ ] Integrations
- [ ] API / contracts
- [ ] UI / DX
- [ ] CI/CD / infra
## Linked Issue/PR
- Closes #
- Related #
## User-visible / Behavior Changes
List user-visible changes (including defaults/config).
If none, write `None`.
## Security Impact (required)
- New permissions/capabilities? (`Yes/No`)
- Secrets/tokens handling changed? (`Yes/No`)
- New/changed network calls? (`Yes/No`)
- Command/tool execution surface changed? (`Yes/No`)
- Data access scope changed? (`Yes/No`)
- If any `Yes`, explain risk + mitigation:
## Repro + Verification
### Environment
- OS:
- Runtime/container:
- Model/provider:
- Integration/channel (if any):
- Relevant config (redacted):
### Steps
1.
2.
3.
### Expected
-
### Actual
-
## Evidence
Attach at least one:
- [ ] Failing test/log before + passing after
- [ ] Trace/log snippets
- [ ] Screenshot/recording
- [ ] Perf numbers (if relevant)
## Human Verification (required)
What you personally verified (not just CI), and how:
- Verified scenarios:
- Edge cases checked:
- What you did **not** verify:
## Compatibility / Migration
- Backward compatible? (`Yes/No`)
- Config/env changes? (`Yes/No`)
- Migration needed? (`Yes/No`)
- If yes, exact upgrade steps:
## Failure Recovery (if this breaks)
- How to disable/revert this change quickly:
- Files/config to restore:
- Known bad symptoms reviewers should watch for:
## Risks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write `None`.
- Risk:
- Mitigation:

View File

@ -1,65 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# check:
# name: "check"
# runs-on: ubuntu-latest
# 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
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- runtime: node
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: bootstrap
command: pnpm vitest run --config src/cli/vitest.config.ts src/cli/profile.test.ts src/cli/bootstrap-external.test.ts src/cli/bootstrap-external.bootstrap-command.test.ts
- runtime: bun
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
steps:
- name: Skip bun lane on push
if: github.event_name == 'push' && matrix.runtime == 'bun'
run: echo "Skipping bun test lane on push events."
- name: Checkout
if: github.event_name != 'push' || matrix.runtime != 'bun'
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
if: matrix.runtime != 'bun' || github.event_name != 'push'
uses: ./.github/actions/setup-node-env
with:
install-bun: "${{ matrix.runtime == 'bun' }}"
- name: Configure Node test resources
if: matrix.runtime == 'node'
run: |
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=8192" >> "$GITHUB_ENV"
- name: Run tests (${{ matrix.runtime }})
if: github.event_name != 'push' || matrix.runtime != 'bun'
run: ${{ matrix.command }}

View File

@ -1,67 +0,0 @@
name: Workflow Sanity
on:
pull_request:
push:
branches: [main]
concurrency:
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
no-tabs:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fail on tabs in workflow files
run: |
python - <<'PY'
from __future__ import annotations
import pathlib
import sys
root = pathlib.Path(".github/workflows")
bad: list[str] = []
for path in sorted(root.rglob("*.yml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
for path in sorted(root.rglob("*.yaml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
if bad:
print("Tabs found in workflow file(s):")
for path in bad:
print(f"- {path}")
sys.exit(1)
PY
actionlint:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install actionlint
shell: bash
run: |
set -euo pipefail
ACTIONLINT_VERSION="1.7.11"
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}"
curl -sSfL -o "${archive}" "${base_url}/${archive}"
curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt"
grep " ${archive}\$" checksums.txt | sha256sum -c -
tar -xzf "${archive}" actionlint
sudo install -m 0755 actionlint /usr/local/bin/actionlint
- name: Lint workflows
run: actionlint
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py

3
.gitignore vendored
View File

@ -103,7 +103,10 @@ package-lock.json
# Local iOS signing overrides
apps/ios/LocalSigning.xcconfig
# Generated protocol schema (produced via pnpm protocol:gen)
dist/protocol.schema.json
.cursor/skills/
.npmrc.deploy

View File

@ -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,
},
}

View File

@ -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();
},
};
});
},
});
}

View File

@ -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();
},
};
});
},
});
}

View File

@ -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);
});
}

View File

@ -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
View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -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))`

View File

@ -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.

View File

@ -1,73 +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 (final merge commit only includes PR # + thanks):
- For the final merge-ready commit: `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
- If you need intermediate fix commits before the final merge commit, keep those messages concise and **omit** PR number/thanks.
- `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>`

View File

@ -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
- 13 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.

View File

@ -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]

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -1,51 +0,0 @@
# SwiftFormat configuration adapted from Peekaboo defaults (Swift 6 friendly)
--swiftversion 6.2
# Self handling
--self insert
--selfrequired
# Imports / extensions
--importgrouping testable-bottom
--extensionacl on-declarations
# Indentation
--indent 4
--indentcase false
--ifdef no-indent
--xcodeindentation enabled
# Line breaks
--linebreaks lf
--maxwidth 120
# Whitespace
--trimwhitespace always
--emptybraces no-space
--nospaceoperators ...,..<
--ranges no-space
--someAny true
--voidtype void
# Wrapping
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first
--closingparen same-line
# Organization
--organizetypes class,struct,enum,extension
--extensionmark "MARK: - %t + %p"
--marktypes always
--markextensions always
--structthreshold 0
--enumthreshold 0
# Other
--stripunusedargs closure-only
--header ignore
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol

View File

@ -1,148 +0,0 @@
# SwiftLint configuration adapted from Peekaboo defaults (Swift 6 friendly)
included:
- apps/macos/Sources
excluded:
- .build
- DerivedData
- "**/.build"
- "**/.swiftpm"
- "**/DerivedData"
- "**/Generated"
- "**/Resources"
- "**/Package.swift"
- "**/Tests/Resources"
- node_modules
- dist
- coverage
- "*.playground"
# Generated (protocol-gen-swift.ts)
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
analyzer_rules:
- unused_declaration
- unused_import
opt_in_rules:
- array_init
- closure_spacing
- contains_over_first_not_nil
- empty_count
- empty_string
- explicit_init
- fallthrough
- fatal_error_message
- first_where
- joined_default_parameter
- last_where
- literal_expression_end_indentation
- multiline_arguments
- multiline_parameters
- operator_usage_whitespace
- overridden_super_call
- pattern_matching_keywords
- private_outlet
- prohibited_super_call
- redundant_nil_coalescing
- sorted_first_last
- switch_case_alignment
- unneeded_parentheses_in_closure_argument
- vertical_parameter_alignment_on_call
disabled_rules:
# SwiftFormat handles these
- trailing_whitespace
- trailing_newline
- trailing_comma
- vertical_whitespace
- indentation_width
# Style exclusions
- explicit_self
- identifier_name
- file_header
- explicit_top_level_acl
- explicit_acl
- explicit_type_interface
- missing_docs
- required_deinit
- prefer_nimble
- quick_discouraged_call
- quick_discouraged_focused_test
- quick_discouraged_pending_test
- anonymous_argument_in_multiline_closure
- no_extension_access_modifier
- no_grouping_extension
- switch_case_on_newline
- strict_fileprivate
- extension_access_modifier
- convenience_type
- no_magic_numbers
- one_declaration_per_file
- vertical_whitespace_between_cases
- vertical_whitespace_closing_braces
- superfluous_else
- number_separator
- prefixed_toplevel_constant
- opening_brace
- trailing_closure
- contrasted_opening_brace
- sorted_imports
- redundant_type_annotation
- shorthand_optional_binding
- untyped_error_in_catch
- file_name
- todo
force_cast: warning
force_try: warning
type_name:
min_length:
warning: 2
error: 1
max_length:
warning: 60
error: 80
function_body_length:
warning: 150
error: 300
function_parameter_count:
warning: 7
error: 10
file_length:
warning: 1500
error: 2500
ignore_comment_only_lines: true
type_body_length:
warning: 800
error: 1200
cyclomatic_complexity:
warning: 20
error: 120
large_tuple:
warning: 4
error: 5
nesting:
type_level:
warning: 4
error: 6
function_level:
warning: 5
error: 7
line_length:
warning: 120
error: 250
ignores_comments: true
ignores_urls: true
reporter: "xcode"

View File

@ -1,3 +0,0 @@
{
"recommendations": ["oxc.oxc-vscode"]
}

22
.vscode/settings.json vendored
View File

@ -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
}

240
AGENTS.md
View File

@ -1,240 +0,0 @@
# 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".
## 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).
## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.openclaw.ai).
- 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.
- 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 its 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`
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repos package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
- 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`.
- 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)
- 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.
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- 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).
## 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`.
- Full kit + whats 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.
- PR submission template (canonical): `.github/pull_request_template.md`
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
## 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`.
## Git Notes
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
## 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.
- 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.
## GHSA (Repo Advisory) Patch/Publish
- Before reviewing security advisories, read `SECURITY.md`.
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
- Private fork PRs must be closed:
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
`gh pr list -R "$fork" --state open` (must be empty)
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `openclaw 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); dont 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`.
- 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`; dont 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).
- **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`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Release signing/notary keys are managed outside the repo; follow internal release docs.
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
- **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.
- 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}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
- Release guardrails: do not change version numbers without operators explicit consent; always ask permission before running any npm publish/release step.
## NPM + 1Password (publish/verify)
- 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.
## Plugin Release Fast Path (no core `openclaw` publish)
- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
- `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
- `eval "$(op signin --account my.1password.com)"`
- 1Password helpers:
- password used by `npm login`:
`op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
- OTP:
`op read 'op://Private/Npmjs/one-time password?attribute=otp'`
- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
- compare local plugin `version` to `npm view <name> version`
- only run `npm publish --access public --otp="<otp>"` when versions differ
- skip if package is missing on npm or version already matches.
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
- Post-check for each release:
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.17`
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
## Changelog Release Notes
- When cutting a mac release with beta GitHub prerelease:
- Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
- Create prerelease with title `openclaw YYYY.M.D-beta.N`.
- Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
- Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
- Keep top version entries in `CHANGELOG.md` sorted by impact:
- `### Changes` first.
- `### Fixes` deduped and ranked with user-facing fixes first.
- Before tagging/publishing, run:
- `node --import tsx scripts/release-check.ts`
- `pnpm release:check`
- `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
AGENTS.md

View File

@ -1,147 +0,0 @@
# Contributing to OpenClaw
Welcome to the lobster tank! 🦞
## Quick Links
- **GitHub:** https://github.com/openclaw/openclaw
- **Vision:** [`VISION.md`](VISION.md)
- **Discord:** https://discord.gg/qkhbAGHRBT
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw)
## Maintainers
- **Peter Steinberger** - Benevolent Dictator
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
- **Shadow** - Discord subsystem, Discord admin, Clawhub, all community moderation
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster
- GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh)
- **Jos** - Telegram, API, Nix mode
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
- **Ayaan Zaidi** - Telegram subsystem, iOS app
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus)
- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app
- GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust)
- **Mariano Belinky** - iOS app, Security
- GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
- **Vincent Koc** - Agents, Telemetry, Hooks, Security
- GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc)
- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening
- GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig)
- **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)
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
3. **Questions** → Discord #setup-help
## Before You PR
- Test locally with your OpenClaw instance
- Run tests: `pnpm build && pnpm check && pnpm test`
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- 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!
## Maintainers
We're selectively expanding the maintainer team.
If you're an experienced contributor who wants to help shape OpenClaw's direction — whether through code, docs, or community — we'd like to hear from you.
Being a maintainer is a responsibility, not an honorary title. We expect active, consistent involvement — triaging issues, reviewing PRs, and helping move the project forward.
Still interested? Email contributing@openclaw.ai with:
- Links to your PRs on OpenClaw (if you don't have any, start there first)
- Links to open source projects you maintain or actively contribute to
- Your GitHub, Discord, and X/Twitter handles
- A brief intro: background, experience, and areas of interest
- Languages you speak and where you're based
- How much time you can realistically commit
We welcome people across all skill sets — engineering, documentation, community management, and more.
We review every human-only-written application carefully and add maintainers slowly and deliberately.
Please allow a few weeks for a response.
## Report a Vulnerability
We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives:
- **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.

View File

@ -1,65 +0,0 @@
FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
# Install Bun (required for build scripts)
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
RUN corepack enable
WORKDIR /app
RUN chown node:node /app
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY --chown=node:node ui/package.json ./ui/package.json
COPY --chown=node:node patches ./patches
COPY --chown=node:node scripts ./scripts
USER node
RUN pnpm install --frozen-lockfile
# Optionally install Chromium and Xvfb for browser automation.
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
# Must run after pnpm install so playwright-core is available in node_modules.
USER root
ARG OPENCLAW_INSTALL_BROWSER=""
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
mkdir -p /home/node/.cache/ms-playwright && \
PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
chown -R node:node /home/node/.cache/ms-playwright && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
USER node
COPY --chown=node:node . .
RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm ui:build
ENV NODE_ENV=production
# 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"]

View File

@ -1,20 +0,0 @@
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
python3 \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox
CMD ["sleep", "infinity"]

View File

@ -1,32 +0,0 @@
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
chromium \
curl \
fonts-liberation \
fonts-noto-color-emoji \
git \
jq \
novnc \
python3 \
socat \
websockify \
x11vnc \
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
EXPOSE 9222 5900 6080
CMD ["openclaw-sandbox-browser"]

View File

@ -1,45 +0,0 @@
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
FROM ${BASE_IMAGE}
USER root
ENV DEBIAN_FRONTEND=noninteractive
ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
ARG INSTALL_PNPM=1
ARG INSTALL_BUN=1
ARG BUN_INSTALL_DIR=/opt/bun
ARG INSTALL_BREW=1
ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew
ARG FINAL_USER=sandbox
ENV BUN_INSTALL=${BUN_INSTALL_DIR}
ENV HOMEBREW_PREFIX=${BREW_INSTALL_DIR}
ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ${PACKAGES} \
&& rm -rf /var/lib/apt/lists/*
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
RUN if [ "${INSTALL_BUN}" = "1" ]; then \
curl -fsSL https://bun.sh/install | bash; \
ln -sf "${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \
fi
RUN if [ "${INSTALL_BREW}" = "1" ]; then \
if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \
mkdir -p "${BREW_INSTALL_DIR}"; \
chown -R linuxbrew:linuxbrew "$(dirname "${BREW_INSTALL_DIR}")"; \
su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \
if [ ! -e "${BREW_INSTALL_DIR}/Library" ]; then ln -s "${BREW_INSTALL_DIR}/Homebrew/Library" "${BREW_INSTALL_DIR}/Library"; fi; \
if [ ! -x "${BREW_INSTALL_DIR}/bin/brew" ]; then echo \"brew install failed\"; exit 1; fi; \
ln -sf "${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \
fi
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
USER ${FINAL_USER}

View File

@ -24,7 +24,7 @@
</p>
<p align="center">
<a href="https://denchclaw.sh">Website</a> · <a href="https://docs.openclaw.ai">Docs</a> · <a href="https://github.com/openclaw/openclaw">OpenClaw Framework</a> · <a href="https://discord.gg/clawd">Discord</a> · <a href="https://clawhub.com">Skills Store</a>
<a href="https://denchclaw.com">Website</a> · <a href="https://docs.openclaw.ai">Docs</a> · <a href="https://github.com/openclaw/openclaw">OpenClaw Framework</a> · <a href="https://discord.gg/clawd">Discord</a> · <a href="https://clawhub.com">Skills Store</a>
</p>
---

View File

@ -1,137 +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.
## Maintainers: GHSA Updates via CLI
When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200.
## Out of Scope
- Public Internet Exposure
- Using OpenClaw in ways that the docs recommend not to
- Deployments where mutually untrusted/adversarial operators share one gateway host and config
- Prompt injection attacks
## Deployment Assumptions
OpenClaw security guidance assumes:
- The host where OpenClaw runs is within a trusted OS/admin boundary.
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
## Plugin Trust Boundary
Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code.
- Plugins can execute with the same OS privileges as the OpenClaw process.
- Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary.
- Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids.
## Operational Guidance
For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see:
- `https://docs.openclaw.ai/gateway/security`
### Tool filesystem hardening
- `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory.
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory.
- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution.
### Web Interface Safety
OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**.
- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`).
- Config: `gateway.bind="loopback"` (default).
- CLI: `openclaw gateway run --bind loopback`.
- Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet).
- Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls.
- Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`.
- This deployment model alone is not a security vulnerability.
- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.
## 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
```

View File

@ -1,54 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: macos-latest
defaults:
run:
shell: bash
working-directory: swabble
steps:
- name: Checkout swabble
uses: actions/checkout@v4
with:
path: swabble
- name: Select Xcode 26.1 (prefer 26.1.1)
run: |
set -euo pipefail
# pick the newest installed 26.1.x, fallback to newest 26.x
CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)"
if [[ -z "$CANDIDATE" ]]; then
CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)"
fi
if [[ -z "$CANDIDATE" ]]; then
echo "No Xcode 26.x found on runner" >&2
exit 1
fi
echo "Selecting $CANDIDATE"
sudo xcode-select -s "$CANDIDATE"
xcodebuild -version
- name: Show Swift version
run: swift --version
- name: Install tooling
run: |
brew update
brew install swiftlint swiftformat
- name: Format check
run: |
./scripts/format.sh
git diff --exit-code
- name: Lint
run: ./scripts/lint.sh
- name: Test
run: swift test --parallel

33
Swabble/.gitignore vendored
View File

@ -1,33 +0,0 @@
# macOS
.DS_Store
# SwiftPM / Build
/.build
/.swiftpm
/DerivedData
xcuserdata/
*.xcuserstate
# Editors
/.vscode
.idea/
# Xcode artifacts
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
# Playgrounds
*.xcplayground
playground.xcworkspace
timeline.xctimeline
# Carthage
Carthage/Build/
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output

View File

@ -1,8 +0,0 @@
--swiftversion 6.2
--indent 4
--maxwidth 120
--wraparguments before-first
--wrapcollections before-first
--stripunusedargs closure-only
--self remove
--header ""

View File

@ -1,43 +0,0 @@
# SwiftLint for swabble
included:
- Sources
excluded:
- .build
- DerivedData
- "**/.swiftpm"
- "**/.build"
- "**/DerivedData"
- "**/.DS_Store"
opt_in_rules:
- array_init
- closure_spacing
- explicit_init
- fatal_error_message
- first_where
- joined_default_parameter
- last_where
- literal_expression_end_indentation
- multiline_arguments
- multiline_parameters
- operator_usage_whitespace
- redundant_nil_coalescing
- sorted_first_last
- switch_case_alignment
- vertical_parameter_alignment_on_call
- vertical_whitespace_opening_braces
- vertical_whitespace_closing_braces
disabled_rules:
- trailing_whitespace
- trailing_newline
- indentation_width
- identifier_name
- explicit_self
- file_header
- todo
line_length:
warning: 140
error: 180
reporter: "xcode"

View File

@ -1,11 +0,0 @@
# Changelog
## 0.2.0 — 2025-12-23
### Highlights
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
### Changes
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,69 +0,0 @@
{
"originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72",
"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",
"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",
"location" : "https://github.com/swiftlang/swift-syntax.git",
"state" : {
"revision" : "0687f71944021d616d34d922343dcef086855920",
"version" : "600.0.1"
}
},
{
"identity" : "swift-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-testing",
"state" : {
"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
}

View File

@ -1,55 +0,0 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "swabble",
platforms: [
.macOS(.v15),
.iOS(.v17),
],
products: [
.library(name: "Swabble", targets: ["Swabble"]),
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
.executable(name: "swabble", targets: ["SwabbleCLI"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
],
targets: [
.target(
name: "Swabble",
path: "Sources/SwabbleCore",
swiftSettings: []),
.target(
name: "SwabbleKit",
path: "Sources/SwabbleKit",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "SwabbleCLI",
dependencies: [
"Swabble",
"SwabbleKit",
.product(name: "Commander", package: "Commander"),
],
path: "Sources/swabble"),
.testTarget(
name: "SwabbleKitTests",
dependencies: [
"SwabbleKit",
.product(name: "Testing", package: "swift-testing"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "swabbleTests",
dependencies: [
"Swabble",
.product(name: "Testing", package: "swift-testing"),
]),
],
swiftLanguageModes: [.v6])

View File

@ -1,111 +0,0 @@
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
- **Local-only**: Speech.framework on-device models; zero network usage.
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
- **Services**: launchd helper stubs for start/stop/install.
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
## Quick start
```bash
# Install deps
brew install swiftformat swiftlint
# Build
swift build
# Write default config (~/.config/swabble/config.json)
swift run swabble setup
# Run foreground daemon
swift run swabble serve
# Test your hook
swift run swabble test-hook "hello world"
# Transcribe a file to SRT
swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
```
## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
```swift
// Package.swift
dependencies: [
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
],
targets: [
.target(name: "MyApp", dependencies: [
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
]),
]
```
## CLI
- `serve` — foreground loop (mic → wake → hook)
- `transcribe <file>` — offline transcription (txt|srt)
- `test-hook "text"` — invoke configured hook
- `mic list|set <index>` — enumerate/select input device
- `setup` — write default config JSON
- `doctor` — check Speech auth & device availability
- `health` — prints `ok`
- `tail-log` — last 10 transcripts
- `status` — show wake state + recent transcripts
- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands)
- `start|stop|restart` — placeholders until full launchd wiring
All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable.
## Config
`~/.config/swabble/config.json` (auto-created by `setup`):
```json
{
"audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1},
"wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]},
"hook": {
"command": "",
"args": [],
"prefix": "Voice swabble from ${hostname}: ",
"cooldownSeconds": 1,
"minCharacters": 24,
"timeoutSeconds": 5,
"env": {}
},
"logging": {"level": "info", "format": "text"},
"transcripts": {"enabled": true, "maxEntries": 50},
"speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false}
}
```
- Config path override: `--config /path/to/config.json` on relevant commands.
- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`.
## Hook protocol
When a wake-gated transcript passes min_chars & cooldown, swabble runs:
```
<command> <args...> "<prefix><text>"
```
Environment variables:
- `SWABBLE_TEXT` — stripped transcript (wake word removed)
- `SWABBLE_PREFIX` — rendered prefix (hostname substituted)
- plus any `hook.env` key/values
## Speech pipeline
- `AVAudioEngine` tap → `BufferConverter``AnalyzerInput``SpeechAnalyzer` with a `SpeechTranscriber` module.
- Requests volatile + final results; the CLI uses text-only wake gating today.
- 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`)
- Tests: `swift test` (uses swift-testing package)
## Roadmap
- launchd control (load/bootout, PID + status socket)
- JSON logging + PII redaction toggle
- Stronger wake-word detection and control socket status/health

View File

@ -1,77 +0,0 @@
import Foundation
public struct SwabbleConfig: Codable, Sendable {
public struct Audio: Codable, Sendable {
public var deviceName: String = ""
public var deviceIndex: Int = -1
public var sampleRate: Double = 16000
public var channels: Int = 1
}
public struct Wake: Codable, Sendable {
public var enabled: Bool = true
public var word: String = "clawd"
public var aliases: [String] = ["claude"]
}
public struct Hook: Codable, Sendable {
public var command: String = ""
public var args: [String] = []
public var prefix: String = "Voice swabble from ${hostname}: "
public var cooldownSeconds: Double = 1
public var minCharacters: Int = 24
public var timeoutSeconds: Double = 5
public var env: [String: String] = [:]
}
public struct Logging: Codable, Sendable {
public var level: String = "info"
public var format: String = "text" // text|json placeholder
}
public struct Transcripts: Codable, Sendable {
public var enabled: Bool = true
public var maxEntries: Int = 50
}
public struct Speech: Codable, Sendable {
public var localeIdentifier: String = Locale.current.identifier
public var etiquetteReplacements: Bool = false
}
public var audio = Audio()
public var wake = Wake()
public var hook = Hook()
public var logging = Logging()
public var transcripts = Transcripts()
public var speech = Speech()
public static let defaultPath = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".config/swabble/config.json")
public init() {}
}
public enum ConfigError: Error {
case missingConfig
}
public enum ConfigLoader {
public static func load(at path: URL?) throws -> SwabbleConfig {
let url = path ?? SwabbleConfig.defaultPath
if !FileManager.default.fileExists(atPath: url.path) {
throw ConfigError.missingConfig
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(SwabbleConfig.self, from: data)
}
public static func save(_ config: SwabbleConfig, at path: URL?) throws {
let url = path ?? SwabbleConfig.defaultPath
let dir = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(config)
try data.write(to: url)
}
}

View File

@ -1,75 +0,0 @@
import Foundation
public struct HookJob: Sendable {
public let text: String
public let timestamp: Date
public init(text: String, timestamp: Date) {
self.text = text
self.timestamp = timestamp
}
}
public actor HookExecutor {
private let config: SwabbleConfig
private var lastRun: Date?
private let hostname: String
public init(config: SwabbleConfig) {
self.config = config
hostname = Host.current().localizedName ?? "host"
}
public func shouldRun() -> Bool {
guard config.hook.cooldownSeconds > 0 else { return true }
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
return false
}
return true
}
public func run(job: HookJob) async throws {
guard shouldRun() else { return }
guard !config.hook.command.isEmpty else { throw NSError(
domain: "Hook",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname)
let payload = prefix + job.text
let process = Process()
process.executableURL = URL(fileURLWithPath: config.hook.command)
process.arguments = config.hook.args + [payload]
var env = ProcessInfo.processInfo.environment
env["SWABBLE_TEXT"] = job.text
env["SWABBLE_PREFIX"] = prefix
for (k, v) in config.hook.env {
env[k] = v
}
process.environment = env
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000)
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
process.waitUntilExit()
}
group.addTask {
try await Task.sleep(nanoseconds: timeoutNanos)
if process.isRunning {
process.terminate()
}
}
try await group.next()
group.cancelAll()
}
lastRun = Date()
}
}

View File

@ -1,50 +0,0 @@
@preconcurrency import AVFoundation
import Foundation
final class BufferConverter {
private final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
enum ConverterError: Swift.Error {
case failedToCreateConverter
case failedToCreateConversionBuffer
case conversionFailed(NSError?)
}
private var converter: AVAudioConverter?
func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
let inputFormat = buffer.format
if inputFormat == format {
return buffer
}
if converter == nil || converter?.outputFormat != format {
converter = AVAudioConverter(from: inputFormat, to: format)
converter?.primeMethod = .none
}
guard let converter else { throw ConverterError.failedToCreateConverter }
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
else {
throw ConverterError.failedToCreateConversionBuffer
}
var nsError: NSError?
let consumed = Box(false)
let inputBuffer = buffer
let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in
if consumed.value {
statusPtr.pointee = .noDataNow
return nil
}
consumed.value = true
statusPtr.pointee = .haveData
return inputBuffer
}
if status == .error {
throw ConverterError.conversionFailed(nsError)
}
return conversionBuffer
}
}

View File

@ -1,114 +0,0 @@
import AVFoundation
import Foundation
import Speech
@available(macOS 26.0, iOS 26.0, *)
public struct SpeechSegment: Sendable {
public let text: String
public let isFinal: Bool
}
@available(macOS 26.0, iOS 26.0, *)
public enum SpeechPipelineError: Error {
case authorizationDenied
case analyzerFormatUnavailable
case transcriberUnavailable
}
/// Live microphone SpeechAnalyzer SpeechTranscriber pipeline.
@available(macOS 26.0, iOS 26.0, *)
public actor SpeechPipeline {
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
private var engine = AVAudioEngine()
private var transcriber: SpeechTranscriber?
private var analyzer: SpeechAnalyzer?
private var inputContinuation: AsyncStream<AnalyzerInput>.Continuation?
private var resultTask: Task<Void, Never>?
private let converter = BufferConverter()
public init() {}
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
let auth = await requestAuthorizationIfNeeded()
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
let transcriberModule = SpeechTranscriber(
locale: Locale(identifier: localeIdentifier),
transcriptionOptions: etiquette ? [.etiquetteReplacements] : [],
reportingOptions: [.volatileResults],
attributeOptions: [])
transcriber = transcriberModule
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
else {
throw SpeechPipelineError.analyzerFormatUnavailable
}
analyzer = SpeechAnalyzer(modules: [transcriberModule])
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
inputContinuation = continuation
let inputNode = engine.inputNode
let inputFormat = inputNode.outputFormat(forBus: 0)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
guard let self else { return }
let boxed = UnsafeBuffer(buffer: buffer)
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
}
engine.prepare()
try engine.start()
try await analyzer?.start(inputSequence: stream)
guard let transcriberForStream = transcriber else {
throw SpeechPipelineError.transcriberUnavailable
}
return AsyncStream { continuation in
self.resultTask = Task {
do {
for try await result in transcriberForStream.results {
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
continuation.yield(seg)
}
} catch {
// swallow errors and finish
}
continuation.finish()
}
continuation.onTermination = { _ in
Task { await self.stop() }
}
}
}
public func stop() async {
resultTask?.cancel()
inputContinuation?.finish()
engine.inputNode.removeTap(onBus: 0)
engine.stop()
try? await analyzer?.finalizeAndFinishThroughEndOfInput()
}
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
do {
let converted = try converter.convert(buffer, to: targetFormat)
let input = AnalyzerInput(buffer: converted)
inputContinuation?.yield(input)
} catch {
// drop on conversion failure
}
}
private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus {
let current = SFSpeechRecognizer.authorizationStatus()
guard current == .notDetermined else { return current }
return await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
}

View File

@ -1,62 +0,0 @@
import CoreMedia
import Foundation
import NaturalLanguage
extension AttributedString {
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
let tokenizer = NLTokenizer(unit: .sentence)
let string = String(characters)
tokenizer.string = string
let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map {
(
$0,
AttributedString.Index($0.lowerBound, within: self)!
..<
AttributedString.Index($0.upperBound, within: self)!)
}
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
let sentence = self[sentenceRange]
guard let maxLength, sentence.characters.count > maxLength else {
return [sentenceRange]
}
let wordTokenizer = NLTokenizer(unit: .word)
wordTokenizer.string = string
var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map {
AttributedString.Index($0.lowerBound, within: self)!
..<
AttributedString.Index($0.upperBound, within: self)!
}
guard !wordRanges.isEmpty else { return [sentenceRange] }
wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound
wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound
var ranges: [Range<AttributedString.Index>] = []
for wordRange in wordRanges {
if let lastRange = ranges.last,
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
} else {
ranges.append(wordRange)
}
}
return ranges
}
return ranges.compactMap { range in
let audioTimeRanges = self[range].runs.filter {
!String(self[$0.range].characters)
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}.compactMap(\.audioTimeRange)
guard !audioTimeRanges.isEmpty else { return nil }
let start = audioTimeRanges.first!.start
let end = audioTimeRanges.last!.end
var attributes = AttributeContainer()
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
start: start,
end: end)
return AttributedString(self[range].characters, attributes: attributes)
}
}
}

View File

@ -1,41 +0,0 @@
import Foundation
public enum LogLevel: String, Comparable, CaseIterable, Sendable {
case trace, debug, info, warn, error
var rank: Int {
switch self {
case .trace: 0
case .debug: 1
case .info: 2
case .warn: 3
case .error: 4
}
}
public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank }
}
public struct Logger: Sendable {
public let level: LogLevel
public init(level: LogLevel) { self.level = level }
public func log(_ level: LogLevel, _ message: String) {
guard level >= self.level else { return }
let ts = ISO8601DateFormatter().string(from: Date())
print("[\(level.rawValue.uppercased())] \(ts) | \(message)")
}
public func trace(_ msg: String) { log(.trace, msg) }
public func debug(_ msg: String) { log(.debug, msg) }
public func info(_ msg: String) { log(.info, msg) }
public func warn(_ msg: String) { log(.warn, msg) }
public func error(_ msg: String) { log(.error, msg) }
}
extension LogLevel {
public init?(configValue: String) {
self.init(rawValue: configValue.lowercased())
}
}

View File

@ -1,45 +0,0 @@
import CoreMedia
import Foundation
public enum OutputFormat: String {
case txt
case srt
public var needsAudioTimeRange: Bool {
switch self {
case .srt: true
default: false
}
}
public func text(for transcript: AttributedString, maxLength: Int) -> String {
switch self {
case .txt:
return String(transcript.characters)
case .srt:
func format(_ timeInterval: TimeInterval) -> String {
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
let s = Int(timeInterval) % 60
let m = (Int(timeInterval) / 60) % 60
let h = Int(timeInterval) / 60 / 60
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
}
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
CMTimeRange,
String)? in
guard let timeRange = sentence.audioTimeRange else { return nil }
return (timeRange, String(sentence.characters))
}.enumerated().map { index, run in
let (timeRange, text) = run
return """
\(index + 1)
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
"""
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}

View File

@ -1,45 +0,0 @@
import Foundation
public actor TranscriptsStore {
public static let shared = TranscriptsStore()
private var entries: [String] = []
private let limit = 100
private let fileURL: URL
public init() {
let dir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
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) {
entries = text.split(separator: "\n").map(String.init).suffix(limit)
}
}
public func append(text: String) {
entries.append(text)
if entries.count > limit {
entries.removeFirst(entries.count - limit)
}
let body = entries.joined(separator: "\n")
try? body.write(to: fileURL, atomically: false, encoding: .utf8)
}
public func latest() -> [String] { entries }
}
extension String {
private func appendLine(to url: URL) throws {
let data = (self + "\n").data(using: .utf8) ?? Data()
if FileManager.default.fileExists(atPath: url.path) {
let handle = try FileHandle(forWritingTo: url)
try handle.seekToEnd()
try handle.write(contentsOf: data)
try handle.close()
} else {
try data.write(to: url)
}
}
}

View File

@ -1,197 +0,0 @@
import Foundation
public struct WakeWordSegment: Sendable, Equatable {
public let text: String
public let start: TimeInterval
public let duration: TimeInterval
public let range: Range<String.Index>?
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
self.text = text
self.start = start
self.duration = duration
self.range = range
}
public var end: TimeInterval { start + duration }
}
public struct WakeWordGateConfig: Sendable, Equatable {
public var triggers: [String]
public var minPostTriggerGap: TimeInterval
public var minCommandLength: Int
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1) {
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
}
}
public struct WakeWordGateMatch: Sendable, Equatable {
public let triggerEndTime: TimeInterval
public let postGap: TimeInterval
public let command: String
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
self.triggerEndTime = triggerEndTime
self.postGap = postGap
self.command = command
}
}
public enum WakeWordGate {
private struct Token {
let normalized: String
let start: TimeInterval
let end: TimeInterval
let range: Range<String.Index>?
let text: String
}
private struct TriggerTokens {
let tokens: [String]
}
private struct MatchCandidate {
let index: Int
let triggerEnd: TimeInterval
let gap: TimeInterval
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
config: WakeWordGateConfig)
-> WakeWordGateMatch? {
let triggerTokens = normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil }
let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: MatchCandidate?
for trigger in triggerTokens {
let count = trigger.tokens.count
guard count > 0, tokens.count > count else { continue }
for i in 0...(tokens.count - count - 1) {
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
if !matched { continue }
let triggerEnd = tokens[i + count - 1].end
let nextToken = tokens[i + count]
let gap = nextToken.start - triggerEnd
if gap < config.minPostTriggerGap { continue }
if let best, i <= best.index { continue }
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
}
}
guard let best else { return nil }
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
}
public static func commandText(
transcript: String,
segments: [WakeWordSegment],
triggerEndTime: TimeInterval)
-> String {
let threshold = triggerEndTime + 0.001
for segment in segments where segment.start >= threshold {
if normalizeToken(segment.text).isEmpty { continue }
if let range = segment.range {
let slice = transcript[range.lowerBound...]
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
break
}
let text = segments
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty }
.map(\.text)
.joined(separator: " ")
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue }
if normalized.contains(token) { return true }
}
return false
}
public static func stripWake(text: String, triggers: [String]) -> String {
var out = text
for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: whitespaceAndPunctuation)
}
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
var output: [TriggerTokens] = []
for trigger in triggers {
let tokens = trigger
.split(whereSeparator: { $0.isWhitespace })
.map { normalizeToken(String($0)) }
.filter { !$0.isEmpty }
if tokens.isEmpty { continue }
output.append(TriggerTokens(tokens: tokens))
}
return output
}
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in
let normalized = normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil }
return Token(
normalized: normalized,
start: segment.start,
end: segment.end,
range: segment.range,
text: segment.text)
}
}
private static func normalizeToken(_ token: String) -> String {
token
.trimmingCharacters(in: whitespaceAndPunctuation)
.lowercased()
}
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
}
#if canImport(Speech)
import Speech
public enum WakeWordSpeechSegments {
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
transcription.segments.map { segment in
let range = Range(segment.substringRange, in: transcript)
return WakeWordSegment(
text: segment.substring,
start: segment.timestamp,
duration: segment.duration,
range: range)
}
}
}
#endif

View File

@ -1,71 +0,0 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
enum CLIRegistry {
static var descriptors: [CommandDescriptor] {
let serveDesc = descriptor(for: ServeCommand.self)
let transcribeDesc = descriptor(for: TranscribeCommand.self)
let testHookDesc = descriptor(for: TestHookCommand.self)
let micList = descriptor(for: MicList.self)
let micSet = descriptor(for: MicSet.self)
let micRoot = CommandDescriptor(
name: "mic",
abstract: "Microphone management",
discussion: nil,
signature: CommandSignature(),
subcommands: [micList, micSet])
let serviceRoot = CommandDescriptor(
name: "service",
abstract: "launchd helper",
discussion: nil,
signature: CommandSignature(),
subcommands: [
descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self)
])
let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self)
let healthDesc = descriptor(for: HealthCommand.self)
let tailLogDesc = descriptor(for: TailLogCommand.self)
let startDesc = descriptor(for: StartCommand.self)
let stopDesc = descriptor(for: StopCommand.self)
let restartDesc = descriptor(for: RestartCommand.self)
let statusDesc = descriptor(for: StatusCommand.self)
let rootSignature = CommandSignature().withStandardRuntimeFlags()
let root = CommandDescriptor(
name: "swabble",
abstract: "Speech hook daemon",
discussion: "Local wake-word → SpeechTranscriber → hook",
signature: rootSignature,
subcommands: [
serveDesc,
transcribeDesc,
testHookDesc,
micRoot,
serviceRoot,
doctorDesc,
setupDesc,
healthDesc,
tailLogDesc,
startDesc,
stopDesc,
restartDesc,
statusDesc
])
return [root]
}
private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor {
let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags()
return CommandDescriptor(
name: type.commandDescription.commandName ?? "",
abstract: type.commandDescription.abstract,
discussion: type.commandDescription.discussion,
signature: sig,
subcommands: [])
}
}

View File

@ -1,37 +0,0 @@
import Commander
import Foundation
import Speech
import Swabble
@MainActor
struct DoctorCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let auth = await SFSpeechRecognizer.authorizationStatus()
print("Speech auth: \(auth)")
do {
_ = try ConfigLoader.load(at: configURL)
print("Config: OK")
} catch {
print("Config missing or invalid; run setup")
}
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.microphone, .external],
mediaType: .audio,
position: .unspecified)
print("Mics found: \(session.devices.count)")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@ -1,16 +0,0 @@
import Commander
import Foundation
@MainActor
struct HealthCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "health", abstract: "Health probe")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
print("ok")
}
}

View File

@ -1,62 +0,0 @@
import AVFoundation
import Commander
import Foundation
import Swabble
@MainActor
struct MicCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "mic",
abstract: "Microphone management",
subcommands: [MicList.self, MicSet.self])
}
}
@MainActor
struct MicList: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "list", abstract: "List input devices")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.microphone, .external],
mediaType: .audio,
position: .unspecified)
let devices = session.devices
if devices.isEmpty { print("no audio inputs found"); return }
for (idx, device) in devices.enumerated() {
print("[\(idx)] \(device.localizedName)")
}
}
}
@MainActor
struct MicSet: ParsableCommand {
@Argument(help: "Device index from list") var index: Int = 0
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
static var commandDescription: CommandDescription {
CommandDescription(commandName: "set", abstract: "Set default input device index")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let value = parsed.positional.first, let intVal = Int(value) { index = intVal }
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
var cfg = try ConfigLoader.load(at: configURL)
cfg.audio.deviceIndex = index
try ConfigLoader.save(cfg, at: configURL)
print("saved device index \(index)")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@ -1,81 +0,0 @@
import Commander
import Foundation
import Swabble
import SwabbleKit
@available(macOS 26.0, *)
@MainActor
struct ServeCommand: ParsableCommand {
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
@Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "serve",
abstract: "Run swabble in the foreground")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if parsed.flags.contains("noWake") { noWake = true }
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
var cfg: SwabbleConfig
do {
cfg = try ConfigLoader.load(at: configURL)
} catch {
cfg = SwabbleConfig()
try ConfigLoader.save(cfg, at: configURL)
}
if noWake {
cfg.wake.enabled = false
}
let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info)
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
let pipeline = SpeechPipeline()
do {
let stream = try await pipeline.start(
localeIdentifier: cfg.speech.localeIdentifier,
etiquette: cfg.speech.etiquetteReplacements)
for await seg in stream {
if cfg.wake.enabled {
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
}
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
let job = HookJob(text: stripped, timestamp: Date())
let executor = HookExecutor(config: cfg)
try await executor.run(job: job)
if cfg.transcripts.enabled {
await TranscriptsStore.shared.append(text: stripped)
}
if seg.isFinal {
logger.info("final: \(stripped)")
} else {
logger.debug("partial: \(stripped)")
}
}
} catch {
logger.error("serve error: \(error)")
throw error
}
}
private var configURL: URL? {
configPath.map { URL(fileURLWithPath: $0) }
}
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
}
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.stripWake(text: text, triggers: triggers)
}
}

View File

@ -1,77 +0,0 @@
import Commander
import Foundation
@MainActor
struct ServiceRootCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "service",
abstract: "Manage launchd agent",
subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self])
}
}
private enum LaunchdHelper {
static let label = "com.swabble.agent"
static var plistURL: URL {
FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
}
static func writePlist(executable: String) throws {
let plist: [String: Any] = [
"Label": label,
"ProgramArguments": [executable, "serve"],
"RunAtLoad": true,
"KeepAlive": true
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: plistURL)
}
static func removePlist() throws {
try? FileManager.default.removeItem(at: plistURL)
}
}
@MainActor
struct ServiceInstall: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "install", abstract: "Install user launch agent")
}
mutating func run() async throws {
let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble"
try LaunchdHelper.writePlist(executable: exe)
print("launchctl load -w \(LaunchdHelper.plistURL.path)")
}
}
@MainActor
struct ServiceUninstall: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "uninstall", abstract: "Remove launch agent")
}
mutating func run() async throws {
try LaunchdHelper.removePlist()
print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)")
}
}
@MainActor
struct ServiceStatus: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "status", abstract: "Show launch agent status")
}
mutating func run() async throws {
if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) {
print("plist present at \(LaunchdHelper.plistURL.path)")
} else {
print("launchd plist not installed")
}
}
}

View File

@ -1,26 +0,0 @@
import Commander
import Foundation
import Swabble
@MainActor
struct SetupCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "setup", abstract: "Write default config")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let cfg = SwabbleConfig()
try ConfigLoader.save(cfg, at: configURL)
print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@ -1,35 +0,0 @@
import Commander
import Foundation
@MainActor
struct StartCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)")
}
mutating func run() async throws {
print("start: launchd helper not implemented; run 'swabble serve' instead")
}
}
@MainActor
struct StopCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)")
}
mutating func run() async throws {
print("stop: launchd helper not implemented yet")
}
}
@MainActor
struct RestartCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)")
}
mutating func run() async throws {
print("restart: launchd helper not implemented yet")
}
}

View File

@ -1,34 +0,0 @@
import Commander
import Foundation
import Swabble
@MainActor
struct StatusCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "status", abstract: "Show daemon state")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let cfg = try? ConfigLoader.load(at: configURL)
let wake = cfg?.wake.word ?? "clawd"
let wakeEnabled = cfg?.wake.enabled ?? false
let latest = await TranscriptsStore.shared.latest().suffix(3)
print("wake: \(wakeEnabled ? wake : "disabled")")
if latest.isEmpty {
print("transcripts: (none yet)")
} else {
print("last transcripts:")
latest.forEach { print("- \($0)") }
}
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@ -1,20 +0,0 @@
import Commander
import Foundation
import Swabble
@MainActor
struct TailLogCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
let latest = await TranscriptsStore.shared.latest()
for line in latest.suffix(10) {
print(line)
}
}
}

View File

@ -1,30 +0,0 @@
import Commander
import Foundation
import Swabble
@MainActor
struct TestHookCommand: ParsableCommand {
@Argument(help: "Text to send to hook") var text: String
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
static var commandDescription: CommandDescription {
CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let positional = parsed.positional.first { text = positional }
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let cfg = try ConfigLoader.load(at: configURL)
let executor = HookExecutor(config: cfg)
try await executor.run(job: HookJob(text: text, timestamp: Date()))
print("hook invoked")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@ -1,61 +0,0 @@
import AVFoundation
import Commander
import Foundation
import Speech
import Swabble
@MainActor
struct TranscribeCommand: ParsableCommand {
@Argument(help: "Path to audio/video file") var inputFile: String = ""
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
.identifier
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
@Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "transcribe",
abstract: "Transcribe a media file locally")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let positional = parsed.positional.first { inputFile = positional }
if let loc = parsed.options["locale"]?.last { locale = loc }
if parsed.flags.contains("censor") { censor = true }
if let out = parsed.options["output"]?.last { outputFile = out }
if let fmt = parsed.options["format"]?.last { format = fmt }
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal }
}
mutating func run() async throws {
let fileURL = URL(fileURLWithPath: inputFile)
let audioFile = try AVAudioFile(forReading: fileURL)
let outputFormat = OutputFormat(rawValue: format) ?? .txt
let transcriber = SpeechTranscriber(
locale: Locale(identifier: locale),
transcriptionOptions: censor ? [.etiquetteReplacements] : [],
reportingOptions: [],
attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [])
let analyzer = SpeechAnalyzer(modules: [transcriber])
try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true)
var transcript: AttributedString = ""
for try await result in transcriber.results {
transcript += result.text
}
let output = outputFormat.text(for: transcript, maxLength: maxLength)
if let path = outputFile {
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
} else {
print(output)
}
}
}

View File

@ -1,151 +0,0 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
private func runCLI() async -> Int32 {
do {
let descriptors = CLIRegistry.descriptors
let program = Program(descriptors: descriptors)
let invocation = try program.resolve(argv: CommandLine.arguments)
try await dispatch(invocation: invocation)
return 0
} catch {
fputs("error: \(error)\n", stderr)
return 1
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatch(invocation: CommandInvocation) async throws {
let parsed = invocation.parsedValues
let path = invocation.path
guard let first = path.first else { throw CommanderProgramError.missingCommand }
switch first {
case "swabble":
try await dispatchSwabble(parsed: parsed, path: path)
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)
} else {
fputs("error: swabble requires macOS 26 or newer\n", stderr)
exit(1)
}

View File

@ -1,63 +0,0 @@
import Foundation
import SwabbleKit
import Testing
@Suite struct WakeWordGateTests {
@Test func matchRequiresGapAfterTrigger() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func matchAllowsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do thing")
}
@Test func matchHandlesMultiWordTriggers() {
let transcript = "hey clawd do it"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.8, 0.1),
("it", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do it")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@ -1,23 +0,0 @@
import Foundation
import Testing
@testable import Swabble
@Test
func configRoundTrip() throws {
var cfg = SwabbleConfig()
cfg.wake.word = "robot"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
defer { try? FileManager.default.removeItem(at: url) }
try ConfigLoader.save(cfg, at: url)
let loaded = try ConfigLoader.load(at: url)
#expect(loaded.wake.word == "robot")
#expect(loaded.hook.prefix.contains("Voice swabble"))
}
@Test
func configMissingThrows() {
#expect(throws: ConfigError.missingConfig) {
_ = try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json"))
}
}

View File

@ -1,33 +0,0 @@
# swabble — macOS 26 speech hook daemon (Swift 6.2)
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
## Requirements
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
- Local only; no network calls during transcription.
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
- Foreground `serve`; later launchd helper for start/stop/restart.
- File transcription command emitting txt or srt.
- Basic status/health surfaces and mic selection stubs.
## Architecture
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
- **Logging**: simple structured logger to stderr; respects log level.
## Out of scope (initial cut)
- Model management (Speech handles assets).
- Launchd helper (planned follow-up).
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
## Open decisions
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
- Hook redaction (PII) parity with brabble — placeholder boolean, no implementation yet.

View File

@ -1,5 +0,0 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG="${ROOT}/.swiftformat"
swiftformat --config "$CONFIG" "$ROOT/Sources"

View File

@ -1,9 +0,0 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG="${ROOT}/.swiftlint.yml"
if ! command -v swiftlint >/dev/null; then
echo "swiftlint not installed" >&2
exit 1
fi
swiftlint --config "$CONFIG"

110
VISION.md
View File

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

View File

@ -1,363 +0,0 @@
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.2.14</title>
<pubDate>Sun, 15 Feb 2026 04:24:34 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>202602140</sparkle:version>
<sparkle:shortVersionString>2026.2.14</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.14</h2>
<h3>Changes</h3>
<ul>
<li>Telegram: add poll sending via <code>openclaw message poll</code> (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.</li>
<li>Slack/Discord: add <code>dmPolicy</code> + <code>allowFrom</code> config aliases for DM access control; legacy <code>dm.policy</code> + <code>dm.allowFrom</code> keys remain supported and <code>openclaw doctor --fix</code> can migrate them.</li>
<li>Discord: allow exec approval prompts to target channels or both DM+channel via <code>channels.discord.execApprovals.target</code>. (#16051) Thanks @leonnardo.</li>
<li>Sandbox: add <code>sandbox.browser.binds</code> to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.</li>
<li>Discord: add debug logging for message routing decisions to improve <code>--debug</code> tracing. (#16202) Thanks @jayleekr.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>CLI/Plugins: ensure <code>openclaw message send</code> exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.</li>
<li>CLI/Plugins: run registered plugin <code>gateway_stop</code> hooks before <code>openclaw message</code> exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.</li>
<li>WhatsApp: honor per-account <code>dmPolicy</code> overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.</li>
<li>Telegram: when <code>channels.telegram.commands.native</code> is <code>false</code>, exclude plugin commands from <code>setMyCommands</code> menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.</li>
<li>LINE: return 200 OK for Developers Console "Verify" requests (<code>{"events":[]}</code>) without <code>X-Line-Signature</code>, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.</li>
<li>Cron: deliver text-only output directly when <code>delivery.to</code> is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.</li>
<li>Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.</li>
<li>Media: accept <code>MEDIA:</code>-prefixed paths (lenient whitespace) when loading outbound media to prevent <code>ENOENT</code> for tool-returned local media paths. (#13107) Thanks @mcaxtr.</li>
<li>Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.</li>
<li>Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)</li>
<li>Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit <code>workspaceDir</code>. (#16722)</li>
<li>BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.</li>
<li>CLI: fix lazy core command registration so top-level maintenance commands (<code>doctor</code>, <code>dashboard</code>, <code>reset</code>, <code>uninstall</code>) resolve correctly instead of exposing a non-functional <code>maintenance</code> placeholder command.</li>
<li>CLI/Dashboard: when <code>gateway.bind=lan</code>, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.</li>
<li>TUI/Gateway: resolve local gateway target URL from <code>gateway.bind</code> mode (tailnet/lan) instead of hardcoded localhost so <code>openclaw tui</code> connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.</li>
<li>TUI: honor explicit <code>--session <key></code> in <code>openclaw tui</code> even when <code>session.scope</code> is <code>global</code>, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.</li>
<li>TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.</li>
<li>TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.</li>
<li>TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.</li>
<li>TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.</li>
<li>TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.</li>
<li>TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.</li>
<li>TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.</li>
<li>TUI/Hooks: pass explicit reset reason (<code>new</code> vs <code>reset</code>) through <code>sessions.reset</code> and emit internal command hooks for gateway-triggered resets so <code>/new</code> hook workflows fire in TUI/webchat.</li>
<li>Cron: prevent <code>cron list</code>/<code>cron status</code> from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.</li>
<li>Cron: repair missing/corrupt <code>nextRunAtMs</code> for the updated job without globally recomputing unrelated due jobs during <code>cron update</code>. (#15750)</li>
<li>Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale <code>runningAtMs</code> markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.</li>
<li>Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as <code>guild=dm</code>. Thanks @thewilloftheshadow.</li>
<li>Discord: treat empty per-guild <code>channels: {}</code> config maps as no channel allowlist (not deny-all), so <code>groupPolicy: "open"</code> guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.</li>
<li>Models/CLI: guard <code>models status</code> string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.</li>
<li>Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.</li>
<li>Gateway/Sessions: abort active embedded runs and clear queued session work before <code>sessions.reset</code>, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.</li>
<li>Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.</li>
<li>Agents: add a safety timeout around embedded <code>session.compact()</code> to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.</li>
<li>Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including <code>session_status</code> model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.</li>
<li>Agents/Process/Bootstrap: preserve unbounded <code>process log</code> offset-only pagination (default tail applies only when both <code>offset</code> and <code>limit</code> are omitted) and enforce strict <code>bootstrapTotalMaxChars</code> budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.</li>
<li>Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing <code>BOOTSTRAP.md</code> once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.</li>
<li>Agents/Workspace: create <code>BOOTSTRAP.md</code> when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.</li>
<li>Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.</li>
<li>Agents: treat empty-stream provider failures (<code>request ended without sending any chunks</code>) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.</li>
<li>Agents: treat <code>read</code> tool <code>file_path</code> arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.</li>
<li>Ollama/Agents: avoid forcing <code><final></code> tag enforcement for Ollama models, which could suppress all output as <code>(no output)</code>. (#16191) Thanks @Glucksberg.</li>
<li>Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.</li>
<li>Skills: watch <code>SKILL.md</code> only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.</li>
<li>Memory/QMD: make <code>memory status</code> read-only by skipping QMD boot update/embed side effects for status-only manager checks.</li>
<li>Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.</li>
<li>Memory/Builtin: keep <code>memory status</code> dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.</li>
<li>Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological <code>qmd</code> command output.</li>
<li>Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.</li>
<li>Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.</li>
<li>Memory/QMD: pass result limits to <code>search</code>/<code>vsearch</code> commands so QMD can cap results earlier.</li>
<li>Memory/QMD: avoid reading full markdown files when a <code>from/lines</code> window is requested in QMD reads.</li>
<li>Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.</li>
<li>Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy <code>stdout</code>.</li>
<li>Memory/QMD: treat prefixed <code>no results found</code> marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.</li>
<li>Memory/QMD: avoid multi-collection <code>query</code> ranking corruption by running one <code>qmd query -c <collection></code> per managed collection and merging by best score (also used for <code>search</code>/<code>vsearch</code> fallback-to-query). (#16740) Thanks @volarian-vai.</li>
<li>Memory/QMD: detect null-byte <code>ENOTDIR</code> update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.</li>
<li>Memory/QMD/Security: add <code>rawKeyPrefix</code> support for QMD scope rules and preserve legacy <code>keyPrefix: "agent:..."</code> matching, preventing scoped deny bypass when operators match agent-prefixed session keys.</li>
<li>Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.</li>
<li>Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.</li>
<li>Security/Memory-LanceDB: require explicit <code>autoCapture: true</code> opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.</li>
<li>Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.</li>
<li>Gateway/Memory: clean up <code>agentRunSeq</code> tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.</li>
<li>Auto-reply/Memory: bound <code>ABORT_MEMORY</code> growth by evicting oldest entries and deleting reset (<code>false</code>) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.</li>
<li>Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.</li>
<li>Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.</li>
<li>Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.</li>
<li>Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.</li>
<li>Media/Security: allow local media reads from OpenClaw state <code>workspace/</code> and <code>sandboxes/</code> roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.</li>
<li>Media/Security: harden local media allowlist bypasses by requiring an explicit <code>readFile</code> override when callers mark paths as validated, and reject filesystem-root <code>localRoots</code> entries. (#16739)</li>
<li>Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.</li>
<li>Security/BlueBubbles: require explicit <code>mediaLocalRoots</code> allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.</li>
<li>Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.</li>
<li>Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.</li>
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
<li>Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.</li>
<li>Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.</li>
<li>Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.</li>
<li>Security/Hooks: restrict hook transform modules to <code>~/.openclaw/hooks/transforms</code> (prevents path traversal/escape module loads via config). Config note: <code>hooks.transformsDir</code> must now be within that directory. Thanks @akhmittra.</li>
<li>Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).</li>
<li>Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.</li>
<li>Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.</li>
<li>Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.</li>
<li>Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.</li>
<li>Security/Slack: compute command authorization for DM slash commands even when <code>dmPolicy=open</code>, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.</li>
<li>Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.</li>
<li>Security/Google Chat: deprecate <code>users/<email></code> allowlists (treat <code>users/...</code> as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.</li>
<li>Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.</li>
<li>Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject <code>@username</code> principals), auto-resolve <code>@username</code> to IDs in <code>openclaw doctor --fix</code> (when possible), and warn in <code>openclaw security audit</code> when legacy configs contain usernames. Thanks @vincentkoc.</li>
<li>Telegram/Security: reject Telegram webhook startup when <code>webhookSecret</code> is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.</li>
<li>Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).</li>
<li>Telegram: set webhook callback timeout handling to <code>onTimeout: "return"</code> (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.</li>
<li>Signal: preserve case-sensitive <code>group:</code> target IDs during normalization so mixed-case group IDs no longer fail with <code>Group not found</code>. (#16748) Thanks @repfigit.</li>
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
<li>Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.</li>
<li>Security/Agents: enforce workspace-root path bounds for <code>apply_patch</code> in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.</li>
<li>Security/Agents: enforce symlink-escape checks for <code>apply_patch</code> delete hunks under <code>workspaceOnly</code>, while still allowing deleting the symlink itself. Thanks @p80n-sec.</li>
<li>Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.</li>
<li>macOS: hard-limit unkeyed <code>openclaw://agent</code> deep links and ignore <code>deliver</code> / <code>to</code> / <code>channel</code> unless a valid unattended key is provided. Thanks @Cillian-Collins.</li>
<li>Scripts/Security: validate GitHub logins and avoid shell invocation in <code>scripts/update-clawtributors.ts</code> to prevent command injection via malicious commit records. Thanks @scanleale.</li>
<li>Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).</li>
<li>Security/Gateway: harden tool-supplied <code>gatewayUrl</code> overrides by restricting them to loopback or the configured <code>gateway.remote.url</code>. Thanks @p80n-sec.</li>
<li>Security/Gateway: block <code>system.execApprovals.*</code> via <code>node.invoke</code> (use <code>exec.approvals.node.*</code> instead). Thanks @christos-eth.</li>
<li>Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.</li>
<li>Security/Gateway: stop returning raw resolved config values in <code>skills.status</code> requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.</li>
<li>Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.</li>
<li>Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.</li>
<li>Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.</li>
<li>Security/Node Host: enforce <code>system.run</code> rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.</li>
<li>Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.</li>
<li>Security/Exec: harden PATH handling by disabling project-local <code>node_modules/.bin</code> bootstrapping by default, disallowing node-host <code>PATH</code> overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.</li>
<li>Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: <code>channels.tlon.allowPrivateNetwork</code>). Thanks @p80n-sec.</li>
<li>Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without <code>telnyx.publicKey</code> are now rejected unless <code>skipSignatureVerification</code> is enabled. Thanks @p80n-sec.</li>
<li>Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.</li>
<li>Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.14/OpenClaw-2026.2.14.zip" length="22914034" type="application/octet-stream" sparkle:edSignature="lR3nuq46/akMIN8RFDpMkTE0VOVoDVG53Xts589LryMGEtUvJxRQDtHBXfx7ZvToTq6CFKG+L5Kq/4rUspMoAQ=="/>
</item>
<item>
<title>2026.2.15</title>
<pubDate>Mon, 16 Feb 2026 05:04:34 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>202602150</sparkle:version>
<sparkle:shortVersionString>2026.2.15</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.15</h2>
<h3>Changes</h3>
<ul>
<li>Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.</li>
<li>Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.</li>
<li>Plugins: expose <code>llm_input</code> and <code>llm_output</code> hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.</li>
<li>Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set <code>agents.defaults.subagents.maxSpawnDepth: 2</code> to allow sub-agents to spawn their own children. Includes <code>maxChildrenPerAgent</code> limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.</li>
<li>Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.</li>
<li>Cron/Gateway: add finished-run webhook delivery toggle (<code>notify</code>) and dedicated webhook auth token support (<code>cron.webhookToken</code>) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.</li>
<li>Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.</li>
<li>Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.</li>
<li>Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.</li>
<li>Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.</li>
<li>Gateway/Security: redact sensitive session/path details from <code>status</code> responses for non-admin clients; full details remain available to <code>operator.admin</code>. (#8590) Thanks @fr33d3m0n.</li>
<li>Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (<code>allowInsecureAuth</code> / <code>dangerouslyDisableDeviceAuth</code>) when device identity is unavailable, preventing false <code>missing scope</code> failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.</li>
<li>LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.</li>
<li>Skills/Security: restrict <code>download</code> installer <code>targetDir</code> to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.</li>
<li>Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.</li>
<li>Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.</li>
<li>Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving <code>passwordFile</code> path exemptions, preventing accidental redaction of non-secret config values like <code>maxTokens</code> and IRC password-file paths. (#16042) Thanks @akramcodez.</li>
<li>Dev tooling: harden git <code>pre-commit</code> hook against option injection from malicious filenames (for example <code>--force</code>), preventing accidental staging of ignored files. Thanks @mrthankyou.</li>
<li>Gateway/Agent: reject malformed <code>agent:</code>-prefixed session keys (for example, <code>agent:main</code>) in <code>agent</code> and <code>agent.identity.get</code> instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.</li>
<li>Gateway/Chat: harden <code>chat.send</code> inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.</li>
<li>Gateway/Send: return an actionable error when <code>send</code> targets internal-only <code>webchat</code>, guiding callers to use <code>chat.send</code> or a deliverable channel. (#15703) Thanks @rodrigouroz.</li>
<li>Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing <code>script-src 'self'</code>. Thanks @Adam55A-code.</li>
<li>Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.</li>
<li>Agents/Sandbox: clarify system prompt path guidance so sandbox <code>bash/exec</code> uses container paths (for example <code>/workspace</code>) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.</li>
<li>Agents/Context: apply configured model <code>contextWindow</code> overrides after provider discovery so <code>lookupContextTokens()</code> honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.</li>
<li>Agents/Context: derive <code>lookupContextTokens()</code> from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.</li>
<li>Agents/OpenAI: force <code>store=true</code> for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.</li>
<li>Memory/FTS: make <code>buildFtsQuery</code> Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.</li>
<li>Auto-reply/Compaction: resolve <code>memory/YYYY-MM-DD.md</code> placeholders with timezone-aware runtime dates and append a <code>Current time:</code> line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.</li>
<li>Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.</li>
<li>Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.</li>
<li>Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.</li>
<li>Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.</li>
<li>Subagents/Models: preserve <code>agents.defaults.model.fallbacks</code> when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.</li>
<li>Telegram: omit <code>message_thread_id</code> for DM sends/draft previews and keep forum-topic handling (<code>id=1</code> general omitted, non-general kept), preventing DM failures with <code>400 Bad Request: message thread not found</code>. (#10942) Thanks @garnetlyx.</li>
<li>Telegram: replace inbound <code><media:audio></code> placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.</li>
<li>Telegram: retry inbound media <code>getFile</code> calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.</li>
<li>Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.</li>
<li>Discord: preserve channel session continuity when runtime payloads omit <code>message.channelId</code> by falling back to event/raw <code>channel_id</code> values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as <code>sessionKey=unknown</code>. (#17622) Thanks @shakkernerd.</li>
<li>Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with <code>_2</code> suffixes. (#17365) Thanks @seewhyme.</li>
<li>Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.</li>
<li>Web UI/Agents: hide <code>BOOTSTRAP.md</code> in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.</li>
<li>Auto-reply/WhatsApp/TUI/Web: when a final assistant message is <code>NO_REPLY</code> and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show <code>NO_REPLY</code> placeholders. (#7010) Thanks @Morrowind-Xie.</li>
<li>Cron: infer <code>payload.kind="agentTurn"</code> for model-only <code>cron.update</code> payload patches, so partial agent-turn updates do not fail validation when <code>kind</code> is omitted. (#15664) Thanks @rodrigouroz.</li>
<li>TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.</li>
<li>TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.</li>
<li>TUI: suppress false <code>(no output)</code> placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.</li>
<li>TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.</li>
<li>CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
</item>
<item>
<title>2026.2.21</title>
<pubDate>Sat, 21 Feb 2026 17:55:48 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>13056</sparkle:version>
<sparkle:shortVersionString>2026.2.21</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.21</h2>
<h3>Changes</h3>
<ul>
<li>Models/Google: add Gemini 3.1 support (<code>google/gemini-3.1-pro-preview</code>).</li>
<li>Providers/Onboarding: add Volcano Engine (Doubao) and BytePlus providers/models (including coding variants), wire onboarding auth choices for interactive + non-interactive flows, and align docs to <code>volcengine-api-key</code>. (#7967) Thanks @funmore123.</li>
<li>Channels/CLI: add per-account/channel <code>defaultTo</code> outbound routing fallback so <code>openclaw agent --deliver</code> can send without explicit <code>--reply-to</code> when a default target is configured. (#16985) Thanks @KirillShchetinin.</li>
<li>Channels: allow per-channel model overrides via <code>channels.modelByChannel</code> and note them in /status. Thanks @thewilloftheshadow.</li>
<li>Telegram/Streaming: simplify preview streaming config to <code>channels.telegram.streaming</code> (boolean), auto-map legacy <code>streamMode</code> values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus.</li>
<li>Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.</li>
<li>Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.</li>
<li>Discord/Voice: add voice channel join/leave/status via <code>/vc</code>, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.</li>
<li>Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei.</li>
<li>Discord: support updating forum <code>available_tags</code> via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201.</li>
<li>Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow.</li>
<li>Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc.</li>
<li>iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.</li>
<li>iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.</li>
<li>iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.</li>
<li>Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.</li>
<li>MSTeams: dedupe sent-message cache storage by removing duplicate per-message Set storage and using timestamps Map keys as the single membership source. (#22514) Thanks @TaKO8Ki.</li>
<li>Agents/Subagents: default subagent spawn depth now uses shared <code>maxSpawnDepth=2</code>, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.</li>
<li>Security/Agents: make owner-ID obfuscation use a dedicated HMAC secret from configuration (<code>ownerDisplaySecret</code>) and update hashing behavior so obfuscation is decoupled from gateway token handling for improved control. (#7343) Thanks @vincentkoc.</li>
<li>Security/Infra: switch gateway lock and tool-call synthetic IDs from SHA-1 to SHA-256 with unchanged truncation length to strengthen hash basis while keeping deterministic behavior and lock key format. (#7343) Thanks @vincentkoc.</li>
<li>Dependencies/Tooling: add non-blocking dead-code scans in CI via Knip/ts-prune/ts-unused-exports to surface unused dependencies and exports earlier. (#22468) Thanks @vincentkoc.</li>
<li>Dependencies/Unused Dependencies: remove or scope unused root and extension deps (<code>@larksuiteoapi/node-sdk</code>, <code>signal-utils</code>, <code>ollama</code>, <code>lit</code>, <code>@lit/context</code>, <code>@lit-labs/signals</code>, <code>@microsoft/agents-hosting-express</code>, <code>@microsoft/agents-hosting-extensions-teams</code>, and plugin-local <code>openclaw</code> devDeps in <code>extensions/open-prose</code>, <code>extensions/lobster</code>, and <code>extensions/llm-task</code>). (#22471, #22495) Thanks @vincentkoc.</li>
<li>Dependencies/A2UI: harden dependency resolution after root cleanup (resolve <code>lit</code>, <code>@lit/context</code>, <code>@lit-labs/signals</code>, and <code>signal-utils</code> from workspace/root) and simplify bundling fallback behavior, including <code>pnpm dlx rolldown</code> compatibility. (#22481, #22507) Thanks @vincentkoc.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit <code>retry_limit</code> error payload when retries never converge, preventing unbounded internal retry cycles (<code>GHSA-76m6-pj3w-v7mf</code>).</li>
<li>Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless <code>getUpdates</code> conflict loops.</li>
<li>Agents/Tool images: include source filenames in <code>agents/tool-images</code> resize logs so compression events can be traced back to specific files.</li>
<li>Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses.</li>
<li>Models/Kimi-Coding: add missing implicit provider template for <code>kimi-coding</code> with correct <code>anthropic-messages</code> API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409)</li>
<li>Auto-reply/Tools: forward <code>senderIsOwner</code> through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj.</li>
<li>Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow.</li>
<li>Memory/QMD: respect per-agent <code>memorySearch.enabled=false</code> during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (<code>search</code>/<code>vsearch</code>/<code>query</code>) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip <code>qmd embed</code> in BM25-only <code>search</code> mode (including <code>memory index --force</code>), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel.</li>
<li>Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so <code>onSearch</code>/<code>onSessionStart</code> no longer fail with <code>database is not open</code> in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter.</li>
<li>Providers/Copilot: drop persisted assistant <code>thinking</code> blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid <code>thinkingSignature</code> payloads. (#19459) Thanks @jackheuberger.</li>
<li>Providers/Copilot: add <code>claude-sonnet-4.6</code> and <code>claude-sonnet-4.5</code> to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn.</li>
<li>Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example <code>whatsapp</code>) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728.</li>
<li>Status: include persisted <code>cacheRead</code>/<code>cacheWrite</code> in session summaries so compact <code>/status</code> output consistently shows cache hit percentages from real session data.</li>
<li>Heartbeat/Cron: restore interval heartbeat behavior so missing <code>HEARTBEAT.md</code> no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths.</li>
<li>WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured <code>allowFrom</code> recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats.</li>
<li>Heartbeat/Active hours: constrain active-hours <code>24</code> sentinel parsing to <code>24:00</code> in time validation so invalid values like <code>24:30</code> are rejected early. (#21410) thanks @adhitShet.</li>
<li>Heartbeat: treat <code>activeHours</code> windows with identical <code>start</code>/<code>end</code> times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.</li>
<li>CLI/Pairing: default <code>pairing list</code> and <code>pairing approve</code> to the sole available pairing channel when omitted, so TUI-only setups can recover from <code>pairing required</code> without guessing channel arguments. (#21527) Thanks @losts1.</li>
<li>TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return <code>pairing required</code>, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux.</li>
<li>TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer.</li>
<li>TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when <code>showOk</code> is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton.</li>
<li>TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with <code>RangeError: Maximum call stack size exceeded</code>. (#18068) Thanks @JaniJegoroff.</li>
<li>Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr.</li>
<li>Memory/Tools: return explicit <code>unavailable</code> warnings/actions from <code>memory_search</code> when embedding/provider failures occur (including quota exhaustion), so disabled memory does not look like an empty recall result. (#21894) Thanks @XBS9.</li>
<li>Session/Startup: require the <code>/new</code> and <code>/reset</code> greeting path to run Session Startup file-reading instructions before responding, so daily memory startup context is not skipped on fresh-session greetings. (#22338) Thanks @armstrong-pv.</li>
<li>Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing <code>provider:default</code> mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii.</li>
<li>Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.</li>
<li>Slack: pass <code>recipient_team_id</code> / <code>recipient_user_id</code> through Slack native streaming calls so <code>chat.startStream</code>/<code>appendStream</code>/<code>stopStream</code> work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012.</li>
<li>CLI/Config: add canonical <code>--strict-json</code> parsing for <code>config set</code> and keep <code>--json</code> as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet.</li>
<li>CLI: keep <code>openclaw -v</code> as a root-only version alias so subcommand <code>-v, --verbose</code> flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet.</li>
<li>Memory: return empty snippets when <code>memory_get</code>/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo.</li>
<li>Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii.</li>
<li>Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal <code><think></code> tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus.</li>
<li>Telegram/Streaming: restore 30-char first-preview debounce and scope <code>NO_REPLY</code> prefix suppression to partial sentinel fragments so normal <code>No...</code> text is not filtered. (#22613) thanks @obviyus.</li>
<li>Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow.</li>
<li>Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus.</li>
<li>Discord/Streaming: apply <code>replyToMode: first</code> only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report.</li>
<li>Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow.</li>
<li>Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang.</li>
<li>Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow.</li>
<li>Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow.</li>
<li>Auto-reply/Runner: emit <code>onAgentRunStart</code> only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd.</li>
<li>Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr.</li>
<li>Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (<code>message_id</code>, <code>message_id_full</code>, <code>reply_to_id</code>, <code>sender_id</code>) into untrusted conversation context. (#20597) Thanks @anisoptera.</li>
<li>iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky.</li>
<li>iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky.</li>
<li>CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate <code>/v1</code> paths during setup checks. (#21336) Thanks @17jmumford.</li>
<li>iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable <code>nodes invoke</code> pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky.</li>
<li>Gateway/Auth: require <code>gateway.trustedProxies</code> to include a loopback proxy address when <code>auth.mode="trusted-proxy"</code> and <code>bind="loopback"</code>, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky.</li>
<li>Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured <code>gateway.trustedProxies</code>. (#20097) thanks @xinhuagu.</li>
<li>Gateway/Auth: allow authenticated clients across roles/scopes to call <code>health</code> while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639.</li>
<li>Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr.</li>
<li>Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr.</li>
<li>Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek.</li>
<li>Gateway/Pairing: clear persisted paired-device state when the gateway client closes with <code>device token mismatch</code> (<code>1008</code>) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky.</li>
<li>Gateway/Config: allow <code>gateway.customBindHost</code> in strict config validation when <code>gateway.bind="custom"</code> so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.</li>
<li>Gateway/Pairing: tolerate legacy paired devices missing <code>roles</code>/<code>scopes</code> metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.</li>
<li>Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local <code>openclaw devices</code> fallback recovery for loopback <code>pairing required</code> deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.</li>
<li>Cron: honor <code>cron.maxConcurrentRuns</code> in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.</li>
<li>Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.</li>
<li>Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.</li>
<li>Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.</li>
<li>Agents/Tool display: fix exec cwd suffix inference so <code>pushd ... && popd ... && <command></code> does not keep stale <code>(in <dir>)</code> context in summaries. (#21925) Thanks @Lukavyi.</li>
<li>Tools/web_search: handle xAI Responses API payloads that emit top-level <code>output_text</code> blocks (without a <code>message</code> wrapper) so Grok web_search no longer returns <code>No response</code> for those results. (#20508) Thanks @echoVic.</li>
<li>Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg.</li>
<li>Docker/Build: include <code>ownerDisplay</code> in <code>CommandsSchema</code> object-level defaults so Docker <code>pnpm build</code> no longer fails with <code>TS2769</code> during plugin SDK d.ts generation. (#22558) Thanks @obviyus.</li>
<li>Docker/Browser: install Playwright Chromium into <code>/home/node/.cache/ms-playwright</code> and set <code>node:node</code> ownership so browser binaries are available to the runtime user in browser-enabled images. (#22585) thanks @obviyus.</li>
<li>Hooks/Session memory: trigger bundled <code>session-memory</code> persistence on both <code>/new</code> and <code>/reset</code> so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul.</li>
<li>Dependencies/Agents: bump embedded Pi SDK packages (<code>@mariozechner/pi-agent-core</code>, <code>@mariozechner/pi-ai</code>, <code>@mariozechner/pi-coding-agent</code>, <code>@mariozechner/pi-tui</code>) to <code>0.54.0</code>. (#21578) Thanks @Takhoffman.</li>
<li>Config/Agents: expose Pi compaction tuning values <code>agents.defaults.compaction.reserveTokens</code> and <code>agents.defaults.compaction.keepRecentTokens</code> in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via <code>reserveTokensFloor</code>. (#21568) Thanks @Takhoffman.</li>
<li>Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.</li>
<li>Docker: run build steps as the <code>node</code> user and use <code>COPY --chown</code> to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo.</li>
<li>Config/Memory: restore schema help/label metadata for hybrid <code>mmr</code> and <code>temporalDecay</code> settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz.</li>
<li>Skills/SonosCLI: add troubleshooting guidance for <code>sonos discover</code> failures on macOS direct mode (<code>sendto: no route to host</code>) and sandbox network restrictions (<code>bind: operation not permitted</code>). (#21316) Thanks @huntharo.</li>
<li>macOS/Build: default release packaging to <code>BUNDLE_ID=ai.openclaw.mac</code> in <code>scripts/package-mac-dist.sh</code>, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit.</li>
<li>Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson.</li>
<li>Anthropic/Agents: preserve required pi-ai default OAuth beta headers when <code>context1m</code> injects <code>anthropic-beta</code>, preventing 401 auth failures for <code>sk-ant-oat-*</code> tokens. (#19789, fixes #19769) Thanks @minupla.</li>
<li>Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting.</li>
<li>macOS/Security: evaluate <code>system.run</code> allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via <code>rawCommand</code> chaining. Thanks @tdjackey for reporting.</li>
<li>WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging <code>chatJid</code> + valid <code>messageId</code> pairs. Thanks @aether-ai-agent for reporting.</li>
<li>ACP/Security: escape control and delimiter characters in ACP <code>resource_link</code> title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting.</li>
<li>TTS/Security: make model-driven provider switching opt-in by default (<code>messages.tts.modelOverrides.allowProvider=false</code> unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting.</li>
<li>Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. Thanks @aether-ai-agent for reporting.</li>
<li>BlueBubbles/Security: require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent.</li>
<li>iOS/Security: force <code>https://</code> for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky.</li>
<li>Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow.</li>
<li>Gateway/Security: require secure context and paired-device checks for Control UI auth even when <code>gateway.controlUi.allowInsecureAuth</code> is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting.</li>
<li>Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting.</li>
<li>Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow.</li>
<li>Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow.</li>
<li>Security/Commands: block prototype-key injection in runtime <code>/debug</code> overrides and require own-property checks for gated command flags (<code>bash</code>, <code>config</code>, <code>debug</code>) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting.</li>
<li>Security/Browser: block non-network browser navigation protocols (including <code>file:</code>, <code>data:</code>, and <code>javascript:</code>) while preserving <code>about:blank</code>, preventing local file reads via browser tool navigation. Thanks @q1uf3ng for reporting.</li>
<li>Security/Exec: block shell startup-file env injection (<code>BASH_ENV</code>, <code>ENV</code>, <code>BASH_FUNC_*</code>, <code>LD_*</code>, <code>DYLD_*</code>) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey.</li>
<li>Security/Exec (Windows): canonicalize <code>cmd.exe /c</code> command text across validation, approval binding, and audit/event rendering to prevent trailing-argument approval mismatches in <code>system.run</code>. Thanks @tdjackey for reporting.</li>
<li>Security/Gateway/Hooks: block <code>__proto__</code>, <code>constructor</code>, and <code>prototype</code> traversal in webhook template path resolution to prevent prototype-chain payload data leakage in <code>messageTemplate</code> rendering. (#22213) Thanks @SleuthCo.</li>
<li>Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc.</li>
<li>Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc.</li>
<li>Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc.</li>
<li>Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow.</li>
<li>Security/Net: strip sensitive headers (<code>Authorization</code>, <code>Proxy-Authorization</code>, <code>Cookie</code>, <code>Cookie2</code>) on cross-origin redirects in <code>fetchWithSsrFGuard</code> to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm.</li>
<li>Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow.</li>
<li>Security/Tools: add per-wrapper random IDs to untrusted-content markers from <code>wrapExternalContent</code>/<code>wrapWebContent</code>, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512.</li>
<li>Shared/Security: reject insecure deep links that use <code>ws://</code> non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.</li>
<li>macOS/Security: reject non-loopback <code>ws://</code> remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky.</li>
<li>Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky.</li>
<li>Security/Dependencies: bump transitive <code>hono</code> usage to <code>4.11.10</code> to incorporate timing-safe authentication comparison hardening for <code>basicAuth</code>/<code>bearerAuth</code> (<code>GHSA-gq3j-xvxp-8hrf</code>). Thanks @vincentkoc.</li>
<li>Security/Gateway: parse <code>X-Forwarded-For</code> with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc.</li>
<li>Security/Sandbox: remove default <code>--no-sandbox</code> for the browser container entrypoint, add explicit opt-in via <code>OPENCLAW_BROWSER_NO_SANDBOX</code> / <code>CLAWDBOT_BROWSER_NO_SANDBOX</code>, and add security-audit checks for stale/missing sandbox browser Docker hash labels. Thanks @TerminalsandCoffee and @vincentkoc.</li>
<li>Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. Thanks @TerminalsandCoffee for reporting.</li>
<li>Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (<code>openclaw-sandbox-browser</code>), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in <code>openclaw security --audit</code> when browser sandboxing runs on bridge without source-range limits. Thanks @TerminalsandCoffee for reporting.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.21/OpenClaw-2026.2.21.zip" length="23065599" type="application/octet-stream" sparkle:edSignature="Wg3P8rMvYO3uWoVR7Izxjm5hC5W0C5jCG2dR4WFSe8ULpUUU79YDJc99NMBnl8ym7ZVbelS3kZ0QSg0Wq2GhCw=="/>
</item>
</channel>
</rss>

View File

@ -1,5 +0,0 @@
.gradle/
**/build/
local.properties
.idea/
**/*.iml

View File

@ -1,51 +0,0 @@
## OpenClaw Node (Android) (internal)
Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**.
Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).
## Open in Android Studio
- Open the folder `apps/android`.
## Build / Run
```bash
cd apps/android
./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:testDebugUnitTest
```
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
## Connect / Pair
1) Start the gateway (on your “master” machine):
```bash
pnpm openclaw 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).
3) Approve pairing (on the gateway machine):
```bash
openclaw nodes pending
openclaw nodes approve <requestId>
```
More details: `docs/platforms/android.md`.
## Permissions
- Discovery:
- Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES`
- Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning)
- Foreground service notification (Android 13+): `POST_NOTIFICATIONS`
- Camera:
- `CAMERA` for `camera.snap` and `camera.clip`
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`

View File

@ -1,150 +0,0 @@
import com.android.build.api.variant.impl.VariantOutputImpl
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "ai.openclaw.android"
compileSdk = 36
sourceSets {
getByName("main") {
assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources"))
}
}
defaultConfig {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602210
versionName = "2026.2.21"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
isMinifyEnabled = false
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes += setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
)
}
}
lint {
disable += setOf(
"GradleDependency",
"IconLauncherShape",
"NewerVersionAvailable",
)
warningsAsErrors = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
androidComponents {
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val outputFileName = "openclaw-${versionName}-${buildType}.apk"
output.outputFileName = outputFileName
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.2")
implementation("androidx.webkit:webkit:1.15.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.9.6")
debugImplementation("androidx.compose.ui:ui-tooling")
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
implementation("androidx.camera:camera-camera2:1.5.2")
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.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")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}

View File

@ -1,28 +0,0 @@
# ── App classes ───────────────────────────────────────────────────
-keep class ai.openclaw.android.** { *; }
# ── Bouncy Castle ─────────────────────────────────────────────────
-keep class org.bouncycastle.** { *; }
-dontwarn org.bouncycastle.**
# ── CameraX ───────────────────────────────────────────────────────
-keep class androidx.camera.** { *; }
# ── kotlinx.serialization ────────────────────────────────────────
-keep class kotlinx.serialization.** { *; }
-keepclassmembers class * {
@kotlinx.serialization.Serializable *;
}
-keepattributes *Annotation*, InnerClasses
# ── OkHttp ────────────────────────────────────────────────────────
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.internal.platform.** { *; }
# ── Misc suppressions ────────────────────────────────────────────
-dontwarn com.sun.jna.**
-dontwarn javax.naming.**
-dontwarn lombok.Generated
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor

View File

@ -1,64 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<application
android:name=".NodeApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.OpenClawNode">
<service
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".InstallResultReceiver"
android:exported="false" />
</application>
</manifest>

View File

@ -1,14 +0,0 @@
package ai.openclaw.android
enum class CameraHudKind {
Photo,
Recording,
Success,
Error,
}
data class CameraHudState(
val token: Long,
val kind: CameraHudKind,
val message: String,
)

View File

@ -1,26 +0,0 @@
package ai.openclaw.android
import android.content.Context
import android.os.Build
import android.provider.Settings
object DeviceNames {
fun bestDefaultNodeName(context: Context): String {
val deviceName =
runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
}
.getOrNull()
?.trim()
.orEmpty()
if (deviceName.isNotEmpty()) return deviceName
val model =
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
.joinToString(" ")
.trim()
return model.ifEmpty { "Android Node" }
}
}

View File

@ -1,33 +0,0 @@
package ai.openclaw.android
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
class InstallResultReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// System needs user confirmation — launch the confirmation activity
@Suppress("DEPRECATION")
val confirmIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (confirmIntent != null) {
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(confirmIntent)
Log.w("openclaw", "app.update: user confirmation requested, launching install dialog")
}
}
PackageInstaller.STATUS_SUCCESS -> {
Log.w("openclaw", "app.update: install SUCCESS")
}
else -> {
Log.e("openclaw", "app.update: install FAILED status=$status message=$message")
}
}
}
}

View File

@ -1,15 +0,0 @@
package ai.openclaw.android
enum class LocationMode(val rawValue: String) {
Off("off"),
WhileUsing("whileUsing"),
Always("always"),
;
companion object {
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}
}
}

View File

@ -1,130 +0,0 @@
package ai.openclaw.android
import android.Manifest
import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.os.Build
import android.view.WindowManager
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import ai.openclaw.android.ui.RootScreen
import ai.openclaw.android.ui.OpenClawTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var permissionRequester: PermissionRequester
private lateinit var screenCaptureRequester: ScreenCaptureRequester
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
WebView.setWebContentsDebuggingEnabled(isDebuggable)
applyImmersiveMode()
requestDiscoveryPermissionsIfNeeded()
requestNotificationPermissionIfNeeded()
NodeForegroundService.start(this)
permissionRequester = PermissionRequester(this)
screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this)
viewModel.camera.attachPermissionRequester(permissionRequester)
viewModel.sms.attachPermissionRequester(permissionRequester)
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.preventSleep.collect { enabled ->
if (enabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
}
setContent {
OpenClawTheme {
Surface(modifier = Modifier) {
RootScreen(viewModel = viewModel)
}
}
}
}
override fun onResume() {
super.onResume()
applyImmersiveMode()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
applyImmersiveMode()
}
}
override fun onStart() {
super.onStart()
viewModel.setForeground(true)
}
override fun onStop() {
viewModel.setForeground(false)
super.onStop()
}
private fun applyImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
}
private fun requestDiscoveryPermissionsIfNeeded() {
if (Build.VERSION.SDK_INT >= 33) {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.NEARBY_WIFI_DEVICES,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
}
} else {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
}
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < 33) return
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
}
}
}

View File

@ -1,188 +0,0 @@
package ai.openclaw.android
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import ai.openclaw.android.gateway.GatewayEndpoint
import ai.openclaw.android.chat.OutgoingAttachment
import ai.openclaw.android.node.CameraCaptureManager
import ai.openclaw.android.node.CanvasController
import ai.openclaw.android.node.ScreenRecordManager
import ai.openclaw.android.node.SmsManager
import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val runtime: NodeRuntime = (app as NodeApp).runtime
val canvas: CanvasController = runtime.canvas
val camera: CameraCaptureManager = runtime.camera
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
val isConnected: StateFlow<Boolean> = runtime.isConnected
val statusText: StateFlow<String> = runtime.statusText
val serverName: StateFlow<String?> = runtime.serverName
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
val screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
val talkStatusText: StateFlow<String> = runtime.talkStatusText
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls
val gatewayToken: StateFlow<String> = runtime.gatewayToken
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
val chatMessages = runtime.chatMessages
val chatError: StateFlow<String?> = runtime.chatError
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
val chatPendingToolCalls = runtime.chatPendingToolCalls
val chatSessions = runtime.chatSessions
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
fun setForeground(value: Boolean) {
runtime.setForeground(value)
}
fun setDisplayName(value: String) {
runtime.setDisplayName(value)
}
fun setCameraEnabled(value: Boolean) {
runtime.setCameraEnabled(value)
}
fun setLocationMode(mode: LocationMode) {
runtime.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
runtime.setLocationPreciseEnabled(value)
}
fun setPreventSleep(value: Boolean) {
runtime.setPreventSleep(value)
}
fun setManualEnabled(value: Boolean) {
runtime.setManualEnabled(value)
}
fun setManualHost(value: String) {
runtime.setManualHost(value)
}
fun setManualPort(value: Int) {
runtime.setManualPort(value)
}
fun setManualTls(value: Boolean) {
runtime.setManualTls(value)
}
fun setGatewayToken(value: String) {
runtime.setGatewayToken(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
runtime.setWakeWords(words)
}
fun resetWakeWordsDefaults() {
runtime.resetWakeWordsDefaults()
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
runtime.setVoiceWakeMode(mode)
}
fun setTalkEnabled(enabled: Boolean) {
runtime.setTalkEnabled(enabled)
}
fun refreshGatewayConnection() {
runtime.refreshGatewayConnection()
}
fun connect(endpoint: GatewayEndpoint) {
runtime.connect(endpoint)
}
fun connectManual() {
runtime.connectManual()
}
fun disconnect() {
runtime.disconnect()
}
fun acceptGatewayTrustPrompt() {
runtime.acceptGatewayTrustPrompt()
}
fun declineGatewayTrustPrompt() {
runtime.declineGatewayTrustPrompt()
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}
fun loadChat(sessionKey: String) {
runtime.loadChat(sessionKey)
}
fun refreshChat() {
runtime.refreshChat()
}
fun refreshChatSessions(limit: Int? = null) {
runtime.refreshChatSessions(limit = limit)
}
fun setChatThinkingLevel(level: String) {
runtime.setChatThinkingLevel(level)
}
fun switchChatSession(sessionKey: String) {
runtime.switchChatSession(sessionKey)
}
fun abortChat() {
runtime.abortChat()
}
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
}
}

View File

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

View File

@ -1,180 +0,0 @@
package ai.openclaw.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.app.PendingIntent
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
private var lastRequiresMic = false
private var didStartForeground = false
override fun onCreate() {
super.onCreate()
ensureChannel()
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
startForegroundWithTypes(notification = initial, requiresMic = false)
val runtime = (application as NodeApp).runtime
notificationJob =
scope.launch {
combine(
runtime.statusText,
runtime.serverName,
runtime.isConnected,
runtime.voiceWakeMode,
runtime.voiceWakeIsListening,
) { status, server, connected, voiceMode, voiceListening ->
Quint(status, server, connected, voiceMode, voiceListening)
}.collect { (status, server, connected, voiceMode, voiceListening) ->
val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node"
val voiceSuffix =
if (voiceMode == VoiceWakeMode.Always) {
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
} else {
""
}
val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix
val requiresMic =
voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission()
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
requiresMic = requiresMic,
)
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
(application as NodeApp).runtime.disconnect()
stopSelf()
return START_NOT_STICKY
}
}
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
return START_STICKY
}
override fun onDestroy() {
notificationJob?.cancel()
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?) = null
private fun ensureChannel() {
val mgr = getSystemService(NotificationManager::class.java)
val channel =
NotificationChannel(
CHANNEL_ID,
"Connection",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "OpenClaw node connection status"
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
}
private fun buildNotification(title: String, text: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val launchPending =
PendingIntent.getActivity(
this,
1,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val stopPending =
PendingIntent.getService(
this,
2,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(launchPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.addAction(0, "Disconnect", stopPending)
.build()
}
private fun updateNotification(notification: Notification) {
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
mgr.notify(NOTIFICATION_ID, notification)
}
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
if (didStartForeground && requiresMic == lastRequiresMic) {
updateNotification(notification)
return
}
lastRequiresMic = requiresMic
val types =
if (requiresMic) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
}
startForeground(NOTIFICATION_ID, notification, types)
didStartForeground = true
}
private fun hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
companion object {
private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "ai.openclaw.android.action.STOP"
fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)
context.startForegroundService(intent)
}
fun stop(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
}
}
private data class Quint<A, B, C, D, E>(val first: A, val second: B, val third: C, val fourth: D, val fifth: E)

View File

@ -1,753 +0,0 @@
package ai.openclaw.android
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.android.chat.ChatController
import ai.openclaw.android.chat.ChatMessage
import ai.openclaw.android.chat.ChatPendingToolCall
import ai.openclaw.android.chat.ChatSessionEntry
import ai.openclaw.android.chat.OutgoingAttachment
import ai.openclaw.android.gateway.DeviceAuthStore
import ai.openclaw.android.gateway.DeviceIdentityStore
import ai.openclaw.android.gateway.GatewayDiscovery
import ai.openclaw.android.gateway.GatewayEndpoint
import ai.openclaw.android.gateway.GatewaySession
import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
import ai.openclaw.android.node.*
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
import ai.openclaw.android.voice.TalkModeManager
import ai.openclaw.android.voice.VoiceWakeManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.concurrent.atomic.AtomicLong
class NodeRuntime(context: Context) {
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
val screenRecorder = ScreenRecordManager(appContext)
val sms = SmsManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
private val externalAudioCaptureActive = MutableStateFlow(false)
private val voiceWake: VoiceWakeManager by lazy {
VoiceWakeManager(
context = appContext,
scope = scope,
onCommand = { command ->
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(command))
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("deliver", JsonPrimitive(false))
}.toString(),
)
},
)
}
val voiceWakeIsListening: StateFlow<Boolean>
get() = voiceWake.isListening
val voiceWakeStatusText: StateFlow<String>
get() = voiceWake.statusText
val talkStatusText: StateFlow<String>
get() = talkMode.statusText
val talkIsListening: StateFlow<Boolean>
get() = talkMode.isListening
val talkIsSpeaking: StateFlow<Boolean>
get() = talkMode.isSpeaking
private val discovery = GatewayDiscovery(appContext, scope = scope)
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
val discoveryStatusText: StateFlow<String> = discovery.statusText
private val identityStore = DeviceIdentityStore(appContext)
private var connectedEndpoint: GatewayEndpoint? = null
private val cameraHandler: CameraHandler = CameraHandler(
appContext = appContext,
camera = camera,
prefs = prefs,
connectedEndpoint = { connectedEndpoint },
externalAudioCaptureActive = externalAudioCaptureActive,
showCameraHud = ::showCameraHud,
triggerCameraFlash = ::triggerCameraFlash,
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
)
private val debugHandler: DebugHandler = DebugHandler(
appContext = appContext,
identityStore = identityStore,
)
private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler(
appContext = appContext,
connectedEndpoint = { connectedEndpoint },
)
private val locationHandler: LocationHandler = LocationHandler(
appContext = appContext,
location = location,
json = json,
isForeground = { _isForeground.value },
locationMode = { locationMode.value },
locationPreciseEnabled = { locationPreciseEnabled.value },
)
private val screenHandler: ScreenHandler = ScreenHandler(
screenRecorder = screenRecorder,
setScreenRecordActive = { _screenRecordActive.value = it },
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
)
private val smsHandlerImpl: SmsHandler = SmsHandler(
sms = sms,
)
private val a2uiHandler: A2UIHandler = A2UIHandler(
canvas = canvas,
json = json,
getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() },
getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() },
)
private val connectionManager: ConnectionManager = ConnectionManager(
prefs = prefs,
cameraEnabled = { cameraEnabled.value },
locationMode = { locationMode.value },
voiceWakeMode = { voiceWakeMode.value },
smsAvailable = { sms.canSendSms() },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
private val invokeDispatcher: InvokeDispatcher = InvokeDispatcher(
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
screenHandler = screenHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
appUpdateHandler = appUpdateHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
)
private lateinit var gatewayEventHandler: GatewayEventHandler
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
val fingerprintSha256: String,
)
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow<String> = _statusText.asStateFlow()
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
private val _mainSessionKey = MutableStateFlow("main")
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
private val cameraHudSeq = AtomicLong(0)
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
private val _cameraFlashToken = MutableStateFlow(0L)
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
private val _screenRecordActive = MutableStateFlow(false)
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
private val _serverName = MutableStateFlow<String?>(null)
val serverName: StateFlow<String?> = _serverName.asStateFlow()
private val _remoteAddress = MutableStateFlow<String?>(null)
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB)
val seamColorArgb: StateFlow<Long> = _seamColorArgb.asStateFlow()
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private var lastAutoA2uiUrl: String? = null
private var operatorConnected = false
private var nodeConnected = false
private var operatorStatusText: String = "Offline"
private var nodeStatusText: String = "Offline"
private val operatorSession =
GatewaySession(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { name, remote, mainSessionKey ->
operatorConnected = true
operatorStatusText = "Connected"
_serverName.value = name
_remoteAddress.value = remote
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
updateStatus()
scope.launch { refreshBrandingFromGateway() }
scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() }
},
onDisconnected = { message ->
operatorConnected = false
operatorStatusText = message
_serverName.value = null
_remoteAddress.value = null
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
chat.onDisconnected(message)
updateStatus()
},
onEvent = { event, payloadJson ->
handleGatewayEvent(event, payloadJson)
},
)
private val nodeSession =
GatewaySession(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
nodeConnected = true
nodeStatusText = "Connected"
updateStatus()
maybeNavigateToA2uiOnConnect()
},
onDisconnected = { message ->
nodeConnected = false
nodeStatusText = message
updateStatus()
showLocalCanvasOnDisconnect()
},
onEvent = { _, _ -> },
onInvoke = { req ->
invokeDispatcher.handleInvoke(req.command, req.paramsJson)
},
onTlsFingerprint = { stableId, fingerprint ->
prefs.saveGatewayTlsFingerprint(stableId, fingerprint)
},
)
private val chat: ChatController =
ChatController(
scope = scope,
session = operatorSession,
json = json,
supportsChatSubscribe = false,
)
private val talkMode: TalkModeManager by lazy {
TalkModeManager(
context = appContext,
scope = scope,
session = operatorSession,
supportsChatSubscribe = false,
isConnected = { operatorConnected },
)
}
private fun applyMainSessionKey(candidate: String?) {
val trimmed = normalizeMainKey(candidate) ?: return
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
if (_mainSessionKey.value == trimmed) return
_mainSessionKey.value = trimmed
talkMode.setMainSessionKey(trimmed)
chat.applyMainSessionKey(trimmed)
}
private fun updateStatus() {
_isConnected.value = operatorConnected
_statusText.value =
when {
operatorConnected && nodeConnected -> "Connected"
operatorConnected && !nodeConnected -> "Connected (node offline)"
!operatorConnected && nodeConnected -> "Connected (operator offline)"
operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
else -> nodeStatusText
}
}
private fun resolveMainSessionKey(): String {
val trimmed = _mainSessionKey.value.trim()
return if (trimmed.isEmpty()) "main" else trimmed
}
private fun maybeNavigateToA2uiOnConnect() {
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty()
if (current.isEmpty() || current == lastAutoA2uiUrl) {
lastAutoA2uiUrl = a2uiUrl
canvas.navigate(a2uiUrl)
}
}
private fun showLocalCanvasOnDisconnect() {
lastAutoA2uiUrl = null
canvas.navigate("")
}
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val locationMode: StateFlow<LocationMode> = prefs.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
val talkEnabled: StateFlow<Boolean> = prefs.talkEnabled
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
private var didAutoConnect = false
val chatSessionKey: StateFlow<String> = chat.sessionKey
val chatSessionId: StateFlow<String?> = chat.sessionId
val chatMessages: StateFlow<List<ChatMessage>> = chat.messages
val chatError: StateFlow<String?> = chat.errorText
val chatHealthOk: StateFlow<Boolean> = chat.healthOk
val chatThinkingLevel: StateFlow<String> = chat.thinkingLevel
val chatStreamingAssistantText: StateFlow<String?> = chat.streamingAssistantText
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = chat.pendingToolCalls
val chatSessions: StateFlow<List<ChatSessionEntry>> = chat.sessions
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
init {
gatewayEventHandler = GatewayEventHandler(
scope = scope,
prefs = prefs,
json = json,
operatorSession = operatorSession,
isConnected = { _isConnected.value },
)
scope.launch {
combine(
voiceWakeMode,
isForeground,
externalAudioCaptureActive,
wakeWords,
) { mode, foreground, externalAudio, words ->
Quad(mode, foreground, externalAudio, words)
}.distinctUntilChanged()
.collect { (mode, foreground, externalAudio, words) ->
voiceWake.setTriggerWords(words)
val shouldListen =
when (mode) {
VoiceWakeMode.Off -> false
VoiceWakeMode.Foreground -> foreground
VoiceWakeMode.Always -> true
} && !externalAudio
if (!shouldListen) {
voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused")
return@collect
}
if (!hasRecordAudioPermission()) {
voiceWake.stop(statusText = "Microphone permission required")
return@collect
}
voiceWake.start()
}
}
scope.launch {
talkEnabled.collect { enabled ->
talkMode.setEnabled(enabled)
externalAudioCaptureActive.value = enabled
}
}
scope.launch(Dispatchers.Default) {
gateways.collect { list ->
if (list.isNotEmpty()) {
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
// UX parity with iOS: only set once when unset.
if (lastDiscoveredStableId.value.trim().isEmpty()) {
prefs.setLastDiscoveredStableId(list.first().stableId)
}
}
if (didAutoConnect) return@collect
if (_isConnected.value) return@collect
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) {
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
if (!manualTls.value) return@collect
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(GatewayEndpoint.manual(host = host, port = port))
}
return@collect
}
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return@collect
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(target)
}
}
scope.launch {
combine(
canvasDebugStatusEnabled,
statusText,
serverName,
remoteAddress,
) { debugEnabled, status, server, remote ->
Quad(debugEnabled, status, server, remote)
}.distinctUntilChanged()
.collect { (debugEnabled, status, server, remote) ->
canvas.setDebugStatusEnabled(debugEnabled)
if (!debugEnabled) return@collect
canvas.setDebugStatus(status, server ?: remote)
}
}
}
fun setForeground(value: Boolean) {
_isForeground.value = value
}
fun setDisplayName(value: String) {
prefs.setDisplayName(value)
}
fun setCameraEnabled(value: Boolean) {
prefs.setCameraEnabled(value)
}
fun setLocationMode(mode: LocationMode) {
prefs.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
prefs.setLocationPreciseEnabled(value)
}
fun setPreventSleep(value: Boolean) {
prefs.setPreventSleep(value)
}
fun setManualEnabled(value: Boolean) {
prefs.setManualEnabled(value)
}
fun setManualHost(value: String) {
prefs.setManualHost(value)
}
fun setManualPort(value: Int) {
prefs.setManualPort(value)
}
fun setManualTls(value: Boolean) {
prefs.setManualTls(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
prefs.setWakeWords(words)
gatewayEventHandler.scheduleWakeWordsSyncIfNeeded()
}
fun resetWakeWordsDefaults() {
setWakeWords(SecurePrefs.defaultWakeWords)
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
prefs.setVoiceWakeMode(mode)
}
fun setTalkEnabled(value: Boolean) {
prefs.setTalkEnabled(value)
}
fun refreshGatewayConnection() {
val endpoint = connectedEndpoint ?: return
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
val tls = connectionManager.resolveTlsParams(endpoint)
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
operatorSession.reconnect()
nodeSession.reconnect()
}
fun connect(endpoint: GatewayEndpoint) {
val tls = connectionManager.resolveTlsParams(endpoint)
if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) {
// First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
_statusText.value = "Verify gateway TLS fingerprint…"
scope.launch {
val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run {
_statusText.value = "Failed: can't read TLS fingerprint"
return@launch
}
_pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp)
}
return
}
connectedEndpoint = endpoint
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
}
fun acceptGatewayTrustPrompt() {
val prompt = _pendingGatewayTrust.value ?: return
_pendingGatewayTrust.value = null
prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256)
connect(prompt.endpoint)
}
fun declineGatewayTrustPrompt() {
_pendingGatewayTrust.value = null
_statusText.value = "Offline"
}
private fun hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
fun connectManual() {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isEmpty() || port <= 0 || port > 65535) {
_statusText.value = "Failed: invalid manual host/port"
return
}
connect(GatewayEndpoint.manual(host = host, port = port))
}
fun disconnect() {
connectedEndpoint = null
_pendingGatewayTrust.value = null
operatorSession.disconnect()
nodeSession.disconnect()
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
scope.launch {
val trimmed = payloadJson.trim()
if (trimmed.isEmpty()) return@launch
val root =
try {
json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch
} catch (_: Throwable) {
return@launch
}
val userActionObj = (root["userAction"] as? JsonObject) ?: root
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID.randomUUID().toString()
}
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
val sourceComponentId =
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = resolveMainSessionKey()
val message =
OpenClawCanvasA2UIAction.formatAgentMessage(
actionName = name,
sessionKey = sessionKey,
surfaceId = surfaceId,
sourceComponentId = sourceComponentId,
host = displayName.value,
instanceId = instanceId.value.lowercase(),
contextJson = contextJson,
)
val connected = nodeConnected
var error: String? = null
if (connected) {
try {
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(message))
put("sessionKey", JsonPrimitive(sessionKey))
put("thinking", JsonPrimitive("low"))
put("deliver", JsonPrimitive(false))
put("key", JsonPrimitive(actionId))
}.toString(),
)
} catch (e: Throwable) {
error = e.message ?: "send failed"
}
} else {
error = "gateway not connected"
}
try {
canvas.eval(
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId = actionId,
ok = connected && error == null,
error = error,
),
)
} catch (_: Throwable) {
// ignore
}
}
}
fun loadChat(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
chat.load(key)
}
fun refreshChat() {
chat.refresh()
}
fun refreshChatSessions(limit: Int? = null) {
chat.refreshSessions(limit = limit)
}
fun setChatThinkingLevel(level: String) {
chat.setThinkingLevel(level)
}
fun switchChatSession(sessionKey: String) {
chat.switchSession(sessionKey)
}
fun abortChat() {
chat.abort()
}
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
}
private fun handleGatewayEvent(event: String, payloadJson: String?) {
if (event == "voicewake.changed") {
gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson)
return
}
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return
try {
val res = operatorSession.request("config.get", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull()
val ui = config?.get("ui").asObjectOrNull()
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
applyMainSessionKey(mainKey)
val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
} catch (_: Throwable) {
// ignore
}
}
private fun triggerCameraFlash() {
// Token is used as a pulse trigger; value doesn't matter as long as it changes.
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
}
private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) {
val token = cameraHudSeq.incrementAndGet()
_cameraHud.value = CameraHudState(token = token, kind = kind, message = message)
if (autoHideMs != null && autoHideMs > 0) {
scope.launch {
delay(autoHideMs)
if (_cameraHud.value?.token == token) _cameraHud.value = null
}
}
}
}

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