Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4917c2cfa | ||
|
|
205a5fd522 | ||
|
|
9ba7c8874b |
No files matched your search
@@ -7,10 +7,6 @@
|
|||||||
[exclude-files]
|
[exclude-files]
|
||||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
||||||
pattern = (^|/)pnpm-lock\.yaml$
|
pattern = (^|/)pnpm-lock\.yaml$
|
||||||
# Generated output and vendored assets.
|
|
||||||
pattern = (^|/)(dist|vendor)/
|
|
||||||
# Local config file with allowlist patterns.
|
|
||||||
pattern = (^|/)\.detect-secrets\.cfg$
|
|
||||||
|
|
||||||
[exclude-lines]
|
[exclude-lines]
|
||||||
# Fastlane checks for private key marker; not a real key.
|
# Fastlane checks for private key marker; not a real key.
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# actionlint configuration
|
|
||||||
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
|
||||||
|
|
||||||
self-hosted-runner:
|
|
||||||
labels:
|
|
||||||
# Blacksmith CI runners
|
|
||||||
- blacksmith-4vcpu-ubuntu-2404
|
|
||||||
- blacksmith-4vcpu-windows-2025
|
|
||||||
|
|
||||||
# Ignore patterns for known issues
|
|
||||||
paths:
|
|
||||||
.github/workflows/**/*.yml:
|
|
||||||
ignore:
|
|
||||||
# Ignore shellcheck warnings (we run shellcheck separately)
|
|
||||||
- 'shellcheck reported issue.+'
|
|
||||||
# Ignore intentional if: false for disabled jobs
|
|
||||||
- 'constant expression "false" in condition'
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# Dependabot configuration
|
|
||||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
|
||||||
|
|
||||||
version: 2
|
|
||||||
|
|
||||||
registries:
|
|
||||||
npm-npmjs:
|
|
||||||
type: npm-registry
|
|
||||||
url: https://registry.npmjs.org
|
|
||||||
replaces-base: true
|
|
||||||
|
|
||||||
updates:
|
|
||||||
# npm dependencies (root)
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
groups:
|
|
||||||
production:
|
|
||||||
dependency-type: production
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
development:
|
|
||||||
dependency-type: development
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
registries:
|
|
||||||
- npm-npmjs
|
|
||||||
|
|
||||||
# GitHub Actions
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
groups:
|
|
||||||
actions:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
|
|
||||||
# Swift Package Manager - macOS app
|
|
||||||
- package-ecosystem: swift
|
|
||||||
directory: /apps/macos
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
groups:
|
|
||||||
swift-deps:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
|
|
||||||
# Swift Package Manager - shared ClawdbotKit
|
|
||||||
- package-ecosystem: swift
|
|
||||||
directory: /apps/shared/ClawdbotKit
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
groups:
|
|
||||||
swift-deps:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
|
|
||||||
# Swift Package Manager - Swabble
|
|
||||||
- package-ecosystem: swift
|
|
||||||
directory: /Swabble
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
groups:
|
|
||||||
swift-deps:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
|
|
||||||
# Gradle - Android app
|
|
||||||
- package-ecosystem: gradle
|
|
||||||
directory: /apps/android
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
groups:
|
|
||||||
android-deps:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
name: Docker Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Build amd64 image
|
|
||||||
build-amd64:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
image-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
image-metadata: ${{ steps.meta.outputs.json }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{version}},suffix=-amd64
|
|
||||||
type=semver,pattern={{version}},suffix=-arm64
|
|
||||||
type=ref,event=branch,suffix=-amd64
|
|
||||||
type=ref,event=branch,suffix=-arm64
|
|
||||||
|
|
||||||
- name: Build and push amd64 image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
provenance: false
|
|
||||||
push: true
|
|
||||||
|
|
||||||
# Build arm64 image
|
|
||||||
build-arm64:
|
|
||||||
runs-on: ubuntu-24.04-arm
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
image-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
image-metadata: ${{ steps.meta.outputs.json }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{version}},suffix=-amd64
|
|
||||||
type=semver,pattern={{version}},suffix=-arm64
|
|
||||||
type=ref,event=branch,suffix=-amd64
|
|
||||||
type=ref,event=branch,suffix=-arm64
|
|
||||||
|
|
||||||
- name: Build and push arm64 image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/arm64
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
provenance: false
|
|
||||||
push: true
|
|
||||||
|
|
||||||
# Create multi-platform manifest
|
|
||||||
create-manifest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
needs: [build-amd64, build-arm64]
|
|
||||||
steps:
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata for manifest
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
|
|
||||||
- name: Create and push manifest
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
${{ needs.build-amd64.outputs.image-digest }} \
|
|
||||||
${{ needs.build-arm64.outputs.image-digest }}
|
|
||||||
env:
|
|
||||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
|
||||||
@@ -29,6 +29,5 @@ jobs:
|
|||||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||||
CLAWDBOT_NO_ONBOARD: "1"
|
CLAWDBOT_NO_ONBOARD: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
|
||||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||||
run: pnpm test:install:smoke
|
run: pnpm test:install:smoke
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# Pre-commit hooks for clawdbot
|
|
||||||
# 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]
|
|
||||||
+123
-1821
File diff suppressed because it is too large.
Load diff
@@ -1,25 +0,0 @@
|
|||||||
# ShellCheck configuration
|
|
||||||
# https://www.shellcheck.net/wiki/
|
|
||||||
|
|
||||||
# Disable common false positives and style suggestions
|
|
||||||
|
|
||||||
# SC2034: Variable appears unused (often exported or used indirectly)
|
|
||||||
disable=SC2034
|
|
||||||
|
|
||||||
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
|
|
||||||
disable=SC2155
|
|
||||||
|
|
||||||
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
|
|
||||||
disable=SC2295
|
|
||||||
|
|
||||||
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
|
|
||||||
disable=SC1012
|
|
||||||
|
|
||||||
# SC2026: Word outside quotes (info-level, often intentional)
|
|
||||||
disable=SC2026
|
|
||||||
|
|
||||||
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
|
|
||||||
disable=SC2016
|
|
||||||
|
|
||||||
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
|
|
||||||
disable=SC2129
|
|
||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
# Whitespace
|
# Whitespace
|
||||||
--trimwhitespace always
|
--trimwhitespace always
|
||||||
--emptybraces no-space
|
--emptybraces no-space
|
||||||
--nospaceoperators ...,..<
|
--nospaceoperators ...,..<
|
||||||
--ranges no-space
|
--ranges no-space
|
||||||
--someAny true
|
--someAny true
|
||||||
--voidtype void
|
--voidtype void
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
- Tests: colocated `*.test.ts`.
|
- Tests: colocated `*.test.ts`.
|
||||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
- 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/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 `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
|
|
||||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`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).
|
- 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 docs: `docs/channels/`
|
||||||
@@ -18,31 +17,18 @@
|
|||||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
- 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.clawd.bot/...` URLs (not root-relative).
|
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||||
|
|
||||||
## 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 clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
|
|
||||||
- Config: use `clawdbot 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 clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
|
|
||||||
- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
|
||||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
- 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>`.
|
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
- 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` (tsc)
|
- Type-check/build: `pnpm build` (tsc)
|
||||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||||
@@ -64,7 +50,6 @@
|
|||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- 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.
|
- 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` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||||
- Full kit + what’s covered: `docs/testing.md`.
|
- Full kit + what’s covered: `docs/testing.md`.
|
||||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||||
@@ -79,7 +64,6 @@
|
|||||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||||
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
||||||
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
|
|
||||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||||
@@ -108,7 +92,6 @@
|
|||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Vocabulary: "makeup" = "mac app".
|
- 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`.
|
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/clawdbot && 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 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.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
@@ -134,10 +117,6 @@
|
|||||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch 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:** 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.
|
- **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.
|
- 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.
|
- **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.
|
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||||
|
|||||||
+1
-186
@@ -2,215 +2,32 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.24
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
|
|
||||||
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
|
||||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
|
||||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
|
||||||
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
|
||||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
|
||||||
- Docs: add verbose installer troubleshooting guidance.
|
|
||||||
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
|
||||||
- Docs: update Fly.io guide notes.
|
|
||||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
|
||||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
|
||||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
|
||||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
|
||||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
|
||||||
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
|
||||||
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
|
||||||
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
|
|
||||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
|
||||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
|
||||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
|
||||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
|
||||||
- Heartbeat: normalize target identifiers for consistent routing.
|
|
||||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
|
||||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
|
||||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
|
||||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
|
||||||
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
|
||||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
|
||||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
|
||||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
|
||||||
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
|
|
||||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
|
||||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
|
||||||
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
|
||||||
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
|
||||||
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
|
||||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
|
||||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
|
||||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
|
||||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
|
||||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
|
||||||
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
|
|
||||||
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
|
||||||
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
|
||||||
|
|
||||||
## 2026.1.23-1
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
|
|
||||||
|
|
||||||
## 2026.1.23
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.clawd.bot/tts
|
|
||||||
- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.clawd.bot/gateway/tools-invoke-http-api
|
|
||||||
- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.clawd.bot/gateway/heartbeat
|
|
||||||
- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.clawd.bot/platforms/fly
|
|
||||||
- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.clawd.bot/channels/tlon
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.clawd.bot/multi-agent-sandbox-tools
|
|
||||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.clawd.bot/bedrock
|
|
||||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.clawd.bot/cli/system
|
|
||||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. (commit 40181afde) https://docs.clawd.bot/cli/models
|
|
||||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. (commit 2c85b1b40)
|
|
||||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c)
|
|
||||||
- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.clawd.bot/tools/llm-task
|
|
||||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
|
||||||
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b)
|
|
||||||
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
|
||||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.clawd.bot/automation/cron-vs-heartbeat
|
|
||||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.clawd.bot/gateway/heartbeat
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
|
|
||||||
- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints.
|
|
||||||
- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3)
|
|
||||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
|
||||||
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
|
||||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
|
||||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
|
||||||
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf)
|
|
||||||
- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac)
|
|
||||||
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
|
||||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
|
||||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
|
||||||
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0)
|
|
||||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556)
|
|
||||||
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
|
||||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
|
||||||
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
|
||||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
|
||||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998)
|
|
||||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
|
||||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
|
||||||
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c)
|
|
||||||
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0)
|
|
||||||
- Telegram: render markdown in media captions. (#1478)
|
|
||||||
- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero.
|
|
||||||
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
|
||||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66)
|
|
||||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
|
||||||
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a)
|
|
||||||
- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea)
|
|
||||||
- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `clawdbot models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1)
|
|
||||||
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
|
|
||||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
|
||||||
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
|
|
||||||
|
|
||||||
## 2026.1.22
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
|
||||||
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
|
|
||||||
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
|
||||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
|
||||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
|
||||||
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
|
||||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
|
||||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
|
||||||
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
|
||||||
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
|
||||||
- Gateway: stop the service before uninstalling and fail if it remains loaded.
|
|
||||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
|
||||||
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
|
|
||||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
|
||||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
|
||||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
|
||||||
- Agents: make tool summaries more readable and only show optional params when set.
|
|
||||||
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
|
||||||
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
|
|
||||||
- CLI: prefer `~` for home paths in output.
|
|
||||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
|
||||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
|
||||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
|
||||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
|
||||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
|
||||||
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
|
||||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
|
||||||
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
|
|
||||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
|
||||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
|
||||||
|
|
||||||
## 2026.1.21-2
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
|
||||||
- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447)
|
|
||||||
|
|
||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
|
||||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
|
||||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
|
||||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
|
||||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
|
||||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
|
||||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
|
||||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
|
||||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
|
||||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
|
||||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
|
||||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
|
||||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
|
||||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
|
||||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
|
||||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
|
||||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||||
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
||||||
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
|
|
||||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||||
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
||||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||||
- Model picker: list the full catalog when no model allowlist is configured.
|
- Model picker: list the full catalog when no model allowlist is configured.
|
||||||
|
- Chat: include configured defaults/providers in `/models` output and normalize all-mode paging. (#1398) Thanks @vignesh07.
|
||||||
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
||||||
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
||||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
|
||||||
|
|
||||||
## 2026.1.20
|
## 2026.1.20
|
||||||
|
|
||||||
@@ -245,7 +62,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
|
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||||
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
|
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
|
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
|
||||||
- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.clawd.bot/gateway/configuration
|
|
||||||
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
|
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
|
||||||
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
|
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
|
||||||
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
|
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
|
||||||
@@ -255,7 +71,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||||
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
|
|
||||||
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
||||||
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
||||||
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||||
|
|
||||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
|
|||||||
# Send a message
|
# Send a message
|
||||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||||
|
|
||||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||||
clawdbot agent --message "Ship checklist" --thinking high
|
clawdbot agent --message "Ship checklist" --thinking high
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
|
|||||||
|
|
||||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||||
|
|
||||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
||||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||||
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||||
@@ -116,7 +116,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||||
@@ -138,7 +138,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||||
|
|
||||||
### Channels
|
### Channels
|
||||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Google Chat](https://docs.clawd.bot/channels/googlechat) (Chat API), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
||||||
|
|
||||||
### Apps + nodes
|
### Apps + nodes
|
||||||
@@ -169,7 +169,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
## How it works (short)
|
## How it works (short)
|
||||||
|
|
||||||
```
|
```
|
||||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌───────────────────────────────┐
|
┌───────────────────────────────┐
|
||||||
@@ -252,7 +252,7 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
|
|||||||
|
|
||||||
## Chat commands
|
## Chat commands
|
||||||
|
|
||||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
||||||
|
|
||||||
- `/status` — compact session status (model + tokens, cost when available)
|
- `/status` — compact session status (model + tokens, cost when available)
|
||||||
- `/new` or `/reset` — reset the session
|
- `/new` or `/reset` — reset the session
|
||||||
@@ -459,7 +459,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
|
|||||||
|
|
||||||
## Clawd
|
## Clawd
|
||||||
|
|
||||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||||
by Peter Steinberger and the community.
|
by Peter Steinberger and the community.
|
||||||
|
|
||||||
- [clawd.me](https://clawd.me)
|
- [clawd.me](https://clawd.me)
|
||||||
@@ -468,41 +468,33 @@ by Peter Steinberger and the community.
|
|||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||||
AI/vibe-coded PRs welcome! 🤖
|
AI/vibe-coded PRs welcome! 🤖
|
||||||
|
|
||||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
|
||||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
|
||||||
|
|
||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
|
||||||
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
|
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
|
||||||
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a>
|
||||||
<a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
|
<a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
|
||||||
<a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a>
|
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||||
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
|
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
|
||||||
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
|
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
|
||||||
<a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
<a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||||
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
<a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||||
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a>
|
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a>
|
||||||
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a>
|
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||||
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a>
|
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||||
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
|
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a>
|
||||||
<a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
<a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
|
||||||
<a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
|
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||||
<a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
|
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
|
||||||
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
|
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
|
||||||
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
|
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
|
||||||
<a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
|
||||||
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a>
|
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||||
<a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a>
|
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||||
<a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
|
||||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
<a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
|
|
||||||
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a>
|
|
||||||
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
|
|
||||||
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
|
||||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
</p>
|
</p>
|
||||||
@@ -12,3 +12,4 @@ If you believe you’ve found a security issue in Clawdbot, please report it pri
|
|||||||
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
- `https://docs.clawd.bot/gateway/security`
|
||||||
|
|
||||||
+273
-195
@@ -3,213 +3,291 @@
|
|||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.23</title>
|
<title>2026.1.20</title>
|
||||||
<pubDate>Sat, 24 Jan 2026 13:02:18 +0000</pubDate>
|
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
<sparkle:version>7750</sparkle:version>
|
<sparkle:version>7116</sparkle:version>
|
||||||
<sparkle:shortVersionString>2026.1.23</sparkle:shortVersionString>
|
<sparkle:shortVersionString>2026.1.20</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.23</h2>
|
<description><![CDATA[<h2>Clawdbot 2026.1.20</h2>
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
<h3>Changes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.</li>
|
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||||
<li>Agents: keep system prompt time zone-only and move current time to <code>session_status</code> for better cache hits.</li>
|
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||||
<li>Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.</li>
|
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
|
||||||
<li>Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).</li>
|
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
|
||||||
<li>Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.</li>
|
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
|
||||||
<li>Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.</li>
|
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
|
||||||
<li>CLI: restart the gateway by default after <code>clawdbot update</code>; add <code>--no-restart</code> to skip it.</li>
|
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
|
||||||
<li>CLI: add live auth probes to <code>clawdbot models status</code> for per-profile verification.</li>
|
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
|
||||||
<li>CLI: add <code>clawdbot system</code> for system events + heartbeat controls; remove standalone <code>wake</code>.</li>
|
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
|
||||||
<li>Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.</li>
|
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
|
||||||
<li>Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.</li>
|
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
|
||||||
<li>Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.</li>
|
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
|
||||||
<li>Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.</li>
|
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
|
||||||
<li>Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.</li>
|
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
|
||||||
<li>Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.</li>
|
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
|
||||||
<li>TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.</li>
|
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
|
||||||
</ul>
|
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
|
||||||
<h3>Fixes</h3>
|
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
|
||||||
<ul>
|
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
|
||||||
<li>Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)</li>
|
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
|
||||||
<li>Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.</li>
|
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
|
||||||
<li>Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)</li>
|
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
|
||||||
<li>Sessions: normalize session key casing to lowercase for consistent routing.</li>
|
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
|
||||||
<li>BlueBubbles: normalize group session keys for outbound mirroring. (#1520)</li>
|
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
|
||||||
<li>Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.</li>
|
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
|
||||||
<li>Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.</li>
|
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
|
||||||
<li>Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).</li>
|
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
|
||||||
<li>Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)</li>
|
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||||
<li>Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.</li>
|
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||||
<li>Docker: update gateway command in docker-compose and Hetzner guide. (#1514)</li>
|
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
|
||||||
<li>Sessions: reject array-backed session stores to prevent silent wipes. (#1469)</li>
|
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||||
<li>Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.</li>
|
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
|
||||||
<li>UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.</li>
|
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
|
||||||
<li>UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.</li>
|
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
|
||||||
<li>Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.</li>
|
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
|
||||||
<li>Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.</li>
|
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||||
<li>Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.</li>
|
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
|
||||||
<li>Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.</li>
|
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
|
||||||
<li>Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).</li>
|
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
|
||||||
<li>Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.</li>
|
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
|
||||||
<li>Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).</li>
|
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
|
||||||
<li>TUI: forward unknown slash commands (for example, <code>/context</code>) to the Gateway.</li>
|
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
|
||||||
<li>TUI: include Gateway slash commands in autocomplete and <code>/help</code>.</li>
|
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
|
||||||
<li>CLI: skip usage lines in <code>clawdbot models status</code> when provider usage is unavailable.</li>
|
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
|
||||||
<li>CLI: suppress diagnostic session/run noise during auth probes.</li>
|
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
|
||||||
<li>CLI: hide auth probe timeout warnings from embedded runs.</li>
|
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
|
||||||
<li>CLI: render auth probe results as a table in <code>clawdbot models status</code>.</li>
|
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
|
||||||
<li>CLI: suppress probe-only embedded logs unless <code>--verbose</code> is set.</li>
|
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
|
||||||
<li>CLI: move auth probe errors below the table to reduce wrapping.</li>
|
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
|
||||||
<li>CLI: prevent ANSI color bleed when table cells wrap.</li>
|
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
|
||||||
<li>CLI: explain when auth profiles are excluded by auth.order in probe details.</li>
|
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
|
||||||
<li>CLI: drop the em dash when the banner tagline wraps to a second line.</li>
|
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
|
||||||
<li>CLI: inline auth probe errors in status rows to reduce wrapping.</li>
|
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
|
||||||
<li>Telegram: render markdown in media captions. (#1478)</li>
|
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
|
||||||
<li>Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.</li>
|
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
|
||||||
<li>Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)</li>
|
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
|
||||||
<li>Daemon: use platform PATH delimiters when building minimal service paths.</li>
|
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
|
||||||
<li>Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.</li>
|
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
|
||||||
<li>Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.</li>
|
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
|
||||||
<li>TUI: render Gateway slash-command replies as system output (for example, <code>/context</code>).</li>
|
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
|
||||||
<li>Media: only parse <code>MEDIA:</code> tags when they start the line to avoid stripping prose mentions. (#1206)</li>
|
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
|
||||||
<li>Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.</li>
|
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
|
||||||
<li>Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)</li>
|
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
|
||||||
<li>Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.</li>
|
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
|
||||||
<li>MS Teams (plugin): remove <code>.default</code> suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.</li>
|
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
|
||||||
<li>MS Teams (plugin): remove <code>.default</code> suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.</li>
|
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
|
||||||
<li>Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)</li>
|
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
|
||||||
<li>Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)</li>
|
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
|
||||||
</ul>
|
<li>Build: update workspace + core/plugin deps.</li>
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
|
||||||
]]></description>
|
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.23/Clawdbot-2026.1.23.zip" length="22326233" type="application/octet-stream" sparkle:edSignature="p40dFczUfmMpsif4BrEUYVqUPG2WiBXleWgefwu4WiqjuyXbw7CAaH5CpQKig/k2qRLlE59kX7AR/qJqmy+yCA=="/>
|
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
|
||||||
</item>
|
<li>macOS: stop syncing Peekaboo in postinstall.</li>
|
||||||
<item>
|
<li>Swabble: use the tagged Commander Swift package release.</li>
|
||||||
<title>2026.1.22</title>
|
|
||||||
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>7530</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.22</h2>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.</li>
|
|
||||||
<li>Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.</li>
|
|
||||||
<li>Slack: add chat-type reply threading overrides via <code>replyToModeByChatType</code>. (#1442) Thanks @stefangalescu.</li>
|
|
||||||
<li>BlueBubbles: add <code>asVoice</code> support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.</li>
|
|
||||||
<li>Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.</li>
|
|
||||||
<li>Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.</li>
|
|
||||||
<li>Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.</li>
|
|
||||||
<li>Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.</li>
|
|
||||||
<li>Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.</li>
|
|
||||||
<li>Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.</li>
|
|
||||||
<li>Gateway: stop the service before uninstalling and fail if it remains loaded.</li>
|
|
||||||
<li>Agents: surface concrete API error details instead of generic AI service errors.</li>
|
|
||||||
<li>Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)</li>
|
|
||||||
<li>Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.</li>
|
|
||||||
<li>Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.</li>
|
|
||||||
<li>Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.</li>
|
|
||||||
<li>Agents: make tool summaries more readable and only show optional params when set.</li>
|
|
||||||
<li>Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.</li>
|
|
||||||
<li>Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.</li>
|
|
||||||
<li>CLI: prefer <code>~</code> for home paths in output.</li>
|
|
||||||
<li>Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.</li>
|
|
||||||
<li>Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.</li>
|
|
||||||
<li>Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.</li>
|
|
||||||
<li>Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.</li>
|
|
||||||
<li>Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.</li>
|
|
||||||
<li>Slack: reduce WebClient retries to avoid duplicate sends. (#1481)</li>
|
|
||||||
<li>Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.</li>
|
|
||||||
<li>macOS: prefer linked channels in gateway summary to avoid false “not linked” status.</li>
|
|
||||||
<li>macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.22/Clawdbot-2026.1.22.zip" length="22302446" type="application/octet-stream" sparkle:edSignature="w/EzfwGBCRRuCg5vz8enIfYujxOZJWRw9PaunQ7gIafKwnBJSTtxcnkvMVwQsnBwB6VN5Tu2MPij7PjDFFX+CA=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>2026.1.21</title>
|
|
||||||
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>7374</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster</li>
|
|
||||||
<li>Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui</li>
|
|
||||||
<li>Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning</li>
|
|
||||||
<li>Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated</li>
|
|
||||||
<li>Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams</li>
|
|
||||||
<li><code>/models</code> UX refresh + <code>clawdbot update wizard</code>. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.</li>
|
|
||||||
<li>Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents</li>
|
|
||||||
<li>Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui</li>
|
|
||||||
<li>CLI: add <code>clawdbot update wizard</code> with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update</li>
|
|
||||||
<li>Models/Commands: add <code>/models</code>, improve <code>/model</code> listing UX, and expand <code>clawdbot models</code> paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models</li>
|
|
||||||
<li>CLI: move gateway service commands under <code>clawdbot gateway</code>, flatten node service commands under <code>clawdbot node</code>, and add <code>gateway probe</code> for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node</li>
|
|
||||||
<li>Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals</li>
|
|
||||||
<li>Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals</li>
|
|
||||||
<li>Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat</li>
|
|
||||||
<li>Sessions: add per-channel idle durations via <code>sessions.channelIdleMinutes</code>. (#1353) Thanks @cash-echo-bot.</li>
|
|
||||||
<li>Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node</li>
|
|
||||||
<li>Cache: add <code>cache.ttlPrune</code> mode and auth-aware defaults for cache TTL behavior.</li>
|
|
||||||
<li>Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue</li>
|
|
||||||
<li>Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord</li>
|
|
||||||
<li>Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal</li>
|
|
||||||
<li>MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams</li>
|
|
||||||
<li>Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).</li>
|
|
||||||
<li>macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).</li>
|
|
||||||
<li>Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.</li>
|
|
||||||
<li>Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Breaking</h3>
|
<h3>Breaking</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>BREAKING:</strong> Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set <code>gateway.controlUi.allowInsecureAuth: true</code> to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http</li>
|
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
|
||||||
<li><strong>BREAKING:</strong> Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Fixes</h3>
|
<h3>Fixes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.</li>
|
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
|
||||||
<li>Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.</li>
|
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
|
||||||
<li>Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.</li>
|
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
|
||||||
<li>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</li>
|
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
|
||||||
<li>Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.</li>
|
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
|
||||||
<li>Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)</li>
|
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
|
||||||
<li>Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.</li>
|
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
|
||||||
<li>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</li>
|
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
|
||||||
<li>macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.</li>
|
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
|
||||||
<li>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</li>
|
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
|
||||||
<li>Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit <code>/model</code> list output. (#1376, #1416)</li>
|
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
|
||||||
<li>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
|
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
|
||||||
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
|
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
|
||||||
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
|
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
|
||||||
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
|
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
|
||||||
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
|
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
|
||||||
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</li>
|
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
|
||||||
<li>Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when <code>gateway.mode</code> is unset. (#900)</li>
|
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
|
||||||
<li>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
|
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
|
||||||
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
|
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
|
||||||
<li>Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.</li>
|
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
|
||||||
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</li>
|
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
|
||||||
|
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
|
||||||
|
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
|
||||||
|
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
|
||||||
|
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
|
||||||
|
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
|
||||||
|
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
|
||||||
|
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
|
||||||
|
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
|
||||||
|
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
|
||||||
|
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
|
||||||
|
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
|
||||||
|
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
|
||||||
|
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
|
||||||
|
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
|
||||||
|
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
|
||||||
|
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
|
||||||
|
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
|
||||||
|
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
|
||||||
|
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
|
||||||
|
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
|
||||||
|
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
|
||||||
|
<li>TUI: highlight model search matches and stabilize search ordering.</li>
|
||||||
|
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
|
||||||
|
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
|
||||||
|
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
|
||||||
|
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
|
||||||
|
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
|
||||||
|
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
|
||||||
|
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
|
||||||
|
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
|
||||||
|
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
|
||||||
|
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
|
||||||
|
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
|
||||||
|
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
|
||||||
|
<li>Exec approvals: enforce allowlist when ask is off.</li>
|
||||||
|
<li>Exec approvals: prefer raw command for node approvals/events.</li>
|
||||||
|
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
|
||||||
|
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
|
||||||
|
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
|
||||||
|
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
|
||||||
|
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
|
||||||
|
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
|
||||||
|
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
|
||||||
|
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
|
||||||
|
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
|
||||||
|
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
|
||||||
|
<li>Discord: only emit slow listener warnings after 30s.</li>
|
||||||
|
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
|
||||||
|
<li>Telegram: honor pairing allowlists for native slash commands.</li>
|
||||||
|
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
|
||||||
|
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
|
||||||
|
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
|
||||||
|
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
|
||||||
|
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
|
||||||
|
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
|
||||||
|
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
|
||||||
|
<li>Anthropic: ignore TTL for OAuth.</li>
|
||||||
|
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
|
||||||
|
<li>Auth profiles: user pins stay locked. (#1138)</li>
|
||||||
|
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
|
||||||
|
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
|
||||||
|
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
|
||||||
|
<li>Windows: install gateway scheduled task as the current user.</li>
|
||||||
|
<li>Windows: show friendly guidance instead of failing on access denied.</li>
|
||||||
|
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
|
||||||
|
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
|
||||||
|
<li>macOS: suppress usage error text in the menubar cost view.</li>
|
||||||
|
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
|
||||||
|
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
|
||||||
|
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
|
||||||
|
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
|
||||||
|
</ul>
|
||||||
|
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.20/Clawdbot-2026.1.20.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.16-2</title>
|
||||||
|
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>6273</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.15</title>
|
||||||
|
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>5998</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
||||||
|
<h3>Highlights</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||||
|
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
||||||
|
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
||||||
|
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Breaking</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
||||||
|
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
||||||
|
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
||||||
|
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
||||||
|
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
||||||
|
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||||
|
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
||||||
|
<li>TUI: show provider/model labels for the active session and default model.</li>
|
||||||
|
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
||||||
|
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
||||||
|
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
||||||
|
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||||
|
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
||||||
|
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
||||||
|
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
||||||
|
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
||||||
|
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
||||||
|
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
||||||
|
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
||||||
|
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
||||||
|
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
||||||
|
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
||||||
|
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
||||||
|
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||||
|
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||||
|
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
||||||
|
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
||||||
|
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
||||||
|
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
||||||
|
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
||||||
|
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
||||||
|
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
||||||
|
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
||||||
|
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
||||||
|
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
||||||
|
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
||||||
|
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
||||||
|
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
||||||
|
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
||||||
|
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
||||||
|
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
||||||
|
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
||||||
|
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
||||||
|
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||||
|
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
||||||
|
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
||||||
|
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
||||||
|
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
||||||
|
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
||||||
|
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
||||||
|
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||||
</item>
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601240
|
versionCode = 202601200
|
||||||
versionName = "2026.1.24"
|
versionName = "2026.1.20"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ data class CameraHudState(
|
|||||||
val kind: CameraHudKind,
|
val kind: CameraHudKind,
|
||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,3 +12,4 @@ enum class VoiceWakeMode(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,14 +8,10 @@ object WakeWords {
|
|||||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseIfChanged(input: String, current: List<String>): List<String>? {
|
|
||||||
val parsed = parseCommaSeparated(input)
|
|
||||||
return if (parsed == current) null else parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||||
val cleaned =
|
val cleaned =
|
||||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||||
return cleaned.ifEmpty { defaults }
|
return cleaned.ifEmpty { defaults }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ class SmsManager(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an SMS message.
|
* Send an SMS message.
|
||||||
*
|
*
|
||||||
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
||||||
* @return SendResult indicating success or failure
|
* @return SendResult indicating success or failure
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
@@ -51,10 +49,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -63,7 +58,6 @@ import com.clawdbot.android.LocationMode
|
|||||||
import com.clawdbot.android.MainViewModel
|
import com.clawdbot.android.MainViewModel
|
||||||
import com.clawdbot.android.NodeForegroundService
|
import com.clawdbot.android.NodeForegroundService
|
||||||
import com.clawdbot.android.VoiceWakeMode
|
import com.clawdbot.android.VoiceWakeMode
|
||||||
import com.clawdbot.android.WakeWords
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsSheet(viewModel: MainViewModel) {
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
@@ -92,8 +86,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
var wakeWordsHadFocus by remember { mutableStateOf(false) }
|
|
||||||
val deviceModel =
|
val deviceModel =
|
||||||
remember {
|
remember {
|
||||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
@@ -112,12 +104,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
val commitWakeWords = {
|
|
||||||
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
|
||||||
if (parsed != null) {
|
|
||||||
viewModel.setWakeWords(parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
@@ -495,27 +481,25 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
value = wakeWordsText,
|
value = wakeWordsText,
|
||||||
onValueChange = setWakeWordsText,
|
onValueChange = setWakeWordsText,
|
||||||
label = { Text("Wake Words (comma-separated)") },
|
label = { Text("Wake Words (comma-separated)") },
|
||||||
modifier =
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
|
||||||
if (focusState.isFocused) {
|
|
||||||
wakeWordsHadFocus = true
|
|
||||||
} else if (wakeWordsHadFocus) {
|
|
||||||
wakeWordsHadFocus = false
|
|
||||||
commitWakeWords()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions =
|
|
||||||
KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
commitWakeWords()
|
|
||||||
focusManager.clearFocus()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
|
item {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
||||||
|
viewModel.setWakeWords(parsed)
|
||||||
|
},
|
||||||
|
enabled = isConnected,
|
||||||
|
) {
|
||||||
|
Text("Save + Sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0A0A0A</color>
|
<color name="ic_launcher_background">#0A0A0A</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Clawdbot Node</string>
|
<string name="app_name">Clawdbot Node</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.clawdbot.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class WakeWordsTest {
|
class WakeWordsTest {
|
||||||
@@ -33,18 +32,5 @@ class WakeWordsTest {
|
|||||||
assertEquals("w1", sanitized.first())
|
assertEquals("w1", sanitized.first())
|
||||||
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseIfChangedSkipsWhenUnchanged() {
|
|
||||||
val current = listOf("clawd", "claude")
|
|
||||||
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
|
|
||||||
assertNull(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseIfChangedReturnsUpdatedList() {
|
|
||||||
val current = listOf("clawd")
|
|
||||||
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
|
|
||||||
assertEquals(listOf("clawd", "jarvis"), parsed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
@@ -23,3 +23,4 @@ class VoiceWakeCommandExtractorTest {
|
|||||||
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "ClawdbotNodeAndroid"
|
rootProject.name = "ClawdbotNodeAndroid"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
||||||
@@ -3,3 +3,4 @@ parent_config: ../../.swiftlint.yml
|
|||||||
included:
|
included:
|
||||||
- Sources
|
- Sources
|
||||||
- ../shared/ClawdisNodeKit/Sources
|
- ../shared/ClawdisNodeKit/Sources
|
||||||
|
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.24</string>
|
<string>2026.1.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260124</string>
|
<string>20260120</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
|
||||||
|
|
||||||
struct VoiceWakeWordsSettingsView: View {
|
struct VoiceWakeWordsSettingsView: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||||
@FocusState private var focusedTriggerIndex: Int?
|
|
||||||
@State private var syncTask: Task<Void, Never>?
|
@State private var syncTask: Task<Void, Never>?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -14,10 +12,6 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
TextField("Wake word", text: self.binding(for: index))
|
TextField("Wake word", text: self.binding(for: index))
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.focused(self.$focusedTriggerIndex, equals: index)
|
|
||||||
.onSubmit {
|
|
||||||
self.commitTriggerWords()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onDelete(perform: self.removeWords)
|
.onDelete(perform: self.removeWords)
|
||||||
|
|
||||||
@@ -45,18 +39,17 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
self.commitTriggerWords()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
|
.onChange(of: self.triggerWords) { _, newValue in
|
||||||
guard oldValue != nil, oldValue != newValue else { return }
|
// Keep local voice wake responsive even if the gateway isn't connected yet.
|
||||||
self.commitTriggerWords()
|
VoiceWakePreferences.saveTriggerWords(newValue)
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
|
||||||
guard self.focusedTriggerIndex == nil else { return }
|
self.syncTask?.cancel()
|
||||||
let updated = VoiceWakePreferences.loadTriggerWords()
|
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
||||||
if updated != self.triggerWords {
|
try? await Task.sleep(nanoseconds: 650_000_000)
|
||||||
self.triggerWords = updated
|
await appModel?.setGlobalWakeWords(snapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +63,6 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
}
|
}
|
||||||
self.commitTriggerWords()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func binding(for index: Int) -> Binding<String> {
|
private func binding(for index: Int) -> Binding<String> {
|
||||||
@@ -84,15 +76,4 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
self.triggerWords[index] = newValue
|
self.triggerWords[index] = newValue
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func commitTriggerWords() {
|
|
||||||
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
|
|
||||||
|
|
||||||
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
|
|
||||||
self.syncTask?.cancel()
|
|
||||||
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
|
||||||
try? await Task.sleep(nanoseconds: 650_000_000)
|
|
||||||
await appModel?.setGlobalWakeWords(snapshot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ enum VoiceWakePreferences {
|
|||||||
|
|
||||||
// Keep defaults aligned with the mac app.
|
// Keep defaults aligned with the mac app.
|
||||||
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
||||||
static let maxWords = 32
|
|
||||||
static let maxWordLength = 64
|
|
||||||
|
|
||||||
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
||||||
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
||||||
@@ -32,8 +30,6 @@ enum VoiceWakePreferences {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
.prefix(Self.maxWords)
|
|
||||||
.map { String($0.prefix(Self.maxWordLength)) }
|
|
||||||
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.24</string>
|
<string>2026.1.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260124</string>
|
<string>20260120</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -11,18 +11,6 @@ import Testing
|
|||||||
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func sanitizeTriggerWordsLimitsWordLength() {
|
|
||||||
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
|
|
||||||
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
|
|
||||||
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func sanitizeTriggerWordsLimitsWordCount() {
|
|
||||||
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
|
|
||||||
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
|
|
||||||
#expect(cleaned.count == VoiceWakePreferences.maxWords)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayStringUsesSanitizedWords() {
|
@Test func displayStringUsesSanitizedWords() {
|
||||||
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdbot
|
CFBundleDisplayName: Clawdbot
|
||||||
CFBundleIconName: AppIcon
|
CFBundleIconName: AppIcon
|
||||||
CFBundleShortVersionString: "2026.1.24"
|
CFBundleShortVersionString: "2026.1.20"
|
||||||
CFBundleVersion: "20260124"
|
CFBundleVersion: "20260120"
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
@@ -130,5 +130,5 @@ targets:
|
|||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: ClawdbotTests
|
CFBundleDisplayName: ClawdbotTests
|
||||||
CFBundleShortVersionString: "2026.1.24"
|
CFBundleShortVersionString: "2026.1.20"
|
||||||
CFBundleVersion: "20260124"
|
CFBundleVersion: "20260120"
|
||||||
@@ -33,4 +33,4 @@
|
|||||||
],
|
],
|
||||||
"squares" : "shared"
|
"squares" : "shared"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2",
|
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "axorcist",
|
"identity" : "axorcist",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ final class AppState {
|
|||||||
case remote
|
case remote
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RemoteTransport: String {
|
|
||||||
case ssh
|
|
||||||
case direct
|
|
||||||
}
|
|
||||||
|
|
||||||
var isPaused: Bool {
|
var isPaused: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||||
}
|
}
|
||||||
@@ -171,10 +166,6 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var remoteTransport: RemoteTransport {
|
|
||||||
didSet { self.syncGatewayConfigIfNeeded() }
|
|
||||||
}
|
|
||||||
|
|
||||||
var canvasEnabled: Bool {
|
var canvasEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||||
}
|
}
|
||||||
@@ -209,10 +200,6 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var remoteUrl: String {
|
|
||||||
didSet { self.syncGatewayConfigIfNeeded() }
|
|
||||||
}
|
|
||||||
|
|
||||||
var remoteIdentity: String {
|
var remoteIdentity: String {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||||
}
|
}
|
||||||
@@ -276,15 +263,13 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let configRoot = ClawdbotConfigFile.loadDict()
|
let configRoot = ClawdbotConfigFile.loadDict()
|
||||||
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||||
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||||
self.remoteTransport = configRemoteTransport
|
|
||||||
self.connectionMode = resolvedConnectionMode
|
self.connectionMode = resolvedConnectionMode
|
||||||
|
|
||||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
if resolvedConnectionMode == .remote,
|
if resolvedConnectionMode == .remote,
|
||||||
configRemoteTransport != .direct,
|
|
||||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||||
{
|
{
|
||||||
@@ -292,7 +277,6 @@ final class AppState {
|
|||||||
} else {
|
} else {
|
||||||
self.remoteTarget = storedRemoteTarget
|
self.remoteTarget = storedRemoteTarget
|
||||||
}
|
}
|
||||||
self.remoteUrl = configRemoteUrl ?? ""
|
|
||||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
@@ -370,11 +354,10 @@ final class AppState {
|
|||||||
private func applyConfigOverrides(_ root: [String: Any]) {
|
private func applyConfigOverrides(_ root: [String: Any]) {
|
||||||
let gateway = root["gateway"] as? [String: Any]
|
let gateway = root["gateway"] as? [String: Any]
|
||||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
|
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
let hasRemoteUrl = !(remoteUrl?
|
let hasRemoteUrl = !(remoteUrl?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty ?? true)
|
.isEmpty ?? true)
|
||||||
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
|
|
||||||
|
|
||||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||||
case "local":
|
case "local":
|
||||||
@@ -395,17 +378,8 @@ final class AppState {
|
|||||||
self.connectionMode = .remote
|
self.connectionMode = .remote
|
||||||
}
|
}
|
||||||
|
|
||||||
if remoteTransport != self.remoteTransport {
|
|
||||||
self.remoteTransport = remoteTransport
|
|
||||||
}
|
|
||||||
let remoteUrlText = remoteUrl ?? ""
|
|
||||||
if remoteUrlText != self.remoteUrl {
|
|
||||||
self.remoteUrl = remoteUrlText
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetMode = desiredMode ?? self.connectionMode
|
let targetMode = desiredMode ?? self.connectionMode
|
||||||
if targetMode == .remote,
|
if targetMode == .remote,
|
||||||
remoteTransport != .direct,
|
|
||||||
let host = AppState.remoteHost(from: remoteUrl)
|
let host = AppState.remoteHost(from: remoteUrl)
|
||||||
{
|
{
|
||||||
self.updateRemoteTarget(host: host)
|
self.updateRemoteTarget(host: host)
|
||||||
@@ -428,8 +402,6 @@ final class AppState {
|
|||||||
let connectionMode = self.connectionMode
|
let connectionMode = self.connectionMode
|
||||||
let remoteTarget = self.remoteTarget
|
let remoteTarget = self.remoteTarget
|
||||||
let remoteIdentity = self.remoteIdentity
|
let remoteIdentity = self.remoteIdentity
|
||||||
let remoteTransport = self.remoteTransport
|
|
||||||
let remoteUrl = self.remoteUrl
|
|
||||||
let desiredMode: String? = switch connectionMode {
|
let desiredMode: String? = switch connectionMode {
|
||||||
case .local:
|
case .local:
|
||||||
"local"
|
"local"
|
||||||
@@ -463,63 +435,39 @@ final class AppState {
|
|||||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||||
var remoteChanged = false
|
var remoteChanged = false
|
||||||
|
|
||||||
if remoteTransport == .direct {
|
if let host = remoteHost {
|
||||||
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
let existingUrl = (remote["url"] as? String)?
|
||||||
if trimmedUrl.isEmpty {
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if remote["url"] != nil {
|
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||||
remote.removeValue(forKey: "url")
|
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||||
remoteChanged = true
|
let port = parsedExisting?.port ?? 18789
|
||||||
}
|
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||||
} else {
|
if existingUrl != desiredUrl {
|
||||||
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
|
remote["url"] = desiredUrl
|
||||||
if (remote["url"] as? String) != normalizedUrl {
|
|
||||||
remote["url"] = normalizedUrl
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
|
|
||||||
remote["transport"] = RemoteTransport.direct.rawValue
|
|
||||||
remoteChanged = true
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
if remote["transport"] != nil {
|
|
||||||
remote.removeValue(forKey: "transport")
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
if let host = remoteHost {
|
|
||||||
let existingUrl = (remote["url"] as? String)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
|
||||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
|
||||||
let port = parsedExisting?.port ?? 18789
|
|
||||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
|
||||||
if existingUrl != desiredUrl {
|
|
||||||
remote["url"] = desiredUrl
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||||
if !sanitizedTarget.isEmpty {
|
if !sanitizedTarget.isEmpty {
|
||||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||||
remote["sshTarget"] = sanitizedTarget
|
remote["sshTarget"] = sanitizedTarget
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
} else if remote["sshTarget"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshTarget")
|
|
||||||
remoteChanged = true
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
|
} else if remote["sshTarget"] != nil {
|
||||||
|
remote.removeValue(forKey: "sshTarget")
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmedIdentity.isEmpty {
|
if !trimmedIdentity.isEmpty {
|
||||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||||
remote["sshIdentity"] = trimmedIdentity
|
remote["sshIdentity"] = trimmedIdentity
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
} else if remote["sshIdentity"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshIdentity")
|
|
||||||
remoteChanged = true
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
|
} else if remote["sshIdentity"] != nil {
|
||||||
|
remote.removeValue(forKey: "sshIdentity")
|
||||||
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if remoteChanged {
|
if remoteChanged {
|
||||||
@@ -673,10 +621,8 @@ extension AppState {
|
|||||||
state.iconOverride = .system
|
state.iconOverride = .system
|
||||||
state.heartbeatsEnabled = true
|
state.heartbeatsEnabled = true
|
||||||
state.connectionMode = .local
|
state.connectionMode = .local
|
||||||
state.remoteTransport = .ssh
|
|
||||||
state.canvasEnabled = true
|
state.canvasEnabled = true
|
||||||
state.remoteTarget = "user@example.com"
|
state.remoteTarget = "user@example.com"
|
||||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
|
||||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||||
state.remoteCliPath = ""
|
state.remoteCliPath = ""
|
||||||
|
|||||||
@@ -40,16 +40,6 @@ extension ChannelsSettings {
|
|||||||
return .orange
|
return .orange
|
||||||
}
|
}
|
||||||
|
|
||||||
var googlechatTint: Color {
|
|
||||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
|
||||||
else { return .secondary }
|
|
||||||
if !status.configured { return .secondary }
|
|
||||||
if status.lastError != nil { return .orange }
|
|
||||||
if status.probe?.ok == false { return .orange }
|
|
||||||
if status.running { return .green }
|
|
||||||
return .orange
|
|
||||||
}
|
|
||||||
|
|
||||||
var signalTint: Color {
|
var signalTint: Color {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
@@ -95,14 +85,6 @@ extension ChannelsSettings {
|
|||||||
return "Configured"
|
return "Configured"
|
||||||
}
|
}
|
||||||
|
|
||||||
var googlechatSummary: String {
|
|
||||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
|
||||||
else { return "Checking…" }
|
|
||||||
if !status.configured { return "Not configured" }
|
|
||||||
if status.running { return "Running" }
|
|
||||||
return "Configured"
|
|
||||||
}
|
|
||||||
|
|
||||||
var signalSummary: String {
|
var signalSummary: String {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
@@ -211,37 +193,6 @@ extension ChannelsSettings {
|
|||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
var googlechatDetails: String? {
|
|
||||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
|
||||||
else { return nil }
|
|
||||||
var lines: [String] = []
|
|
||||||
if let source = status.credentialSource {
|
|
||||||
lines.append("Credential: \(source)")
|
|
||||||
}
|
|
||||||
if let audienceType = status.audienceType {
|
|
||||||
let audience = status.audience ?? ""
|
|
||||||
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
|
|
||||||
lines.append("Audience: \(label)")
|
|
||||||
}
|
|
||||||
if let probe = status.probe {
|
|
||||||
if probe.ok {
|
|
||||||
if let elapsed = probe.elapsedMs {
|
|
||||||
lines.append("Probe \(Int(elapsed))ms")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let code = probe.status.map { String($0) } ?? "unknown"
|
|
||||||
lines.append("Probe failed (\(code))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
|
||||||
lines.append("Last probe \(relativeAge(from: last))")
|
|
||||||
}
|
|
||||||
if let err = status.lastError, !err.isEmpty {
|
|
||||||
lines.append("Error: \(err)")
|
|
||||||
}
|
|
||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
|
||||||
}
|
|
||||||
|
|
||||||
var signalDetails: String? {
|
var signalDetails: String? {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
@@ -293,7 +244,7 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var orderedChannels: [ChannelItem] {
|
var orderedChannels: [ChannelItem] {
|
||||||
let fallback = ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage"]
|
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
|
||||||
let order = self.store.snapshot?.channelOrder ?? fallback
|
let order = self.store.snapshot?.channelOrder ?? fallback
|
||||||
let channels = order.enumerated().map { index, id in
|
let channels = order.enumerated().map { index, id in
|
||||||
ChannelItem(
|
ChannelItem(
|
||||||
@@ -356,8 +307,6 @@ extension ChannelsSettings {
|
|||||||
return self.telegramTint
|
return self.telegramTint
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordTint
|
return self.discordTint
|
||||||
case "googlechat":
|
|
||||||
return self.googlechatTint
|
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalTint
|
return self.signalTint
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -377,8 +326,6 @@ extension ChannelsSettings {
|
|||||||
return self.telegramSummary
|
return self.telegramSummary
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordSummary
|
return self.discordSummary
|
||||||
case "googlechat":
|
|
||||||
return self.googlechatSummary
|
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalSummary
|
return self.signalSummary
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -398,8 +345,6 @@ extension ChannelsSettings {
|
|||||||
return self.telegramDetails
|
return self.telegramDetails
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordDetails
|
return self.discordDetails
|
||||||
case "googlechat":
|
|
||||||
return self.googlechatDetails
|
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalDetails
|
return self.signalDetails
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -432,10 +377,6 @@ extension ChannelsSettings {
|
|||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
case "googlechat":
|
|
||||||
return self
|
|
||||||
.date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)?
|
|
||||||
.lastProbeAt)
|
|
||||||
case "signal":
|
case "signal":
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||||
@@ -470,10 +411,6 @@ extension ChannelsSettings {
|
|||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
case "googlechat":
|
|
||||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
|
||||||
else { return false }
|
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
|
||||||
case "signal":
|
case "signal":
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|||||||
@@ -85,28 +85,6 @@ struct ChannelsStatusSnapshot: Codable {
|
|||||||
let lastProbeAt: Double?
|
let lastProbeAt: Double?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GoogleChatProbe: Codable {
|
|
||||||
let ok: Bool
|
|
||||||
let status: Int?
|
|
||||||
let error: String?
|
|
||||||
let elapsedMs: Double?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GoogleChatStatus: Codable {
|
|
||||||
let configured: Bool
|
|
||||||
let credentialSource: String?
|
|
||||||
let audienceType: String?
|
|
||||||
let audience: String?
|
|
||||||
let webhookPath: String?
|
|
||||||
let webhookUrl: String?
|
|
||||||
let running: Bool
|
|
||||||
let lastStartAt: Double?
|
|
||||||
let lastStopAt: Double?
|
|
||||||
let lastError: String?
|
|
||||||
let probe: GoogleChatProbe?
|
|
||||||
let lastProbeAt: Double?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SignalProbe: Codable {
|
struct SignalProbe: Codable {
|
||||||
let ok: Bool
|
let ok: Bool
|
||||||
let status: Int?
|
let status: Int?
|
||||||
|
|||||||
@@ -6,20 +6,15 @@ final class ConnectionModeCoordinator {
|
|||||||
static let shared = ConnectionModeCoordinator()
|
static let shared = ConnectionModeCoordinator()
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
|
||||||
private var lastMode: AppState.ConnectionMode?
|
|
||||||
|
|
||||||
/// Apply the requested connection mode by starting/stopping local gateway,
|
/// Apply the requested connection mode by starting/stopping local gateway,
|
||||||
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
|
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
|
||||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||||
if let lastMode = self.lastMode, lastMode != mode {
|
|
||||||
GatewayProcessManager.shared.clearLastFailure()
|
|
||||||
NodesStore.shared.lastError = nil
|
|
||||||
}
|
|
||||||
self.lastMode = mode
|
|
||||||
switch mode {
|
switch mode {
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
_ = await NodeServiceManager.stop()
|
if let error = await NodeServiceManager.stop() {
|
||||||
NodesStore.shared.lastError = nil
|
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||||
|
}
|
||||||
await RemoteTunnelManager.shared.stopAll()
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
GatewayProcessManager.shared.stop()
|
GatewayProcessManager.shared.stop()
|
||||||
@@ -28,8 +23,9 @@ final class ConnectionModeCoordinator {
|
|||||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||||
|
|
||||||
case .local:
|
case .local:
|
||||||
_ = await NodeServiceManager.stop()
|
if let error = await NodeServiceManager.stop() {
|
||||||
NodesStore.shared.lastError = nil
|
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||||
|
}
|
||||||
await RemoteTunnelManager.shared.stopAll()
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||||
@@ -60,7 +56,6 @@ final class ConnectionModeCoordinator {
|
|||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
NodesStore.shared.lastError = nil
|
|
||||||
if let error = await NodeServiceManager.start() {
|
if let error = await NodeServiceManager.start() {
|
||||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
|
|||||||
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
||||||
let showDockIconKey = "clawdbot.showDockIcon"
|
let showDockIconKey = "clawdbot.showDockIcon"
|
||||||
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||||
let voiceWakeMaxWords = 32
|
|
||||||
let voiceWakeMaxWordLength = 64
|
|
||||||
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
||||||
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
||||||
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private(set) var lastPingMs: Double?
|
private(set) var lastPingMs: Double?
|
||||||
private(set) var authSourceLabel: String?
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||||
|
|
||||||
@@ -129,7 +128,6 @@ final class ControlChannel {
|
|||||||
await GatewayConnection.shared.shutdown()
|
await GatewayConnection.shared.shutdown()
|
||||||
self.state = .disconnected
|
self.state = .disconnected
|
||||||
self.lastPingMs = nil
|
self.lastPingMs = nil
|
||||||
self.authSourceLabel = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||||
@@ -190,11 +188,8 @@ final class ControlChannel {
|
|||||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||||
{
|
{
|
||||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||||
let tokenKey = CommandResolver.connectionModeIsRemote()
|
|
||||||
? "gateway.remote.token"
|
|
||||||
: "gateway.auth.token"
|
|
||||||
return
|
return
|
||||||
"Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " +
|
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||||
"or clear it on the gateway. " +
|
"or clear it on the gateway. " +
|
||||||
"Reason: \(reason)"
|
"Reason: \(reason)"
|
||||||
}
|
}
|
||||||
@@ -305,27 +300,6 @@ final class ControlChannel {
|
|||||||
code: 0,
|
code: 0,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||||
}
|
}
|
||||||
await self.refreshAuthSourceLabel()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshAuthSourceLabel() async {
|
|
||||||
let isRemote = CommandResolver.connectionModeIsRemote()
|
|
||||||
let authSource = await GatewayConnection.shared.authSource()
|
|
||||||
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
|
|
||||||
guard let source else { return nil }
|
|
||||||
switch source {
|
|
||||||
case .deviceToken:
|
|
||||||
return "Auth: device token (paired device)"
|
|
||||||
case .sharedToken:
|
|
||||||
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
|
||||||
case .password:
|
|
||||||
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
|
|
||||||
case .none:
|
|
||||||
return "Auth: none"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ struct DebugSettings: View {
|
|||||||
@State private var modelsError: String?
|
@State private var modelsError: String?
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let healthStore = HealthStore.shared
|
private let healthStore = HealthStore.shared
|
||||||
@State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
|
||||||
@State private var launchAgentWriteError: String?
|
|
||||||
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||||
@State private var sessionStoreSaveError: String?
|
@State private var sessionStoreSaveError: String?
|
||||||
@@ -49,7 +47,6 @@ struct DebugSettings: View {
|
|||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
self.header
|
self.header
|
||||||
|
|
||||||
self.launchdSection
|
|
||||||
self.appInfoSection
|
self.appInfoSection
|
||||||
self.gatewaySection
|
self.gatewaySection
|
||||||
self.logsSection
|
self.logsSection
|
||||||
@@ -82,41 +79,6 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var launchdSection: some View {
|
|
||||||
GroupBox("Gateway startup") {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled)
|
|
||||||
.onChange(of: self.launchAgentWriteDisabled) { _, newValue in
|
|
||||||
self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue)
|
|
||||||
if self.launchAgentWriteError != nil {
|
|
||||||
self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if newValue {
|
|
||||||
Task {
|
|
||||||
_ = await GatewayLaunchAgentManager.set(
|
|
||||||
enabled: false,
|
|
||||||
bundlePath: Bundle.main.bundlePath,
|
|
||||||
port: GatewayEnvironment.gatewayPort())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
|
|
||||||
"It will only attach to an existing Gateway.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
if let launchAgentWriteError {
|
|
||||||
Text(launchAgentWriteError)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Debug")
|
Text("Debug")
|
||||||
|
|||||||
@@ -84,52 +84,11 @@ enum ExecApprovalDecision: String, Codable, Sendable {
|
|||||||
case deny
|
case deny
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
|
struct ExecAllowlistEntry: Codable, Hashable {
|
||||||
var id: UUID
|
|
||||||
var pattern: String
|
var pattern: String
|
||||||
var lastUsedAt: Double?
|
var lastUsedAt: Double?
|
||||||
var lastUsedCommand: String?
|
var lastUsedCommand: String?
|
||||||
var lastResolvedPath: String?
|
var lastResolvedPath: String?
|
||||||
|
|
||||||
init(
|
|
||||||
id: UUID = UUID(),
|
|
||||||
pattern: String,
|
|
||||||
lastUsedAt: Double? = nil,
|
|
||||||
lastUsedCommand: String? = nil,
|
|
||||||
lastResolvedPath: String? = nil)
|
|
||||||
{
|
|
||||||
self.id = id
|
|
||||||
self.pattern = pattern
|
|
||||||
self.lastUsedAt = lastUsedAt
|
|
||||||
self.lastUsedCommand = lastUsedCommand
|
|
||||||
self.lastResolvedPath = lastResolvedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case id
|
|
||||||
case pattern
|
|
||||||
case lastUsedAt
|
|
||||||
case lastUsedCommand
|
|
||||||
case lastResolvedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
|
|
||||||
self.pattern = try container.decode(String.self, forKey: .pattern)
|
|
||||||
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
|
|
||||||
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
|
|
||||||
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
try container.encode(self.id, forKey: .id)
|
|
||||||
try container.encode(self.pattern, forKey: .pattern)
|
|
||||||
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
|
|
||||||
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
|
|
||||||
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecApprovalsDefaults: Codable {
|
struct ExecApprovalsDefaults: Codable {
|
||||||
@@ -190,7 +149,6 @@ struct ExecApprovalsResolvedDefaults {
|
|||||||
|
|
||||||
enum ExecApprovalsStore {
|
enum ExecApprovalsStore {
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
||||||
private static let defaultAgentId = "main"
|
|
||||||
private static let defaultSecurity: ExecSecurity = .deny
|
private static let defaultSecurity: ExecSecurity = .deny
|
||||||
private static let defaultAsk: ExecAsk = .onMiss
|
private static let defaultAsk: ExecAsk = .onMiss
|
||||||
private static let defaultAskFallback: ExecSecurity = .deny
|
private static let defaultAskFallback: ExecSecurity = .deny
|
||||||
@@ -207,22 +165,13 @@ enum ExecApprovalsStore {
|
|||||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
var agents = file.agents ?? [:]
|
|
||||||
if let legacyDefault = agents["default"] {
|
|
||||||
if let main = agents[self.defaultAgentId] {
|
|
||||||
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
|
|
||||||
} else {
|
|
||||||
agents[self.defaultAgentId] = legacyDefault
|
|
||||||
}
|
|
||||||
agents.removeValue(forKey: "default")
|
|
||||||
}
|
|
||||||
return ExecApprovalsFile(
|
return ExecApprovalsFile(
|
||||||
version: 1,
|
version: 1,
|
||||||
socket: ExecApprovalsSocketConfig(
|
socket: ExecApprovalsSocketConfig(
|
||||||
path: socketPath.isEmpty ? nil : socketPath,
|
path: socketPath.isEmpty ? nil : socketPath,
|
||||||
token: token.isEmpty ? nil : token),
|
token: token.isEmpty ? nil : token),
|
||||||
defaults: file.defaults,
|
defaults: file.defaults,
|
||||||
agents: agents)
|
agents: file.agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
@@ -323,20 +272,19 @@ enum ExecApprovalsStore {
|
|||||||
ask: defaults.ask ?? self.defaultAsk,
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
let key = self.agentKey(agentId)
|
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
|
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: "default"
|
||||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||||
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||||
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||||
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||||
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback
|
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||||
?? resolvedDefaults.askFallback,
|
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
|
|
||||||
?? resolvedDefaults.autoAllowSkills)
|
|
||||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||||
.map { entry in
|
.map { entry in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
id: entry.id,
|
|
||||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: entry.lastUsedAt,
|
lastUsedAt: entry.lastUsedAt,
|
||||||
lastUsedCommand: entry.lastUsedCommand,
|
lastUsedCommand: entry.lastUsedCommand,
|
||||||
@@ -421,7 +369,6 @@ enum ExecApprovalsStore {
|
|||||||
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||||
guard item.pattern == pattern else { return item }
|
guard item.pattern == pattern else { return item }
|
||||||
return ExecAllowlistEntry(
|
return ExecAllowlistEntry(
|
||||||
id: item.id,
|
|
||||||
pattern: item.pattern,
|
pattern: item.pattern,
|
||||||
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||||
lastUsedCommand: command,
|
lastUsedCommand: command,
|
||||||
@@ -441,7 +388,6 @@ enum ExecApprovalsStore {
|
|||||||
let cleaned = allowlist
|
let cleaned = allowlist
|
||||||
.map { item in
|
.map { item in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
id: item.id,
|
|
||||||
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: item.lastUsedAt,
|
lastUsedAt: item.lastUsedAt,
|
||||||
lastUsedCommand: item.lastUsedCommand,
|
lastUsedCommand: item.lastUsedCommand,
|
||||||
@@ -509,40 +455,7 @@ enum ExecApprovalsStore {
|
|||||||
|
|
||||||
private static func agentKey(_ agentId: String?) -> String {
|
private static func agentKey(_ agentId: String?) -> String {
|
||||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return trimmed.isEmpty ? self.defaultAgentId : trimmed
|
return trimmed.isEmpty ? "default" : trimmed
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizedPattern(_ pattern: String?) -> String? {
|
|
||||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func mergeAgents(
|
|
||||||
current: ExecApprovalsAgent,
|
|
||||||
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
|
|
||||||
{
|
|
||||||
var seen = Set<String>()
|
|
||||||
var allowlist: [ExecAllowlistEntry] = []
|
|
||||||
func append(_ entry: ExecAllowlistEntry) {
|
|
||||||
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen.insert(key)
|
|
||||||
allowlist.append(entry)
|
|
||||||
}
|
|
||||||
for entry in current.allowlist ?? [] {
|
|
||||||
append(entry)
|
|
||||||
}
|
|
||||||
for entry in legacy.allowlist ?? [] {
|
|
||||||
append(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ExecApprovalsAgent(
|
|
||||||
security: current.security ?? legacy.security,
|
|
||||||
ask: current.ask ?? legacy.ask,
|
|
||||||
askFallback: current.askFallback ?? legacy.askFallback,
|
|
||||||
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
|
||||||
allowlist: allowlist.isEmpty ? nil : allowlist)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,30 +554,6 @@ enum ExecCommandFormatter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ExecApprovalHelpers {
|
|
||||||
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
|
|
||||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
guard !trimmed.isEmpty else { return nil }
|
|
||||||
return ExecApprovalDecision(rawValue: trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func requiresAsk(
|
|
||||||
ask: ExecAsk,
|
|
||||||
security: ExecSecurity,
|
|
||||||
allowlistMatch: ExecAllowlistEntry?,
|
|
||||||
skillAllow: Bool) -> Bool
|
|
||||||
{
|
|
||||||
if ask == .always { return true }
|
|
||||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? {
|
|
||||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
|
||||||
return pattern.isEmpty ? nil : pattern
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecAllowlistMatcher {
|
enum ExecAllowlistMatcher {
|
||||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||||
guard let resolution, !entries.isEmpty else { return nil }
|
guard let resolution, !entries.isEmpty else { return nil }
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import CoreGraphics
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ final class ExecApprovalsGatewayPrompter {
|
|||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(payload)
|
let data = try JSONEncoder().encode(payload)
|
||||||
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||||
guard self.shouldPresent(request: request) else { return }
|
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||||
try await GatewayConnection.shared.requestVoid(
|
try await GatewayConnection.shared.requestVoid(
|
||||||
method: .execApprovalResolve,
|
method: .execApprovalResolve,
|
||||||
@@ -58,66 +56,4 @@ final class ExecApprovalsGatewayPrompter {
|
|||||||
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldPresent(request: GatewayApprovalRequest) -> Bool {
|
|
||||||
let mode = AppStateStore.shared.connectionMode
|
|
||||||
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return Self.shouldPresent(
|
|
||||||
mode: mode,
|
|
||||||
activeSession: activeSession,
|
|
||||||
requestSession: requestSession,
|
|
||||||
lastInputSeconds: Self.lastInputSeconds(),
|
|
||||||
thresholdSeconds: 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func shouldPresent(
|
|
||||||
mode: AppState.ConnectionMode,
|
|
||||||
activeSession: String?,
|
|
||||||
requestSession: String?,
|
|
||||||
lastInputSeconds: Int?,
|
|
||||||
thresholdSeconds: Int) -> Bool
|
|
||||||
{
|
|
||||||
let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local)
|
|
||||||
|
|
||||||
if let session = requested, !session.isEmpty {
|
|
||||||
if let active, !active.isEmpty {
|
|
||||||
return active == session
|
|
||||||
}
|
|
||||||
return recentlyActive
|
|
||||||
}
|
|
||||||
|
|
||||||
if let active, !active.isEmpty {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return mode == .local
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func lastInputSeconds() -> Int? {
|
|
||||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
|
||||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
|
||||||
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
|
||||||
return Int(seconds.rounded())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
extension ExecApprovalsGatewayPrompter {
|
|
||||||
static func _testShouldPresent(
|
|
||||||
mode: AppState.ConnectionMode,
|
|
||||||
activeSession: String?,
|
|
||||||
requestSession: String?,
|
|
||||||
lastInputSeconds: Int?,
|
|
||||||
thresholdSeconds: Int = 120) -> Bool
|
|
||||||
{
|
|
||||||
self.shouldPresent(
|
|
||||||
mode: mode,
|
|
||||||
activeSession: activeSession,
|
|
||||||
requestSession: requestSession,
|
|
||||||
lastInputSeconds: lastInputSeconds,
|
|
||||||
thresholdSeconds: thresholdSeconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -13,7 +13,6 @@ struct ExecApprovalPromptRequest: Codable, Sendable {
|
|||||||
var ask: String?
|
var ask: String?
|
||||||
var agentId: String?
|
var agentId: String?
|
||||||
var resolvedPath: String?
|
var resolvedPath: String?
|
||||||
var sessionKey: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ExecApprovalSocketRequest: Codable {
|
private struct ExecApprovalSocketRequest: Codable {
|
||||||
@@ -216,15 +215,36 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Allow this command?"
|
alert.messageText = "Allow this command?"
|
||||||
alert.informativeText = "Review the command details before allowing."
|
|
||||||
alert.accessoryView = self.buildAccessoryView(request)
|
var details = "Clawdbot wants to run:\n\n\(request.command)"
|
||||||
|
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedCwd.isEmpty {
|
||||||
|
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||||
|
}
|
||||||
|
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedAgent.isEmpty {
|
||||||
|
details += "\n\nAgent:\n\(trimmedAgent)"
|
||||||
|
}
|
||||||
|
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedPath.isEmpty {
|
||||||
|
details += "\n\nExecutable:\n\(trimmedPath)"
|
||||||
|
}
|
||||||
|
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedHost.isEmpty {
|
||||||
|
details += "\n\nHost:\n\(trimmedHost)"
|
||||||
|
}
|
||||||
|
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
||||||
|
details += "\n\nSecurity:\n\(security)"
|
||||||
|
}
|
||||||
|
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
||||||
|
details += "\nAsk mode:\n\(ask)"
|
||||||
|
}
|
||||||
|
details += "\n\nThis runs on this machine."
|
||||||
|
alert.informativeText = details
|
||||||
|
|
||||||
alert.addButton(withTitle: "Allow Once")
|
alert.addButton(withTitle: "Allow Once")
|
||||||
alert.addButton(withTitle: "Always Allow")
|
alert.addButton(withTitle: "Always Allow")
|
||||||
alert.addButton(withTitle: "Don't Allow")
|
alert.addButton(withTitle: "Don't Allow")
|
||||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
|
||||||
alert.buttons[2].hasDestructiveAction = true
|
|
||||||
}
|
|
||||||
|
|
||||||
switch alert.runModal() {
|
switch alert.runModal() {
|
||||||
case .alertFirstButtonReturn:
|
case .alertFirstButtonReturn:
|
||||||
@@ -235,110 +255,6 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
return .deny
|
return .deny
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView {
|
|
||||||
let stack = NSStackView()
|
|
||||||
stack.orientation = .vertical
|
|
||||||
stack.spacing = 8
|
|
||||||
stack.alignment = .leading
|
|
||||||
|
|
||||||
let commandTitle = NSTextField(labelWithString: "Command")
|
|
||||||
commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
|
||||||
stack.addArrangedSubview(commandTitle)
|
|
||||||
|
|
||||||
let commandText = NSTextView()
|
|
||||||
commandText.isEditable = false
|
|
||||||
commandText.isSelectable = true
|
|
||||||
commandText.drawsBackground = true
|
|
||||||
commandText.backgroundColor = NSColor.textBackgroundColor
|
|
||||||
commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
|
||||||
commandText.string = request.command
|
|
||||||
commandText.textContainerInset = NSSize(width: 6, height: 6)
|
|
||||||
commandText.textContainer?.lineFragmentPadding = 0
|
|
||||||
commandText.textContainer?.widthTracksTextView = true
|
|
||||||
commandText.isHorizontallyResizable = false
|
|
||||||
commandText.isVerticallyResizable = false
|
|
||||||
|
|
||||||
let commandScroll = NSScrollView()
|
|
||||||
commandScroll.borderType = .lineBorder
|
|
||||||
commandScroll.hasVerticalScroller = false
|
|
||||||
commandScroll.hasHorizontalScroller = false
|
|
||||||
commandScroll.documentView = commandText
|
|
||||||
commandScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true
|
|
||||||
commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
|
|
||||||
stack.addArrangedSubview(commandScroll)
|
|
||||||
|
|
||||||
let contextTitle = NSTextField(labelWithString: "Context")
|
|
||||||
contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
|
||||||
stack.addArrangedSubview(contextTitle)
|
|
||||||
|
|
||||||
let contextStack = NSStackView()
|
|
||||||
contextStack.orientation = .vertical
|
|
||||||
contextStack.spacing = 4
|
|
||||||
contextStack.alignment = .leading
|
|
||||||
|
|
||||||
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedCwd.isEmpty {
|
|
||||||
self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack)
|
|
||||||
}
|
|
||||||
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedAgent.isEmpty {
|
|
||||||
self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack)
|
|
||||||
}
|
|
||||||
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedPath.isEmpty {
|
|
||||||
self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack)
|
|
||||||
}
|
|
||||||
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedHost.isEmpty {
|
|
||||||
self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack)
|
|
||||||
}
|
|
||||||
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
|
||||||
self.addDetailRow(title: "Security", value: security, to: contextStack)
|
|
||||||
}
|
|
||||||
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
|
||||||
self.addDetailRow(title: "Ask mode", value: ask, to: contextStack)
|
|
||||||
}
|
|
||||||
|
|
||||||
if contextStack.arrangedSubviews.isEmpty {
|
|
||||||
let empty = NSTextField(labelWithString: "No additional context provided.")
|
|
||||||
empty.textColor = NSColor.secondaryLabelColor
|
|
||||||
empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
||||||
contextStack.addArrangedSubview(empty)
|
|
||||||
}
|
|
||||||
|
|
||||||
stack.addArrangedSubview(contextStack)
|
|
||||||
|
|
||||||
let footer = NSTextField(labelWithString: "This runs on this machine.")
|
|
||||||
footer.textColor = NSColor.secondaryLabelColor
|
|
||||||
footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
||||||
stack.addArrangedSubview(footer)
|
|
||||||
|
|
||||||
return stack
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func addDetailRow(title: String, value: String, to stack: NSStackView) {
|
|
||||||
let row = NSStackView()
|
|
||||||
row.orientation = .horizontal
|
|
||||||
row.spacing = 6
|
|
||||||
row.alignment = .firstBaseline
|
|
||||||
|
|
||||||
let titleLabel = NSTextField(labelWithString: "\(title):")
|
|
||||||
titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
|
|
||||||
titleLabel.textColor = NSColor.secondaryLabelColor
|
|
||||||
|
|
||||||
let valueLabel = NSTextField(labelWithString: value)
|
|
||||||
valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
||||||
valueLabel.lineBreakMode = .byTruncatingMiddle
|
|
||||||
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
||||||
|
|
||||||
row.addArrangedSubview(titleLabel)
|
|
||||||
row.addArrangedSubview(valueLabel)
|
|
||||||
stack.addArrangedSubview(row)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -398,7 +314,7 @@ private enum ExecHostExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var approvedByAsk = approvalDecision != nil
|
var approvedByAsk = approvalDecision != nil
|
||||||
if ExecApprovalHelpers.requiresAsk(
|
if self.requiresAsk(
|
||||||
ask: context.ask,
|
ask: context.ask,
|
||||||
security: context.security,
|
security: context.security,
|
||||||
allowlistMatch: context.allowlistMatch,
|
allowlistMatch: context.allowlistMatch,
|
||||||
@@ -413,8 +329,7 @@ private enum ExecHostExecutor {
|
|||||||
security: context.security.rawValue,
|
security: context.security.rawValue,
|
||||||
ask: context.ask.rawValue,
|
ask: context.ask.rawValue,
|
||||||
agentId: context.trimmedAgent,
|
agentId: context.trimmedAgent,
|
||||||
resolvedPath: context.resolution?.resolvedPath,
|
resolvedPath: context.resolution?.resolvedPath))
|
||||||
sessionKey: request.sessionKey))
|
|
||||||
|
|
||||||
switch decision {
|
switch decision {
|
||||||
case .deny:
|
case .deny:
|
||||||
@@ -502,20 +417,36 @@ private enum ExecHostExecutor {
|
|||||||
skillAllow: skillAllow)
|
skillAllow: skillAllow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func requiresAsk(
|
||||||
|
ask: ExecAsk,
|
||||||
|
security: ExecSecurity,
|
||||||
|
allowlistMatch: ExecAllowlistEntry?,
|
||||||
|
skillAllow: Bool) -> Bool
|
||||||
|
{
|
||||||
|
if ask == .always { return true }
|
||||||
|
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private static func persistAllowlistEntry(
|
private static func persistAllowlistEntry(
|
||||||
decision: ExecApprovalDecision?,
|
decision: ExecApprovalDecision?,
|
||||||
context: ExecApprovalContext)
|
context: ExecApprovalContext)
|
||||||
{
|
{
|
||||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
|
||||||
command: context.command,
|
|
||||||
resolution: context.resolution)
|
|
||||||
else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func allowlistPattern(
|
||||||
|
command: [String],
|
||||||
|
resolution: ExecCommandResolution?) -> String?
|
||||||
|
{
|
||||||
|
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||||
|
return pattern.isEmpty ? nil : pattern
|
||||||
|
}
|
||||||
|
|
||||||
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||||
guard needsScreenRecording == true else { return nil }
|
guard needsScreenRecording == true else { return nil }
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
|||||||
case whatsapp
|
case whatsapp
|
||||||
case telegram
|
case telegram
|
||||||
case discord
|
case discord
|
||||||
case googlechat
|
|
||||||
case slack
|
case slack
|
||||||
case signal
|
case signal
|
||||||
case imessage
|
case imessage
|
||||||
@@ -70,7 +69,6 @@ actor GatewayConnection {
|
|||||||
case channelsLogout = "channels.logout"
|
case channelsLogout = "channels.logout"
|
||||||
case modelsList = "models.list"
|
case modelsList = "models.list"
|
||||||
case chatHistory = "chat.history"
|
case chatHistory = "chat.history"
|
||||||
case sessionsPreview = "sessions.preview"
|
|
||||||
case chatSend = "chat.send"
|
case chatSend = "chat.send"
|
||||||
case chatAbort = "chat.abort"
|
case chatAbort = "chat.abort"
|
||||||
case skillsStatus = "skills.status"
|
case skillsStatus = "skills.status"
|
||||||
@@ -251,11 +249,6 @@ actor GatewayConnection {
|
|||||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||||
}
|
}
|
||||||
|
|
||||||
func authSource() async -> GatewayAuthSource? {
|
|
||||||
guard let client else { return nil }
|
|
||||||
return await client.authSource()
|
|
||||||
}
|
|
||||||
|
|
||||||
func shutdown() async {
|
func shutdown() async {
|
||||||
if let client {
|
if let client {
|
||||||
await client.shutdown()
|
await client.shutdown()
|
||||||
@@ -542,30 +535,6 @@ extension GatewayConnection {
|
|||||||
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sessions
|
|
||||||
|
|
||||||
func sessionsPreview(
|
|
||||||
keys: [String],
|
|
||||||
limit: Int? = nil,
|
|
||||||
maxChars: Int? = nil,
|
|
||||||
timeoutMs: Int? = nil) async throws -> ClawdbotSessionsPreviewPayload
|
|
||||||
{
|
|
||||||
let resolvedKeys = keys
|
|
||||||
.map { self.canonicalizeSessionKey($0) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
if resolvedKeys.isEmpty {
|
|
||||||
return ClawdbotSessionsPreviewPayload(ts: 0, previews: [])
|
|
||||||
}
|
|
||||||
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
|
|
||||||
if let limit { params["limit"] = AnyCodable(limit) }
|
|
||||||
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
|
|
||||||
let timeout = timeoutMs.map { Double($0) }
|
|
||||||
return try await self.requestDecoded(
|
|
||||||
method: .sessionsPreview,
|
|
||||||
params: params,
|
|
||||||
timeoutMs: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Chat
|
// MARK: - Chat
|
||||||
|
|
||||||
func chatHistory(
|
func chatHistory(
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import ClawdbotDiscovery
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum GatewayDiscoveryHelpers {
|
|
||||||
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
|
||||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
|
||||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
|
||||||
let user = NSUserName()
|
|
||||||
var target = "\(user)@\(host)"
|
|
||||||
if gateway.sshPort != 22 {
|
|
||||||
target += ":\(gateway.sshPort)"
|
|
||||||
}
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
|
||||||
self.directGatewayUrl(
|
|
||||||
tailnetDns: gateway.tailnetDns,
|
|
||||||
lanHost: gateway.lanHost,
|
|
||||||
gatewayPort: gateway.gatewayPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func directGatewayUrl(
|
|
||||||
tailnetDns: String?,
|
|
||||||
lanHost: String?,
|
|
||||||
gatewayPort: Int?) -> String?
|
|
||||||
{
|
|
||||||
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
|
|
||||||
return "wss://\(tailnetDns)"
|
|
||||||
}
|
|
||||||
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
|
|
||||||
let port = gatewayPort ?? 18789
|
|
||||||
return "ws://\(lanHost):\(port)"
|
|
||||||
}
|
|
||||||
|
|
||||||
static func sanitizedTailnetHost(_ host: String?) -> String? {
|
|
||||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
|
||||||
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func trimmed(_ value: String?) -> String? {
|
|
||||||
value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,6 @@ import SwiftUI
|
|||||||
struct GatewayDiscoveryInlineList: View {
|
struct GatewayDiscoveryInlineList: View {
|
||||||
var discovery: GatewayDiscoveryModel
|
var discovery: GatewayDiscoveryModel
|
||||||
var currentTarget: String?
|
var currentTarget: String?
|
||||||
var currentUrl: String?
|
|
||||||
var transport: AppState.RemoteTransport
|
|
||||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||||
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||||
|
|
||||||
@@ -27,8 +25,9 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||||
let display = self.displayInfo(for: gateway)
|
let target = self.suggestedSSHTarget(gateway)
|
||||||
let selected = display.selected
|
let selected = (target != nil && self.currentTarget?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||||
@@ -41,7 +40,7 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
Text(display.label)
|
Text(target ?? "Gateway pairing only")
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -84,26 +83,27 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.fill(Color(NSColor.controlBackgroundColor)))
|
.fill(Color(NSColor.controlBackgroundColor)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.help(self.transport == .direct
|
.help("Click a discovered gateway to fill the SSH target.")
|
||||||
? "Click a discovered gateway to fill the gateway URL."
|
|
||||||
: "Click a discovered gateway to fill the SSH target.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func displayInfo(
|
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
|
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||||
{
|
guard let host else { return nil }
|
||||||
switch self.transport {
|
let user = NSUserName()
|
||||||
case .direct:
|
return GatewayDiscoveryModel.buildSSHTarget(
|
||||||
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
|
user: user,
|
||||||
let label = url ?? "Gateway pairing only"
|
host: host,
|
||||||
let selected = url != nil && self.trimmed(self.currentUrl) == url
|
port: gateway.sshPort)
|
||||||
return (label, selected)
|
}
|
||||||
case .ssh:
|
|
||||||
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
|
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||||
let label = target ?? "Gateway pairing only"
|
guard let host else { return nil }
|
||||||
let selected = target != nil && self.trimmed(self.currentTarget) == target
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return (label, selected)
|
if trimmed.isEmpty { return nil }
|
||||||
|
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||||
@@ -111,10 +111,6 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
if hovered { return Color.secondary.opacity(0.08) }
|
if hovered { return Color.secondary.opacity(0.08) }
|
||||||
return Color.clear
|
return Color.clear
|
||||||
}
|
}
|
||||||
|
|
||||||
private func trimmed(_ value: String?) -> String {
|
|
||||||
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayDiscoveryMenu: View {
|
struct GatewayDiscoveryMenu: View {
|
||||||
|
|||||||
@@ -311,19 +311,6 @@ actor GatewayEndpointStore {
|
|||||||
token: token,
|
token: token,
|
||||||
password: password))
|
password: password))
|
||||||
case .remote:
|
case .remote:
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
|
||||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
|
||||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
|
||||||
self.cancelRemoteEnsure()
|
|
||||||
self.setState(.unavailable(
|
|
||||||
mode: .remote,
|
|
||||||
reason: "gateway.remote.url missing or invalid for direct transport"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.cancelRemoteEnsure()
|
|
||||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let port = await self.deps.remotePortIfRunning()
|
let port = await self.deps.remotePortIfRunning()
|
||||||
guard let port else {
|
guard let port else {
|
||||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||||
@@ -354,25 +341,6 @@ actor GatewayEndpointStore {
|
|||||||
code: 1,
|
code: 1,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
}
|
}
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
|
||||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
|
||||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
|
||||||
throw NSError(
|
|
||||||
domain: "GatewayEndpoint",
|
|
||||||
code: 1,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
|
||||||
}
|
|
||||||
guard let port = GatewayRemoteConfig.defaultPort(for: url),
|
|
||||||
let portInt = UInt16(exactly: port)
|
|
||||||
else {
|
|
||||||
throw NSError(
|
|
||||||
domain: "GatewayEndpoint",
|
|
||||||
code: 1,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
|
|
||||||
}
|
|
||||||
self.logger.info("remote transport direct; skipping SSH tunnel")
|
|
||||||
return portInt
|
|
||||||
}
|
|
||||||
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
@@ -433,21 +401,6 @@ actor GatewayEndpointStore {
|
|||||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
}
|
}
|
||||||
|
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
|
||||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
|
||||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
|
||||||
throw NSError(
|
|
||||||
domain: "GatewayEndpoint",
|
|
||||||
code: 1,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
|
||||||
}
|
|
||||||
let token = self.deps.token()
|
|
||||||
let password = self.deps.password()
|
|
||||||
self.cancelRemoteEnsure()
|
|
||||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
|
||||||
return (url, token, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.kickRemoteEnsureIfNeeded(detail: detail)
|
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||||
guard let ensure = self.remoteEnsure else {
|
guard let ensure = self.remoteEnsure else {
|
||||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||||
@@ -529,7 +482,7 @@ actor GatewayEndpointStore {
|
|||||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
guard bind == "tailnet" else { return nil }
|
guard bind == "auto" else { return nil }
|
||||||
|
|
||||||
let currentHost = currentURL.host?.lowercased() ?? ""
|
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
@@ -607,13 +560,16 @@ actor GatewayEndpointStore {
|
|||||||
{
|
{
|
||||||
switch bindMode {
|
switch bindMode {
|
||||||
case "tailnet":
|
case "tailnet":
|
||||||
tailscaleIP ?? "127.0.0.1"
|
return tailscaleIP ?? "127.0.0.1"
|
||||||
case "auto":
|
case "auto":
|
||||||
"127.0.0.1"
|
if let tailscaleIP, !tailscaleIP.isEmpty {
|
||||||
|
return tailscaleIP
|
||||||
|
}
|
||||||
|
return "127.0.0.1"
|
||||||
case "custom":
|
case "custom":
|
||||||
customBindHost ?? "127.0.0.1"
|
return customBindHost ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
"127.0.0.1"
|
return "127.0.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,46 +4,11 @@ enum GatewayLaunchAgentManager {
|
|||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
private static var disableLaunchAgentMarkerURL: URL {
|
|
||||||
FileManager().homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager().homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isLaunchAgentWriteDisabled() -> Bool {
|
|
||||||
FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
|
|
||||||
let marker = self.disableLaunchAgentMarkerURL
|
|
||||||
if disabled {
|
|
||||||
do {
|
|
||||||
try FileManager().createDirectory(
|
|
||||||
at: marker.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true)
|
|
||||||
if !FileManager().fileExists(atPath: marker.path) {
|
|
||||||
FileManager().createFile(atPath: marker.path, contents: nil)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return error.localizedDescription
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if FileManager().fileExists(atPath: marker.path) {
|
|
||||||
do {
|
|
||||||
try FileManager().removeItem(at: marker)
|
|
||||||
} catch {
|
|
||||||
return error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isLoaded() async -> Bool {
|
static func isLoaded() async -> Bool {
|
||||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||||
return loaded
|
return loaded
|
||||||
@@ -101,6 +66,12 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension GatewayLaunchAgentManager {
|
extension GatewayLaunchAgentManager {
|
||||||
|
private static func isLaunchAgentWriteDisabled() -> Bool {
|
||||||
|
let marker = FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
|
return FileManager().fileExists(atPath: marker.path)
|
||||||
|
}
|
||||||
|
|
||||||
private static func readDaemonLoaded() async -> Bool? {
|
private static func readDaemonLoaded() async -> Bool? {
|
||||||
let result = await self.runDaemonCommandResult(
|
let result = await self.runDaemonCommandResult(
|
||||||
["status", "--json", "--no-probe"],
|
["status", "--json", "--no-probe"],
|
||||||
@@ -144,7 +115,7 @@ extension GatewayLaunchAgentManager {
|
|||||||
quiet: Bool) async -> CommandResult
|
quiet: Bool) async -> CommandResult
|
||||||
{
|
{
|
||||||
let command = CommandResolver.clawdbotCommand(
|
let command = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "gateway",
|
subcommand: "daemon",
|
||||||
extraArgs: self.withJsonFlag(args),
|
extraArgs: self.withJsonFlag(args),
|
||||||
// Launchd management must always run locally, even if remote mode is configured.
|
// Launchd management must always run locally, even if remote mode is configured.
|
||||||
configRoot: ["gateway": ["mode": "local"]])
|
configRoot: ["gateway": ["mode": "local"]])
|
||||||
|
|||||||
@@ -42,20 +42,10 @@ final class GatewayProcessManager {
|
|||||||
private var environmentRefreshTask: Task<Void, Never>?
|
private var environmentRefreshTask: Task<Void, Never>?
|
||||||
private var lastEnvironmentRefresh: Date?
|
private var lastEnvironmentRefresh: Date?
|
||||||
private var logRefreshTask: Task<Void, Never>?
|
private var logRefreshTask: Task<Void, Never>?
|
||||||
#if DEBUG
|
|
||||||
private var testingConnection: GatewayConnection?
|
|
||||||
#endif
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
|
||||||
|
|
||||||
private let logLimit = 20000 // characters to keep in-memory
|
private let logLimit = 20000 // characters to keep in-memory
|
||||||
private let environmentRefreshMinInterval: TimeInterval = 30
|
private let environmentRefreshMinInterval: TimeInterval = 30
|
||||||
private var connection: GatewayConnection {
|
|
||||||
#if DEBUG
|
|
||||||
return self.testingConnection ?? .shared
|
|
||||||
#else
|
|
||||||
return .shared
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
func setActive(_ active: Bool) {
|
func setActive(_ active: Bool) {
|
||||||
// Remote mode should never spawn a local gateway; treat as stopped.
|
// Remote mode should never spawn a local gateway; treat as stopped.
|
||||||
@@ -79,11 +69,6 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
|
||||||
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
|
|
||||||
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||||
guard !enabled else { return }
|
guard !enabled else { return }
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
@@ -141,10 +126,6 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearLastFailure() {
|
|
||||||
self.lastFailureReason = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshEnvironmentStatus(force: Bool = false) {
|
func refreshEnvironmentStatus(force: Bool = false) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if !force {
|
if !force {
|
||||||
@@ -197,7 +178,7 @@ final class GatewayProcessManager {
|
|||||||
let hasListener = instance != nil
|
let hasListener = instance != nil
|
||||||
|
|
||||||
let attemptAttach = {
|
let attemptAttach = {
|
||||||
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
|
try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
for attempt in 0..<(hasListener ? 3 : 1) {
|
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||||
@@ -206,7 +187,6 @@ final class GatewayProcessManager {
|
|||||||
let snap = decodeHealthSnapshot(from: data)
|
let snap = decodeHealthSnapshot(from: data)
|
||||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||||
self.existingGatewayDetails = details
|
self.existingGatewayDetails = details
|
||||||
self.clearLastFailure()
|
|
||||||
self.status = .attachedExisting(details: details)
|
self.status = .attachedExisting(details: details)
|
||||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||||
self.logger.info("gateway using existing instance details=\(details)")
|
self.logger.info("gateway using existing instance details=\(details)")
|
||||||
@@ -242,17 +222,19 @@ final class GatewayProcessManager {
|
|||||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||||
let instanceText = instance ?? "pid unknown"
|
let instanceText = instance ?? "pid unknown"
|
||||||
if let snap {
|
if let snap {
|
||||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
let linkId = snap.channelOrder?.first(where: {
|
||||||
let linkId = order.first(where: { snap.channels[$0]?.linked == true })
|
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||||
?? order.first(where: { snap.channels[$0]?.linked != nil })
|
return false
|
||||||
guard let linkId else {
|
}) ?? snap.channels.keys.first(where: {
|
||||||
return "port \(port), health probe succeeded, \(instanceText)"
|
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||||
}
|
return false
|
||||||
let linked = snap.channels[linkId]?.linked ?? false
|
})
|
||||||
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||||
|
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||||
let label =
|
let label =
|
||||||
snap.channelLabels?[linkId] ??
|
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||||
linkId.capitalized
|
linkId?.capitalized ??
|
||||||
|
"channel"
|
||||||
let linkText = linked ? "linked" : "not linked"
|
let linkText = linked ? "linked" : "not linked"
|
||||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||||
}
|
}
|
||||||
@@ -311,15 +293,6 @@ final class GatewayProcessManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
|
||||||
let message = "Launchd disabled; start the Gateway manually or disable attach-only."
|
|
||||||
self.status = .failed(message)
|
|
||||||
self.lastFailureReason = "launchd disabled"
|
|
||||||
self.appendLog("[gateway] launchd disabled; skipping auto-start\n")
|
|
||||||
self.logger.info("gateway launchd enable skipped (disable marker set)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||||
@@ -337,10 +310,9 @@ final class GatewayProcessManager {
|
|||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if !self.desiredActive { return }
|
if !self.desiredActive { return }
|
||||||
do {
|
do {
|
||||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||||
let instance = await PortGuardian.shared.describe(port: port)
|
let instance = await PortGuardian.shared.describe(port: port)
|
||||||
let details = instance.map { "pid \($0.pid)" }
|
let details = instance.map { "pid \($0.pid)" }
|
||||||
self.clearLastFailure()
|
|
||||||
self.status = .running(details: details)
|
self.status = .running(details: details)
|
||||||
self.logger.info("gateway started details=\(details ?? "ok")")
|
self.logger.info("gateway started details=\(details ?? "ok")")
|
||||||
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||||
@@ -380,8 +352,7 @@ final class GatewayProcessManager {
|
|||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if !self.desiredActive { return false }
|
if !self.desiredActive { return false }
|
||||||
do {
|
do {
|
||||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||||
self.clearLastFailure()
|
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
@@ -414,19 +385,3 @@ final class GatewayProcessManager {
|
|||||||
return String(text.suffix(limit))
|
return String(text.suffix(limit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
extension GatewayProcessManager {
|
|
||||||
func setTestingConnection(_ connection: GatewayConnection?) {
|
|
||||||
self.testingConnection = connection
|
|
||||||
}
|
|
||||||
|
|
||||||
func setTestingDesiredActive(_ active: Bool) {
|
|
||||||
self.desiredActive = active
|
|
||||||
}
|
|
||||||
|
|
||||||
func setTestingLastFailureReason(_ reason: String?) {
|
|
||||||
self.lastFailureReason = reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum GatewayRemoteConfig {
|
|
||||||
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
|
||||||
guard let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let remote = gateway["remote"] as? [String: Any],
|
|
||||||
let raw = remote["transport"] as? String
|
|
||||||
else {
|
|
||||||
return .ssh
|
|
||||||
}
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resolveUrlString(root: [String: Any]) -> String? {
|
|
||||||
guard let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let remote = gateway["remote"] as? [String: Any],
|
|
||||||
let urlRaw = remote["url"] as? String
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
|
|
||||||
guard let raw = self.resolveUrlString(root: root) else { return nil }
|
|
||||||
return self.normalizeGatewayUrl(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func normalizeGatewayUrlString(_ raw: String) -> String? {
|
|
||||||
self.normalizeGatewayUrl(raw)?.absoluteString
|
|
||||||
}
|
|
||||||
|
|
||||||
static func normalizeGatewayUrl(_ raw: String) -> URL? {
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
|
||||||
let scheme = url.scheme?.lowercased() ?? ""
|
|
||||||
guard scheme == "ws" || scheme == "wss" else { return nil }
|
|
||||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
guard !host.isEmpty else { return nil }
|
|
||||||
if scheme == "ws", url.port == nil {
|
|
||||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
components.port = 18789
|
|
||||||
return components.url
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
static func defaultPort(for url: URL) -> Int? {
|
|
||||||
if let port = url.port { return port }
|
|
||||||
let scheme = url.scheme?.lowercased() ?? ""
|
|
||||||
switch scheme {
|
|
||||||
case "wss":
|
|
||||||
return 443
|
|
||||||
case "ws":
|
|
||||||
return 18789
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,15 @@ import AppKit
|
|||||||
import ClawdbotDiscovery
|
import ClawdbotDiscovery
|
||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import CoreLocation
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GeneralSettings: View {
|
struct GeneralSettings: View {
|
||||||
@Bindable var state: AppState
|
@Bindable var state: AppState
|
||||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||||
|
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||||
|
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||||
private let healthStore = HealthStore.shared
|
private let healthStore = HealthStore.shared
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||||
@@ -17,7 +20,7 @@ struct GeneralSettings: View {
|
|||||||
@State private var showRemoteAdvanced = false
|
@State private var showRemoteAdvanced = false
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||||
private var remoteLabelWidth: CGFloat { 88 }
|
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
@@ -57,6 +60,27 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Location Access")
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
Picker("", selection: self.$locationModeRaw) {
|
||||||
|
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
||||||
|
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||||
|
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||||
|
.disabled(self.locationMode == .off)
|
||||||
|
|
||||||
|
Text("Always may require System Settings to approve background location.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
title: "Enable Peekaboo Bridge",
|
title: "Enable Peekaboo Bridge",
|
||||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||||
@@ -82,12 +106,27 @@ struct GeneralSettings: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.refreshGatewayStatus()
|
self.refreshGatewayStatus()
|
||||||
|
self.lastLocationModeRaw = self.locationModeRaw
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||||
if !enabled {
|
if !enabled {
|
||||||
CanvasManager.shared.hideAll()
|
CanvasManager.shared.hideAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||||
|
let previous = self.lastLocationModeRaw
|
||||||
|
self.lastLocationModeRaw = newValue
|
||||||
|
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
||||||
|
Task {
|
||||||
|
let granted = await self.requestLocationAuthorization(mode: mode)
|
||||||
|
if !granted {
|
||||||
|
await MainActor.run {
|
||||||
|
self.locationModeRaw = previous
|
||||||
|
self.lastLocationModeRaw = previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var activeBinding: Binding<Bool> {
|
private var activeBinding: Binding<Bool> {
|
||||||
@@ -96,6 +135,26 @@ struct GeneralSettings: View {
|
|||||||
set: { self.state.isPaused = !$0 })
|
set: { self.state.isPaused = !$0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var locationMode: ClawdbotLocationMode {
|
||||||
|
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||||
|
guard mode != .off else { return true }
|
||||||
|
guard CLLocationManager.locationServicesEnabled() else {
|
||||||
|
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = CLLocationManager().authorizationStatus
|
||||||
|
let requireAlways = mode == .always
|
||||||
|
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||||
|
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||||
|
}
|
||||||
|
|
||||||
private var connectionSection: some View {
|
private var connectionSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Clawdbot runs")
|
Text("Clawdbot runs")
|
||||||
@@ -105,7 +164,7 @@ struct GeneralSettings: View {
|
|||||||
Picker("Mode", selection: self.$state.connectionMode) {
|
Picker("Mode", selection: self.$state.connectionMode) {
|
||||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||||
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@@ -132,56 +191,66 @@ struct GeneralSettings: View {
|
|||||||
if self.state.connectionMode == .remote {
|
if self.state.connectionMode == .remote {
|
||||||
self.remoteCard
|
self.remoteCard
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var remoteCard: some View {
|
private var remoteCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
self.remoteTransportRow
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Text("SSH")
|
||||||
if self.state.remoteTransport == .ssh {
|
.font(.callout.weight(.semibold))
|
||||||
self.remoteSshRow
|
.frame(width: 48, alignment: .leading)
|
||||||
} else {
|
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||||
self.remoteDirectRow
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
Button {
|
||||||
|
Task { await self.testRemote() }
|
||||||
|
} label: {
|
||||||
|
if self.remoteStatus == .checking {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Test remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
GatewayDiscoveryInlineList(
|
GatewayDiscoveryInlineList(
|
||||||
discovery: self.gatewayDiscovery,
|
discovery: self.gatewayDiscovery,
|
||||||
currentTarget: self.state.remoteTarget,
|
currentTarget: self.state.remoteTarget)
|
||||||
currentUrl: self.state.remoteUrl,
|
|
||||||
transport: self.state.remoteTransport)
|
|
||||||
{ gateway in
|
{ gateway in
|
||||||
self.applyDiscoveredGateway(gateway)
|
self.applyDiscoveredGateway(gateway)
|
||||||
}
|
}
|
||||||
.padding(.leading, self.remoteLabelWidth + 10)
|
.padding(.leading, 58)
|
||||||
|
|
||||||
self.remoteStatusView
|
self.remoteStatusView
|
||||||
.padding(.leading, self.remoteLabelWidth + 10)
|
.padding(.leading, 58)
|
||||||
|
|
||||||
if self.state.remoteTransport == .ssh {
|
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
LabeledContent("Identity file") {
|
||||||
LabeledContent("Identity file") {
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: 280)
|
||||||
.frame(width: 280)
|
}
|
||||||
}
|
LabeledContent("Project root") {
|
||||||
LabeledContent("Project root") {
|
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: 280)
|
||||||
.frame(width: 280)
|
}
|
||||||
}
|
LabeledContent("CLI path") {
|
||||||
LabeledContent("CLI path") {
|
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: 280)
|
||||||
.frame(width: 280)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
|
||||||
} label: {
|
|
||||||
Text("Advanced")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
}
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
} label: {
|
||||||
|
Text("Advanced")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostics
|
// Diagnostics
|
||||||
@@ -204,96 +273,18 @@ struct GeneralSettings: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let authLabel = ControlChannel.shared.authSourceLabel {
|
|
||||||
Text(authLabel)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.state.remoteTransport == .ssh {
|
Text("Tip: enable Tailscale for stable remote access.")
|
||||||
Text("Tip: enable Tailscale for stable remote access.")
|
.font(.footnote)
|
||||||
.font(.footnote)
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
||||||
.lineLimit(1)
|
|
||||||
} else {
|
|
||||||
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.onAppear { self.gatewayDiscovery.start() }
|
.onAppear { self.gatewayDiscovery.start() }
|
||||||
.onDisappear { self.gatewayDiscovery.stop() }
|
.onDisappear { self.gatewayDiscovery.stop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var remoteTransportRow: some View {
|
|
||||||
HStack(alignment: .center, spacing: 10) {
|
|
||||||
Text("Transport")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
|
||||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
|
||||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
|
||||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.frame(maxWidth: 320)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var remoteSshRow: some View {
|
|
||||||
HStack(alignment: .center, spacing: 10) {
|
|
||||||
Text("SSH target")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
|
||||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
Button {
|
|
||||||
Task { await self.testRemote() }
|
|
||||||
} label: {
|
|
||||||
if self.remoteStatus == .checking {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Text("Test remote")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var remoteDirectRow: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(alignment: .center, spacing: 10) {
|
|
||||||
Text("Gateway")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
|
||||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
Button {
|
|
||||||
Task { await self.testRemote() }
|
|
||||||
} label: {
|
|
||||||
if self.remoteStatus == .checking {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Text("Test remote")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
||||||
}
|
|
||||||
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.leading, self.remoteLabelWidth + 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var controlStatusLine: String {
|
private var controlStatusLine: String {
|
||||||
switch ControlChannel.shared.state {
|
switch ControlChannel.shared.state {
|
||||||
case .connected: "Connected"
|
case .connected: "Connected"
|
||||||
@@ -523,36 +514,24 @@ extension GeneralSettings {
|
|||||||
func testRemote() async {
|
func testRemote() async {
|
||||||
self.remoteStatus = .checking
|
self.remoteStatus = .checking
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
if self.state.remoteTransport == .direct {
|
guard !settings.target.isEmpty else {
|
||||||
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.remoteStatus = .failed("Set an SSH target first")
|
||||||
guard !trimmedUrl.isEmpty else {
|
return
|
||||||
self.remoteStatus = .failed("Set a gateway URL first")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard Self.isValidWsUrl(trimmedUrl) else {
|
|
||||||
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
guard !settings.target.isEmpty else {
|
|
||||||
self.remoteStatus = .failed("Set an SSH target first")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: basic SSH reachability check
|
|
||||||
let sshResult = await ShellExecutor.run(
|
|
||||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
|
||||||
cwd: nil,
|
|
||||||
env: nil,
|
|
||||||
timeout: 8)
|
|
||||||
|
|
||||||
guard sshResult.ok else {
|
|
||||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: control channel health check
|
// Step 1: basic SSH reachability check
|
||||||
|
let sshResult = await ShellExecutor.run(
|
||||||
|
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
||||||
|
cwd: nil,
|
||||||
|
env: nil,
|
||||||
|
timeout: 8)
|
||||||
|
|
||||||
|
guard sshResult.ok else {
|
||||||
|
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: control channel health over tunnel
|
||||||
let originalMode = AppStateStore.shared.connectionMode
|
let originalMode = AppStateStore.shared.connectionMode
|
||||||
do {
|
do {
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
@@ -579,14 +558,6 @@ extension GeneralSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isValidWsUrl(_ raw: String) -> Bool {
|
|
||||||
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
|
|
||||||
let scheme = url.scheme?.lowercased() ?? ""
|
|
||||||
guard scheme == "ws" || scheme == "wss" else { return false }
|
|
||||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return !host.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
||||||
var args: [String] = [
|
var args: [String] = [
|
||||||
"/usr/bin/ssh",
|
"/usr/bin/ssh",
|
||||||
@@ -655,18 +626,12 @@ extension GeneralSettings {
|
|||||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||||
guard let host else { return }
|
guard let host else { return }
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
if self.state.remoteTransport == .direct {
|
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
user: user,
|
||||||
self.state.remoteUrl = url
|
host: host,
|
||||||
}
|
port: gateway.sshPort)
|
||||||
} else {
|
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||||
user: user,
|
|
||||||
host: host,
|
|
||||||
port: gateway.sshPort)
|
|
||||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
|
||||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,9 +654,7 @@ extension GeneralSettings {
|
|||||||
static func exerciseForTesting() {
|
static func exerciseForTesting() {
|
||||||
let state = AppState(preview: true)
|
let state = AppState(preview: true)
|
||||||
state.connectionMode = .remote
|
state.connectionMode = .remote
|
||||||
state.remoteTransport = .ssh
|
|
||||||
state.remoteTarget = "user@host:2222"
|
state.remoteTarget = "user@host:2222"
|
||||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
|
||||||
state.remoteIdentity = "/tmp/id_ed25519"
|
state.remoteIdentity = "/tmp/id_ed25519"
|
||||||
state.remoteProjectRoot = "/tmp/clawdbot"
|
state.remoteProjectRoot = "/tmp/clawdbot"
|
||||||
state.remoteCliPath = "/tmp/clawdbot"
|
state.remoteCliPath = "/tmp/clawdbot"
|
||||||
|
|||||||
@@ -166,11 +166,6 @@ final class HealthStore {
|
|||||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||||
{
|
{
|
||||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||||
for id in order {
|
|
||||||
if let summary = snap.channels[id], summary.linked == true {
|
|
||||||
return (id: id, summary: summary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id in order {
|
for id in order {
|
||||||
if let summary = snap.channels[id], summary.linked != nil {
|
if let summary = snap.channels[id], summary.linked != nil {
|
||||||
return (id: id, summary: summary)
|
return (id: id, summary: summary)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Darwin
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MenuBarExtraAccess
|
import MenuBarExtraAccess
|
||||||
import Observation
|
import Observation
|
||||||
import OSLog
|
|
||||||
import Security
|
import Security
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -11,7 +10,6 @@ import SwiftUI
|
|||||||
struct ClawdbotApp: App {
|
struct ClawdbotApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
@State private var state: AppState
|
@State private var state: AppState
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "app")
|
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let controlChannel = ControlChannel.shared
|
private let controlChannel = ControlChannel.shared
|
||||||
private let activityStore = WorkActivityStore.shared
|
private let activityStore = WorkActivityStore.shared
|
||||||
@@ -33,7 +31,6 @@ struct ClawdbotApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
ClawdbotLogging.bootstrapIfNeeded()
|
ClawdbotLogging.bootstrapIfNeeded()
|
||||||
Self.applyAttachOnlyOverrideIfNeeded()
|
|
||||||
_state = State(initialValue: AppStateStore.shared)
|
_state = State(initialValue: AppStateStore.shared)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,22 +91,6 @@ struct ClawdbotApp: App {
|
|||||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func applyAttachOnlyOverrideIfNeeded() {
|
|
||||||
let args = CommandLine.arguments
|
|
||||||
guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
|
|
||||||
if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
|
|
||||||
Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
_ = await GatewayLaunchAgentManager.set(
|
|
||||||
enabled: false,
|
|
||||||
bundlePath: Bundle.main.bundlePath,
|
|
||||||
port: GatewayEnvironment.gatewayPort())
|
|
||||||
}
|
|
||||||
Self.logger.info("attach-only flag enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isGatewaySleeping: Bool {
|
private var isGatewaySleeping: Bool {
|
||||||
if self.state.isPaused { return false }
|
if self.state.isPaused { return false }
|
||||||
switch self.state.connectionMode {
|
switch self.state.connectionMode {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -20,7 +18,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private var isMenuOpen = false
|
private var isMenuOpen = false
|
||||||
private var lastKnownMenuWidth: CGFloat?
|
private var lastKnownMenuWidth: CGFloat?
|
||||||
private var menuOpenWidth: CGFloat?
|
private var menuOpenWidth: CGFloat?
|
||||||
private var isObservingControlChannel = false
|
|
||||||
|
|
||||||
private var cachedSnapshot: SessionStoreSnapshot?
|
private var cachedSnapshot: SessionStoreSnapshot?
|
||||||
private var cachedErrorText: String?
|
private var cachedErrorText: String?
|
||||||
@@ -53,7 +50,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
self.startControlChannelObservation()
|
|
||||||
self.nodesStore.start()
|
self.nodesStore.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,50 +96,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.cancelPreviewTasks()
|
self.cancelPreviewTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startControlChannelObservation() {
|
|
||||||
guard !self.isObservingControlChannel else { return }
|
|
||||||
self.isObservingControlChannel = true
|
|
||||||
self.observeControlChannelState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observeControlChannelState() {
|
|
||||||
withObservationTracking {
|
|
||||||
_ = ControlChannel.shared.state
|
|
||||||
} onChange: { [weak self] in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.handleControlChannelStateChange()
|
|
||||||
self.observeControlChannelState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleControlChannelStateChange() {
|
|
||||||
guard self.isMenuOpen, let menu = self.statusItem?.menu else { return }
|
|
||||||
self.loadTask?.cancel()
|
|
||||||
self.loadTask = Task { [weak self, weak menu] in
|
|
||||||
guard let self, let menu else { return }
|
|
||||||
await self.refreshCache(force: true)
|
|
||||||
await self.refreshUsageCache(force: true)
|
|
||||||
await self.refreshCostUsageCache(force: true)
|
|
||||||
await MainActor.run {
|
|
||||||
guard self.isMenuOpen else { return }
|
|
||||||
self.inject(into: menu)
|
|
||||||
self.injectNodes(into: menu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.nodesLoadTask?.cancel()
|
|
||||||
self.nodesLoadTask = Task { [weak self, weak menu] in
|
|
||||||
guard let self, let menu else { return }
|
|
||||||
await self.nodesStore.refresh()
|
|
||||||
await MainActor.run {
|
|
||||||
guard self.isMenuOpen else { return }
|
|
||||||
self.injectNodes(into: menu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
self.originalDelegate?.menuNeedsUpdate?(menu)
|
||||||
}
|
}
|
||||||
@@ -189,23 +141,14 @@ extension MenuSessionsInjector {
|
|||||||
if rhs.key == mainKey { return false }
|
if rhs.key == mainKey { return false }
|
||||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||||
}
|
}
|
||||||
if !rows.isEmpty {
|
|
||||||
let previewKeys = rows.prefix(20).map(\.key)
|
|
||||||
let task = Task {
|
|
||||||
await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10)
|
|
||||||
}
|
|
||||||
self.previewTasks.append(task)
|
|
||||||
}
|
|
||||||
|
|
||||||
let headerItem = NSMenuItem()
|
let headerItem = NSMenuItem()
|
||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
let statusText = self
|
|
||||||
.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
|
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
count: rows.count,
|
count: rows.count,
|
||||||
statusText: statusText)),
|
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
@@ -518,25 +461,11 @@ extension MenuSessionsInjector {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .remote:
|
case .remote:
|
||||||
platform = "remote"
|
platform = "remote"
|
||||||
if AppStateStore.shared.remoteTransport == .direct {
|
let target = AppStateStore.shared.remoteTarget
|
||||||
let trimmedUrl = AppStateStore.shared.remoteUrl
|
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||||
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
|
|
||||||
if let port = url.port {
|
|
||||||
host = "\(urlHost):\(port)"
|
|
||||||
} else {
|
|
||||||
host = urlHost
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
host = trimmedUrl.nonEmpty
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let target = AppStateStore.shared.remoteTarget
|
host = target.nonEmpty
|
||||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
|
||||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
|
||||||
} else {
|
|
||||||
host = target.nonEmpty
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case .local:
|
case .local:
|
||||||
platform = "local"
|
platform = "local"
|
||||||
@@ -669,11 +598,8 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
if self.cachedSnapshot != nil {
|
self.cachedSnapshot = nil
|
||||||
self.cachedErrorText = "Gateway disconnected (showing cached)"
|
self.cachedErrorText = nil
|
||||||
} else {
|
|
||||||
self.cachedErrorText = nil
|
|
||||||
}
|
|
||||||
self.cacheUpdatedAt = Date()
|
self.cacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -698,6 +624,8 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
|
self.cachedUsageSummary = nil
|
||||||
|
self.cachedUsageErrorText = nil
|
||||||
self.usageCacheUpdatedAt = Date()
|
self.usageCacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -720,6 +648,8 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
|
self.cachedCostSummary = nil
|
||||||
|
self.cachedCostErrorText = nil
|
||||||
self.costCacheUpdatedAt = Date()
|
self.costCacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -480,26 +480,26 @@ actor MacNodeRuntime {
|
|||||||
message: "SYSTEM_RUN_DISABLED: security=deny")
|
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||||
}
|
}
|
||||||
|
|
||||||
let approval = await self.resolveSystemRunApproval(
|
let requiresAsk: Bool = {
|
||||||
req: req,
|
if ask == .always { return true }
|
||||||
params: params,
|
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||||
context: ExecRunContext(
|
return false
|
||||||
displayCommand: displayCommand,
|
}()
|
||||||
security: security,
|
|
||||||
ask: ask,
|
let approvedByAsk = params.approved == true
|
||||||
agentId: agentId,
|
if requiresAsk, !approvedByAsk {
|
||||||
resolution: resolution,
|
await self.emitExecEvent(
|
||||||
allowlistMatch: allowlistMatch,
|
"exec.denied",
|
||||||
skillAllow: skillAllow,
|
payload: ExecEventPayload(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId))
|
runId: runId,
|
||||||
if let response = approval.response { return response }
|
host: "node",
|
||||||
let approvedByAsk = approval.approvedByAsk
|
command: displayCommand,
|
||||||
let persistAllowlist = approval.persistAllowlist
|
reason: "approval-required"))
|
||||||
if persistAllowlist, security == .allowlist,
|
return Self.errorResponse(
|
||||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
req,
|
||||||
{
|
code: .unavailable,
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
message: "SYSTEM_RUN_DENIED: approval required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||||
@@ -619,100 +619,6 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ExecApprovalOutcome {
|
|
||||||
var approvedByAsk: Bool
|
|
||||||
var persistAllowlist: Bool
|
|
||||||
var response: BridgeInvokeResponse?
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ExecRunContext {
|
|
||||||
var displayCommand: String
|
|
||||||
var security: ExecSecurity
|
|
||||||
var ask: ExecAsk
|
|
||||||
var agentId: String?
|
|
||||||
var resolution: ExecCommandResolution?
|
|
||||||
var allowlistMatch: ExecAllowlistEntry?
|
|
||||||
var skillAllow: Bool
|
|
||||||
var sessionKey: String
|
|
||||||
var runId: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveSystemRunApproval(
|
|
||||||
req: BridgeInvokeRequest,
|
|
||||||
params: ClawdbotSystemRunParams,
|
|
||||||
context: ExecRunContext) async -> ExecApprovalOutcome
|
|
||||||
{
|
|
||||||
let requiresAsk = ExecApprovalHelpers.requiresAsk(
|
|
||||||
ask: context.ask,
|
|
||||||
security: context.security,
|
|
||||||
allowlistMatch: context.allowlistMatch,
|
|
||||||
skillAllow: context.skillAllow)
|
|
||||||
|
|
||||||
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
|
|
||||||
var approvedByAsk = params.approved == true || decisionFromParams != nil
|
|
||||||
var persistAllowlist = decisionFromParams == .allowAlways
|
|
||||||
if decisionFromParams == .deny {
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: context.sessionKey,
|
|
||||||
runId: context.runId,
|
|
||||||
host: "node",
|
|
||||||
command: context.displayCommand,
|
|
||||||
reason: "user-denied"))
|
|
||||||
return ExecApprovalOutcome(
|
|
||||||
approvedByAsk: approvedByAsk,
|
|
||||||
persistAllowlist: persistAllowlist,
|
|
||||||
response: Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if requiresAsk, !approvedByAsk {
|
|
||||||
let decision = await MainActor.run {
|
|
||||||
ExecApprovalsPromptPresenter.prompt(
|
|
||||||
ExecApprovalPromptRequest(
|
|
||||||
command: context.displayCommand,
|
|
||||||
cwd: params.cwd,
|
|
||||||
host: "node",
|
|
||||||
security: context.security.rawValue,
|
|
||||||
ask: context.ask.rawValue,
|
|
||||||
agentId: context.agentId,
|
|
||||||
resolvedPath: context.resolution?.resolvedPath,
|
|
||||||
sessionKey: context.sessionKey))
|
|
||||||
}
|
|
||||||
switch decision {
|
|
||||||
case .deny:
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: context.sessionKey,
|
|
||||||
runId: context.runId,
|
|
||||||
host: "node",
|
|
||||||
command: context.displayCommand,
|
|
||||||
reason: "user-denied"))
|
|
||||||
return ExecApprovalOutcome(
|
|
||||||
approvedByAsk: approvedByAsk,
|
|
||||||
persistAllowlist: persistAllowlist,
|
|
||||||
response: Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
|
||||||
case .allowAlways:
|
|
||||||
approvedByAsk = true
|
|
||||||
persistAllowlist = true
|
|
||||||
case .allowOnce:
|
|
||||||
approvedByAsk = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ExecApprovalOutcome(
|
|
||||||
approvedByAsk: approvedByAsk,
|
|
||||||
persistAllowlist: persistAllowlist,
|
|
||||||
response: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
_ = ExecApprovalsStore.ensureFile()
|
_ = ExecApprovalsStore.ensureFile()
|
||||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ extension OnboardingView {
|
|||||||
self.preferredGatewayID = gateway.stableID
|
self.preferredGatewayID = gateway.stableID
|
||||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||||
|
|
||||||
if self.state.remoteTransport == .direct {
|
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
|
||||||
self.state.remoteUrl = url
|
|
||||||
}
|
|
||||||
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||||
user: user,
|
user: user,
|
||||||
|
|||||||
@@ -177,67 +177,42 @@ extension OnboardingView {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("Transport")
|
Text("SSH target")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
.textFieldStyle(.roundedBorder)
|
||||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
.frame(width: fieldWidth)
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.frame(width: fieldWidth)
|
|
||||||
}
|
}
|
||||||
if self.state.remoteTransport == .direct {
|
GridRow {
|
||||||
GridRow {
|
Text("Identity file")
|
||||||
Text("Gateway URL")
|
.font(.callout.weight(.semibold))
|
||||||
.font(.callout.weight(.semibold))
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: fieldWidth)
|
||||||
.frame(width: fieldWidth)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if self.state.remoteTransport == .ssh {
|
GridRow {
|
||||||
GridRow {
|
Text("Project root")
|
||||||
Text("SSH target")
|
.font(.callout.weight(.semibold))
|
||||||
.font(.callout.weight(.semibold))
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: fieldWidth)
|
||||||
.frame(width: fieldWidth)
|
}
|
||||||
}
|
GridRow {
|
||||||
GridRow {
|
Text("CLI path")
|
||||||
Text("Identity file")
|
.font(.callout.weight(.semibold))
|
||||||
.font(.callout.weight(.semibold))
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
TextField(
|
||||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
"/Applications/Clawdbot.app/.../clawdbot",
|
||||||
.textFieldStyle(.roundedBorder)
|
text: self.$state.remoteCliPath)
|
||||||
.frame(width: fieldWidth)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
.frame(width: fieldWidth)
|
||||||
GridRow {
|
|
||||||
Text("Project root")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
|
||||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(width: fieldWidth)
|
|
||||||
}
|
|
||||||
GridRow {
|
|
||||||
Text("CLI path")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
|
||||||
TextField(
|
|
||||||
"/Applications/Clawdbot.app/.../clawdbot",
|
|
||||||
text: self.$state.remoteCliPath)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(width: fieldWidth)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(self.state.remoteTransport == .direct
|
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||||
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
|
|
||||||
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
|
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -250,10 +225,7 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
if self.state.remoteTransport == .direct {
|
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||||
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
|
||||||
}
|
|
||||||
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
|
||||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||||
return "\(host)\(portSuffix)"
|
return "\(host)\(portSuffix)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import ClawdbotKit
|
|
||||||
import CoreLocation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PermissionsSettings: View {
|
struct PermissionsSettings: View {
|
||||||
@@ -19,8 +17,6 @@ struct PermissionsSettings: View {
|
|||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
LocationAccessSettings()
|
|
||||||
|
|
||||||
Button("Restart onboarding") { self.showOnboarding() }
|
Button("Restart onboarding") { self.showOnboarding() }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -30,72 +26,6 @@ struct PermissionsSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LocationAccessSettings: View {
|
|
||||||
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
|
||||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
|
||||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Location Access")
|
|
||||||
.font(.body)
|
|
||||||
|
|
||||||
Picker("", selection: self.$locationModeRaw) {
|
|
||||||
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
|
||||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
|
||||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
|
||||||
.disabled(self.locationMode == .off)
|
|
||||||
|
|
||||||
Text("Always may require System Settings to approve background location.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
self.lastLocationModeRaw = self.locationModeRaw
|
|
||||||
}
|
|
||||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
|
||||||
let previous = self.lastLocationModeRaw
|
|
||||||
self.lastLocationModeRaw = newValue
|
|
||||||
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
|
||||||
Task {
|
|
||||||
let granted = await self.requestLocationAuthorization(mode: mode)
|
|
||||||
if !granted {
|
|
||||||
await MainActor.run {
|
|
||||||
self.locationModeRaw = previous
|
|
||||||
self.lastLocationModeRaw = previous
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var locationMode: ClawdbotLocationMode {
|
|
||||||
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
|
||||||
guard mode != .off else { return true }
|
|
||||||
guard CLLocationManager.locationServicesEnabled() else {
|
|
||||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = CLLocationManager().authorizationStatus
|
|
||||||
let requireAlways = mode == .always
|
|
||||||
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
|
||||||
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PermissionStatusList: View {
|
struct PermissionStatusList: View {
|
||||||
let status: [Capability: Bool]
|
let status: [Capability: Bool]
|
||||||
let refresh: () async -> Void
|
let refresh: () async -> Void
|
||||||
@@ -117,6 +47,7 @@ struct PermissionStatusList: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
.help("Refresh status")
|
.help("Refresh status")
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,14 +184,6 @@ actor PortGuardian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isListening(port: Int, pid: Int32? = nil) async -> Bool {
|
|
||||||
let listeners = await self.listeners(on: port)
|
|
||||||
if let pid {
|
|
||||||
return listeners.contains(where: { $0.pid == pid })
|
|
||||||
}
|
|
||||||
return !listeners.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
private func listeners(on port: Int) async -> [Listener] {
|
private func listeners(on port: Int) async -> [Listener] {
|
||||||
let res = await ShellExecutor.run(
|
let res = await ShellExecutor.run(
|
||||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ actor RemoteTunnelManager {
|
|||||||
tunnel.process.isRunning,
|
tunnel.process.isRunning,
|
||||||
let local = tunnel.localPort
|
let local = tunnel.localPort
|
||||||
{
|
{
|
||||||
let pid = tunnel.process.processIdentifier
|
if await self.isTunnelHealthy(port: local) {
|
||||||
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
|
||||||
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||||
return local
|
return local
|
||||||
}
|
}
|
||||||
self.logger.error(
|
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||||
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
|
|
||||||
await self.beginRestart()
|
await self.beginRestart()
|
||||||
tunnel.terminate()
|
tunnel.terminate()
|
||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
@@ -37,11 +35,19 @@ actor RemoteTunnelManager {
|
|||||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||||
self.isSshProcess(desc)
|
self.isSshProcess(desc)
|
||||||
{
|
{
|
||||||
self.logger.info(
|
if await self.isTunnelHealthy(port: desiredPort) {
|
||||||
"reusing existing SSH tunnel listener " +
|
self.logger.info(
|
||||||
"localPort=\(desiredPort, privacy: .public) " +
|
"reusing existing SSH tunnel listener " +
|
||||||
"pid=\(desc.pid, privacy: .public)")
|
"localPort=\(desiredPort, privacy: .public) " +
|
||||||
return desiredPort
|
"pid=\(desc.pid, privacy: .public)")
|
||||||
|
return desiredPort
|
||||||
|
}
|
||||||
|
if self.restartInFlight {
|
||||||
|
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
await self.beginRestart()
|
||||||
|
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -82,6 +88,10 @@ actor RemoteTunnelManager {
|
|||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
||||||
|
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
||||||
|
}
|
||||||
|
|
||||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||||
let cmd = desc.command.lowercased()
|
let cmd = desc.command.lowercased()
|
||||||
if cmd.contains("ssh") { return true }
|
if cmd.contains("ssh") { return true }
|
||||||
@@ -118,5 +128,21 @@ actor RemoteTunnelManager {
|
|||||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep tunnel reuse lightweight; restart only when the listener disappears.
|
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
||||||
|
let pid = desc.pid
|
||||||
|
self.logger.error(
|
||||||
|
"stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)")
|
||||||
|
let killed = await self.kill(pid: pid)
|
||||||
|
if !killed {
|
||||||
|
self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)")
|
||||||
|
}
|
||||||
|
await PortGuardian.shared.removeRecord(pid: pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kill(pid: Int32) async -> Bool {
|
||||||
|
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||||
|
if term.ok { return true }
|
||||||
|
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||||
|
return sigkill.ok
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
@@ -173,4 +173,4 @@
|
|||||||
"iPod5,1": "iPod touch (5th generation)",
|
"iPod5,1": "iPod touch (5th generation)",
|
||||||
"iPod7,1": "iPod touch (6th generation)",
|
"iPod7,1": "iPod touch (6th generation)",
|
||||||
"iPod9,1": "iPod touch (7th generation)"
|
"iPod9,1": "iPod touch (7th generation)"
|
||||||
}
|
}
|
||||||
@@ -211,4 +211,4 @@
|
|||||||
"Mac Pro (2019)",
|
"Mac Pro (2019)",
|
||||||
"Mac Pro (Rack, 2019)"
|
"Mac Pro (Rack, 2019)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.24</string>
|
<string>2026.1.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601240</string>
|
<string>202601200</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Clawdbot</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import ClawdbotChatUI
|
import ClawdbotChatUI
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -32,80 +31,31 @@ actor SessionPreviewCache {
|
|||||||
static let shared = SessionPreviewCache()
|
static let shared = SessionPreviewCache()
|
||||||
|
|
||||||
private struct CacheEntry {
|
private struct CacheEntry {
|
||||||
let snapshot: SessionMenuPreviewSnapshot
|
let items: [SessionPreviewItem]
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
private var entries: [String: CacheEntry] = [:]
|
private var entries: [String: CacheEntry] = [:]
|
||||||
|
|
||||||
func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? {
|
func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? {
|
||||||
guard let entry = self.entries[sessionKey] else { return nil }
|
guard let entry = self.entries[sessionKey] else { return nil }
|
||||||
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
||||||
return entry.snapshot
|
return entry.items
|
||||||
}
|
}
|
||||||
|
|
||||||
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
|
func store(items: [SessionPreviewItem], for sessionKey: String) {
|
||||||
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
|
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
|
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
|
||||||
self.entries[sessionKey]?.snapshot
|
self.entries[sessionKey]?.items
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actor SessionPreviewLimiter {
|
|
||||||
static let shared = SessionPreviewLimiter(maxConcurrent: 2)
|
|
||||||
|
|
||||||
private let maxConcurrent: Int
|
|
||||||
private var available: Int
|
|
||||||
private var waitQueue: [UUID] = []
|
|
||||||
private var waiters: [UUID: CheckedContinuation<Void, Never>] = [:]
|
|
||||||
|
|
||||||
init(maxConcurrent: Int) {
|
|
||||||
let normalized = max(1, maxConcurrent)
|
|
||||||
self.maxConcurrent = normalized
|
|
||||||
self.available = normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
func withPermit<T>(_ operation: () async throws -> T) async throws -> T {
|
|
||||||
await self.acquire()
|
|
||||||
defer { self.release() }
|
|
||||||
if Task.isCancelled { throw CancellationError() }
|
|
||||||
return try await operation()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func acquire() async {
|
|
||||||
if self.available > 0 {
|
|
||||||
self.available -= 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let id = UUID()
|
|
||||||
await withCheckedContinuation { cont in
|
|
||||||
self.waitQueue.append(id)
|
|
||||||
self.waiters[id] = cont
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func release() {
|
|
||||||
if let id = self.waitQueue.first {
|
|
||||||
self.waitQueue.removeFirst()
|
|
||||||
if let cont = self.waiters.removeValue(forKey: id) {
|
|
||||||
cont.resume()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.available = min(self.available + 1, self.maxConcurrent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
extension SessionPreviewCache {
|
extension SessionPreviewCache {
|
||||||
func _testSet(
|
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||||
snapshot: SessionMenuPreviewSnapshot,
|
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||||
for sessionKey: String,
|
|
||||||
updatedAt: Date = Date())
|
|
||||||
{
|
|
||||||
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func _testReset() {
|
func _testReset() {
|
||||||
@@ -224,44 +174,36 @@ enum SessionMenuPreviewLoader {
|
|||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||||
private static let previewTimeoutSeconds: Double = 4
|
private static let previewTimeoutSeconds: Double = 4
|
||||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||||
private static let previewMaxChars = 240
|
|
||||||
|
|
||||||
private struct PreviewTimeoutError: LocalizedError {
|
private struct PreviewTimeoutError: LocalizedError {
|
||||||
var errorDescription: String? { "preview timeout" }
|
var errorDescription: String? { "preview timeout" }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func prewarm(sessionKeys: [String], maxItems: Int) async {
|
|
||||||
let keys = self.uniqueKeys(sessionKeys)
|
|
||||||
guard !keys.isEmpty else { return }
|
|
||||||
do {
|
|
||||||
let payload = try await self.requestPreview(keys: keys, maxItems: maxItems)
|
|
||||||
await self.cache(payload: payload, maxItems: maxItems)
|
|
||||||
} catch {
|
|
||||||
if self.isUnknownMethodError(error) { return }
|
|
||||||
let errorDescription = String(describing: error)
|
|
||||||
Self.logger.debug(
|
|
||||||
"Session preview prewarm failed count=\(keys.count, privacy: .public) " +
|
|
||||||
"error=\(errorDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||||
if let cached = await SessionPreviewCache.shared.cachedSnapshot(
|
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||||
for: sessionKey,
|
return self.snapshot(from: cached)
|
||||||
maxAge: cacheMaxAgeSeconds)
|
|
||||||
{
|
|
||||||
return cached
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
|
let payload = try await AsyncTimeout.withTimeout(
|
||||||
return snapshot
|
seconds: self.previewTimeoutSeconds,
|
||||||
|
onTimeout: { PreviewTimeoutError() },
|
||||||
|
operation: {
|
||||||
|
try await GatewayConnection.shared.chatHistory(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
limit: self.previewLimit(for: maxItems),
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
})
|
||||||
|
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||||
|
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||||
|
return Self.snapshot(from: built)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||||
} catch {
|
} catch {
|
||||||
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
|
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||||
return fallback
|
if let fallback {
|
||||||
|
return Self.snapshot(from: fallback)
|
||||||
}
|
}
|
||||||
let errorDescription = String(describing: error)
|
let errorDescription = String(describing: error)
|
||||||
Self.logger.warning(
|
Self.logger.warning(
|
||||||
@@ -271,120 +213,18 @@ enum SessionMenuPreviewLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot {
|
|
||||||
do {
|
|
||||||
let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems)
|
|
||||||
if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first {
|
|
||||||
return self.snapshot(from: entry, maxItems: maxItems)
|
|
||||||
}
|
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
|
||||||
} catch {
|
|
||||||
if self.isUnknownMethodError(error) {
|
|
||||||
return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func requestPreview(
|
|
||||||
keys: [String],
|
|
||||||
maxItems: Int) async throws -> ClawdbotSessionsPreviewPayload
|
|
||||||
{
|
|
||||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
|
||||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
|
||||||
return try await SessionPreviewLimiter.shared.withPermit {
|
|
||||||
try await AsyncTimeout.withTimeout(
|
|
||||||
seconds: self.previewTimeoutSeconds,
|
|
||||||
onTimeout: { PreviewTimeoutError() },
|
|
||||||
operation: {
|
|
||||||
try await GatewayConnection.shared.sessionsPreview(
|
|
||||||
keys: keys,
|
|
||||||
limit: boundedItems,
|
|
||||||
maxChars: self.previewMaxChars,
|
|
||||||
timeoutMs: timeoutMs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func fetchHistorySnapshot(
|
|
||||||
sessionKey: String,
|
|
||||||
maxItems: Int) async throws -> SessionMenuPreviewSnapshot
|
|
||||||
{
|
|
||||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
|
||||||
let payload = try await SessionPreviewLimiter.shared.withPermit {
|
|
||||||
try await AsyncTimeout.withTimeout(
|
|
||||||
seconds: self.previewTimeoutSeconds,
|
|
||||||
onTimeout: { PreviewTimeoutError() },
|
|
||||||
operation: {
|
|
||||||
try await GatewayConnection.shared.chatHistory(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
limit: self.previewLimit(for: maxItems),
|
|
||||||
timeoutMs: timeoutMs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
|
||||||
return Self.snapshot(from: built)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func snapshot(
|
|
||||||
from entry: ClawdbotSessionPreviewEntry,
|
|
||||||
maxItems: Int) -> SessionMenuPreviewSnapshot
|
|
||||||
{
|
|
||||||
let items = self.previewItems(from: entry, maxItems: maxItems)
|
|
||||||
let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
switch normalized {
|
|
||||||
case "ok":
|
|
||||||
return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
|
||||||
case "empty":
|
|
||||||
return SessionMenuPreviewSnapshot(items: items, status: .empty)
|
|
||||||
case "missing":
|
|
||||||
return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing"))
|
|
||||||
default:
|
|
||||||
return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func cache(payload: ClawdbotSessionsPreviewPayload, maxItems: Int) async {
|
|
||||||
for entry in payload.previews {
|
|
||||||
let snapshot = self.snapshot(from: entry, maxItems: maxItems)
|
|
||||||
await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func previewLimit(for maxItems: Int) -> Int {
|
private static func previewLimit(for maxItems: Int) -> Int {
|
||||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
min(max(maxItems * 3, 20), 120)
|
||||||
return min(max(boundedItems * 3, 20), 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeMaxItems(_ maxItems: Int) -> Int {
|
|
||||||
max(1, min(maxItems, 50))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func previewItems(
|
|
||||||
from entry: ClawdbotSessionPreviewEntry,
|
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
|
||||||
{
|
|
||||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
|
||||||
let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in
|
|
||||||
let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !text.isEmpty else { return nil }
|
|
||||||
let role = self.previewRoleFromRaw(item.role)
|
|
||||||
return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text)
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmed = built.suffix(boundedItems)
|
|
||||||
return Array(trimmed.reversed())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func previewItems(
|
private static func previewItems(
|
||||||
from payload: ClawdbotChatHistoryPayload,
|
from payload: ClawdbotChatHistoryPayload,
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
{
|
{
|
||||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
|
||||||
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
||||||
let messages = self.decodeMessages(raw)
|
let messages = self.decodeMessages(raw)
|
||||||
let built = messages.compactMap { message -> SessionPreviewItem? in
|
let built = messages.compactMap { message -> SessionPreviewItem? in
|
||||||
@@ -395,7 +235,7 @@ enum SessionMenuPreviewLoader {
|
|||||||
return SessionPreviewItem(id: id, role: role, text: text)
|
return SessionPreviewItem(id: id, role: role, text: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmed = built.suffix(boundedItems)
|
let trimmed = built.suffix(maxItems)
|
||||||
return Array(trimmed.reversed())
|
return Array(trimmed.reversed())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,16 +248,12 @@ enum SessionMenuPreviewLoader {
|
|||||||
|
|
||||||
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
||||||
if isTool { return .tool }
|
if isTool { return .tool }
|
||||||
return self.previewRoleFromRaw(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
|
|
||||||
switch raw.lowercased() {
|
switch raw.lowercased() {
|
||||||
case "user": .user
|
case "user": return .user
|
||||||
case "assistant": .assistant
|
case "assistant": return .assistant
|
||||||
case "system": .system
|
case "system": return .system
|
||||||
case "tool": .tool
|
case "tool": return .tool
|
||||||
default: .other
|
default: return .other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,16 +316,4 @@ enum SessionMenuPreviewLoader {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func uniqueKeys(_ keys: [String]) -> [String] {
|
|
||||||
let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty })
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isUnknownMethodError(_ error: Error) -> Bool {
|
|
||||||
guard let response = error as? GatewayResponseError else { return false }
|
|
||||||
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
|
|
||||||
let message = response.message.lowercased()
|
|
||||||
return message.contains("unknown method")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(self.model.entries, id: \.id) { entry in
|
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||||
ExecAllowlistRow(
|
ExecAllowlistRow(
|
||||||
entry: Binding(
|
entry: Binding(
|
||||||
get: { self.model.entry(for: entry.id) ?? entry },
|
get: { self.model.entries[index] },
|
||||||
set: { self.model.updateEntry($0, id: entry.id) }),
|
set: { self.model.updateEntry($0, at: index) }),
|
||||||
onRemove: { self.model.removeEntry(id: entry.id) })
|
onRemove: { self.model.removeEntry(at: index) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,24 +373,20 @@ final class ExecApprovalsSettingsModel {
|
|||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
|
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
||||||
guard !self.isDefaultsScope else { return }
|
guard !self.isDefaultsScope else { return }
|
||||||
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
self.entries[index] = entry
|
self.entries[index] = entry
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEntry(id: UUID) {
|
func removeEntry(at index: Int) {
|
||||||
guard !self.isDefaultsScope else { return }
|
guard !self.isDefaultsScope else { return }
|
||||||
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
self.entries.remove(at: index)
|
self.entries.remove(at: index)
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func entry(for id: UUID) -> ExecAllowlistEntry? {
|
|
||||||
self.entries.first(where: { $0.id == id })
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshSkillBins(force: Bool = false) async {
|
func refreshSkillBins(force: Bool = false) async {
|
||||||
guard self.autoAllowSkills else {
|
guard self.autoAllowSkills else {
|
||||||
self.skillBins = []
|
self.skillBins = []
|
||||||
|
|||||||
@@ -221,6 +221,6 @@ final class TailscaleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||||
self.detectTailnetIPv4()
|
Self.detectTailnetIPv4()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,6 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
.prefix(voiceWakeMaxWords)
|
|
||||||
.map { String($0.prefix(voiceWakeMaxWordLength)) }
|
|
||||||
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ struct VoiceWakeSettings: View {
|
|||||||
@State private var micObserver = AudioInputDeviceObserver()
|
@State private var micObserver = AudioInputDeviceObserver()
|
||||||
@State private var micRefreshTask: Task<Void, Never>?
|
@State private var micRefreshTask: Task<Void, Never>?
|
||||||
@State private var availableLocales: [Locale] = []
|
@State private var availableLocales: [Locale] = []
|
||||||
@State private var triggerEntries: [TriggerEntry] = []
|
|
||||||
private let fieldLabelWidth: CGFloat = 140
|
private let fieldLabelWidth: CGFloat = 140
|
||||||
private let controlWidth: CGFloat = 240
|
private let controlWidth: CGFloat = 240
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
@@ -32,9 +31,9 @@ struct VoiceWakeSettings: View {
|
|||||||
var id: String { self.uid }
|
var id: String { self.uid }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct TriggerEntry: Identifiable {
|
private struct IndexedWord: Identifiable {
|
||||||
let id: UUID
|
let id: Int
|
||||||
var value: String
|
let value: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private var voiceWakeBinding: Binding<Bool> {
|
private var voiceWakeBinding: Binding<Bool> {
|
||||||
@@ -106,7 +105,6 @@ struct VoiceWakeSettings: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.startMicObserver()
|
self.startMicObserver()
|
||||||
self.loadTriggerEntries()
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@@ -124,10 +122,8 @@ struct VoiceWakeSettings: View {
|
|||||||
self.micRefreshTask = nil
|
self.micRefreshTask = nil
|
||||||
Task { await self.meter.stop() }
|
Task { await self.meter.stop() }
|
||||||
self.micObserver.stop()
|
self.micObserver.stop()
|
||||||
self.syncTriggerEntriesToState()
|
|
||||||
} else {
|
} else {
|
||||||
self.startMicObserver()
|
self.startMicObserver()
|
||||||
self.loadTriggerEntries()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
@@ -140,16 +136,11 @@ struct VoiceWakeSettings: View {
|
|||||||
self.micRefreshTask = nil
|
self.micRefreshTask = nil
|
||||||
self.micObserver.stop()
|
self.micObserver.stop()
|
||||||
Task { await self.meter.stop() }
|
Task { await self.meter.stop() }
|
||||||
self.syncTriggerEntriesToState()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadTriggerEntries() {
|
private var indexedWords: [IndexedWord] {
|
||||||
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
|
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
|
||||||
}
|
|
||||||
|
|
||||||
private func syncTriggerEntriesToState() {
|
|
||||||
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var triggerTable: some View {
|
private var triggerTable: some View {
|
||||||
@@ -163,42 +154,29 @@ struct VoiceWakeSettings: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Add word", systemImage: "plus")
|
Label("Add word", systemImage: "plus")
|
||||||
}
|
}
|
||||||
.disabled(self.triggerEntries
|
.disabled(self.state.swabbleTriggerWords
|
||||||
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||||
|
|
||||||
Button("Reset defaults") {
|
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
|
||||||
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
|
|
||||||
self.syncTriggerEntriesToState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
Table(self.indexedWords) {
|
||||||
ForEach(self.$triggerEntries) { $entry in
|
TableColumn("Word") { row in
|
||||||
HStack(spacing: 8) {
|
TextField("Wake word", text: self.binding(for: row.id))
|
||||||
TextField("Wake word", text: $entry.value)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.onSubmit {
|
|
||||||
self.syncTriggerEntriesToState()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
self.removeWord(id: entry.id)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.help("Remove trigger word")
|
|
||||||
.frame(width: 24)
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
|
|
||||||
if entry.id != self.triggerEntries.last?.id {
|
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
TableColumn("") { row in
|
||||||
|
Button {
|
||||||
|
self.removeWord(at: row.id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Remove trigger word")
|
||||||
|
}
|
||||||
|
.width(36)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
|
.frame(minHeight: 180)
|
||||||
.background(Color(nsColor: .textBackgroundColor))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
@@ -233,12 +211,24 @@ struct VoiceWakeSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func addWord() {
|
private func addWord() {
|
||||||
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
|
self.state.swabbleTriggerWords.append("")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeWord(id: UUID) {
|
private func removeWord(at index: Int) {
|
||||||
self.triggerEntries.removeAll { $0.id == id }
|
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
|
||||||
self.syncTriggerEntriesToState()
|
self.state.swabbleTriggerWords.remove(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func binding(for index: Int) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
|
||||||
|
return self.state.swabbleTriggerWords[index]
|
||||||
|
},
|
||||||
|
set: { newValue in
|
||||||
|
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
|
||||||
|
self.state.swabbleTriggerWords[index] = newValue
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleTest() {
|
private func toggleTest() {
|
||||||
@@ -648,14 +638,13 @@ extension VoiceWakeSettings {
|
|||||||
state.voicePushToTalkEnabled = true
|
state.voicePushToTalkEnabled = true
|
||||||
state.swabbleTriggerWords = ["Claude", "Hey"]
|
state.swabbleTriggerWords = ["Claude", "Hey"]
|
||||||
|
|
||||||
var view = VoiceWakeSettings(state: state, isActive: true)
|
let view = VoiceWakeSettings(state: state, isActive: true)
|
||||||
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
|
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
|
||||||
view.availableLocales = [Locale(identifier: "en_US")]
|
view.availableLocales = [Locale(identifier: "en_US")]
|
||||||
view.meterLevel = 0.42
|
view.meterLevel = 0.42
|
||||||
view.meterError = "No input"
|
view.meterError = "No input"
|
||||||
view.testState = .detected("ok")
|
view.testState = .detected("ok")
|
||||||
view.isTesting = true
|
view.isTesting = true
|
||||||
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
|
|
||||||
|
|
||||||
_ = view.body
|
_ = view.body
|
||||||
_ = view.localePicker
|
_ = view.localePicker
|
||||||
@@ -665,9 +654,8 @@ extension VoiceWakeSettings {
|
|||||||
_ = view.chimeSection
|
_ = view.chimeSection
|
||||||
|
|
||||||
view.addWord()
|
view.addWord()
|
||||||
if let entryId = view.triggerEntries.first?.id {
|
_ = view.binding(for: 0).wrappedValue
|
||||||
view.removeWord(id: entryId)
|
view.removeWord(at: 0)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -309,7 +309,7 @@ private func resolveLocalHost(bind: String?) -> String {
|
|||||||
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
let tailnetIP = detectTailnetIPv4()
|
let tailnetIP = detectTailnetIPv4()
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case "tailnet":
|
case "tailnet", "auto":
|
||||||
return tailnetIP ?? "127.0.0.1"
|
return tailnetIP ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
public let commands: [String]?
|
public let commands: [String]?
|
||||||
public let permissions: [String: AnyCodable]?
|
public let permissions: [String: AnyCodable]?
|
||||||
public let pathenv: String?
|
|
||||||
public let role: String?
|
public let role: String?
|
||||||
public let scopes: [String]?
|
public let scopes: [String]?
|
||||||
public let device: [String: AnyCodable]?
|
public let device: [String: AnyCodable]?
|
||||||
@@ -33,7 +32,6 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
commands: [String]?,
|
commands: [String]?,
|
||||||
permissions: [String: AnyCodable]?,
|
permissions: [String: AnyCodable]?,
|
||||||
pathenv: String?,
|
|
||||||
role: String?,
|
role: String?,
|
||||||
scopes: [String]?,
|
scopes: [String]?,
|
||||||
device: [String: AnyCodable]?,
|
device: [String: AnyCodable]?,
|
||||||
@@ -47,7 +45,6 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
self.caps = caps
|
self.caps = caps
|
||||||
self.commands = commands
|
self.commands = commands
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
self.pathenv = pathenv
|
|
||||||
self.role = role
|
self.role = role
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -62,7 +59,6 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
case caps
|
case caps
|
||||||
case commands
|
case commands
|
||||||
case permissions
|
case permissions
|
||||||
case pathenv = "pathEnv"
|
|
||||||
case role
|
case role
|
||||||
case scopes
|
case scopes
|
||||||
case device
|
case device
|
||||||
@@ -385,7 +381,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
public let to: String
|
public let to: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: String?
|
public let mediaurl: String?
|
||||||
public let mediaurls: [String]?
|
|
||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
@@ -396,7 +391,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
to: String,
|
to: String,
|
||||||
message: String,
|
message: String,
|
||||||
mediaurl: String?,
|
mediaurl: String?,
|
||||||
mediaurls: [String]?,
|
|
||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
@@ -406,7 +400,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
self.to = to
|
self.to = to
|
||||||
self.message = message
|
self.message = message
|
||||||
self.mediaurl = mediaurl
|
self.mediaurl = mediaurl
|
||||||
self.mediaurls = mediaurls
|
|
||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
@@ -417,7 +410,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case to
|
case to
|
||||||
case message
|
case message
|
||||||
case mediaurl = "mediaUrl"
|
case mediaurl = "mediaUrl"
|
||||||
case mediaurls = "mediaUrls"
|
|
||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
@@ -482,9 +474,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
public let threadid: String?
|
public let threadid: String?
|
||||||
public let groupid: String?
|
|
||||||
public let groupchannel: String?
|
|
||||||
public let groupspace: String?
|
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -507,9 +496,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
threadid: String?,
|
threadid: String?,
|
||||||
groupid: String?,
|
|
||||||
groupchannel: String?,
|
|
||||||
groupspace: String?,
|
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -531,9 +517,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
self.threadid = threadid
|
self.threadid = threadid
|
||||||
self.groupid = groupid
|
|
||||||
self.groupchannel = groupchannel
|
|
||||||
self.groupspace = groupspace
|
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -556,9 +539,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
case threadid = "threadId"
|
case threadid = "threadId"
|
||||||
case groupid = "groupId"
|
|
||||||
case groupchannel = "groupChannel"
|
|
||||||
case groupspace = "groupSpace"
|
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -568,44 +548,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AgentIdentityParams: Codable, Sendable {
|
|
||||||
public let agentid: String?
|
|
||||||
public let sessionkey: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
agentid: String?,
|
|
||||||
sessionkey: String?
|
|
||||||
) {
|
|
||||||
self.agentid = agentid
|
|
||||||
self.sessionkey = sessionkey
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case agentid = "agentId"
|
|
||||||
case sessionkey = "sessionKey"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AgentIdentityResult: Codable, Sendable {
|
|
||||||
public let agentid: String
|
|
||||||
public let name: String?
|
|
||||||
public let avatar: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
agentid: String,
|
|
||||||
name: String?,
|
|
||||||
avatar: String?
|
|
||||||
) {
|
|
||||||
self.agentid = agentid
|
|
||||||
self.name = name
|
|
||||||
self.avatar = avatar
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case agentid = "agentId"
|
|
||||||
case name
|
|
||||||
case avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AgentWaitParams: Codable, Sendable {
|
public struct AgentWaitParams: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
@@ -941,30 +883,8 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsPreviewParams: Codable, Sendable {
|
|
||||||
public let keys: [String]
|
|
||||||
public let limit: Int?
|
|
||||||
public let maxchars: Int?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
keys: [String],
|
|
||||||
limit: Int?,
|
|
||||||
maxchars: Int?
|
|
||||||
) {
|
|
||||||
self.keys = keys
|
|
||||||
self.limit = limit
|
|
||||||
self.maxchars = maxchars
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case keys
|
|
||||||
case limit
|
|
||||||
case maxchars = "maxChars"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SessionsResolveParams: Codable, Sendable {
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
public let key: String?
|
public let key: String?
|
||||||
public let sessionid: String?
|
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
@@ -973,7 +893,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
key: String?,
|
key: String?,
|
||||||
sessionid: String?,
|
|
||||||
label: String?,
|
label: String?,
|
||||||
agentid: String?,
|
agentid: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
@@ -981,7 +900,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
includeunknown: Bool?
|
includeunknown: Bool?
|
||||||
) {
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.sessionid = sessionid
|
|
||||||
self.label = label
|
self.label = label
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
@@ -990,7 +908,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case key
|
case key
|
||||||
case sessionid = "sessionId"
|
|
||||||
case label
|
case label
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
@@ -1167,29 +1084,17 @@ public struct ConfigApplyParams: Codable, Sendable {
|
|||||||
public struct ConfigPatchParams: Codable, Sendable {
|
public struct ConfigPatchParams: Codable, Sendable {
|
||||||
public let raw: String
|
public let raw: String
|
||||||
public let basehash: String?
|
public let basehash: String?
|
||||||
public let sessionkey: String?
|
|
||||||
public let note: String?
|
|
||||||
public let restartdelayms: Int?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
raw: String,
|
raw: String,
|
||||||
basehash: String?,
|
basehash: String?
|
||||||
sessionkey: String?,
|
|
||||||
note: String?,
|
|
||||||
restartdelayms: Int?
|
|
||||||
) {
|
) {
|
||||||
self.raw = raw
|
self.raw = raw
|
||||||
self.basehash = basehash
|
self.basehash = basehash
|
||||||
self.sessionkey = sessionkey
|
|
||||||
self.note = note
|
|
||||||
self.restartdelayms = restartdelayms
|
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case raw
|
case raw
|
||||||
case basehash = "baseHash"
|
case basehash = "baseHash"
|
||||||
case sessionkey = "sessionKey"
|
|
||||||
case note
|
|
||||||
case restartdelayms = "restartDelayMs"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1538,21 +1443,17 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
|||||||
public struct AgentSummary: Codable, Sendable {
|
public struct AgentSummary: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
public let identity: [String: AnyCodable]?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String?,
|
name: String?
|
||||||
identity: [String: AnyCodable]?
|
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.identity = identity
|
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
case identity
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2003,30 +1904,27 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||||
public let id: String?
|
|
||||||
public let command: String
|
public let command: String
|
||||||
public let cwd: AnyCodable?
|
public let cwd: String?
|
||||||
public let host: AnyCodable?
|
public let host: String?
|
||||||
public let security: AnyCodable?
|
public let security: String?
|
||||||
public let ask: AnyCodable?
|
public let ask: String?
|
||||||
public let agentid: AnyCodable?
|
public let agentid: String?
|
||||||
public let resolvedpath: AnyCodable?
|
public let resolvedpath: String?
|
||||||
public let sessionkey: AnyCodable?
|
public let sessionkey: String?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String?,
|
|
||||||
command: String,
|
command: String,
|
||||||
cwd: AnyCodable?,
|
cwd: String?,
|
||||||
host: AnyCodable?,
|
host: String?,
|
||||||
security: AnyCodable?,
|
security: String?,
|
||||||
ask: AnyCodable?,
|
ask: String?,
|
||||||
agentid: AnyCodable?,
|
agentid: String?,
|
||||||
resolvedpath: AnyCodable?,
|
resolvedpath: String?,
|
||||||
sessionkey: AnyCodable?,
|
sessionkey: String?,
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -2038,7 +1936,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
|||||||
self.timeoutms = timeoutms
|
self.timeoutms = timeoutms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
|
||||||
case command
|
case command
|
||||||
case cwd
|
case cwd
|
||||||
case host
|
case host
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import Clawdbot
|
|
||||||
|
|
||||||
@Suite struct ExecApprovalHelpersTests {
|
|
||||||
@Test func parseDecisionTrimsAndRejectsInvalid() {
|
|
||||||
#expect(ExecApprovalHelpers.parseDecision("allow-once") == .allowOnce)
|
|
||||||
#expect(ExecApprovalHelpers.parseDecision(" allow-always ") == .allowAlways)
|
|
||||||
#expect(ExecApprovalHelpers.parseDecision("deny") == .deny)
|
|
||||||
#expect(ExecApprovalHelpers.parseDecision("") == nil)
|
|
||||||
#expect(ExecApprovalHelpers.parseDecision("nope") == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func allowlistPatternPrefersResolution() {
|
|
||||||
let resolved = ExecCommandResolution(
|
|
||||||
rawExecutable: "rg",
|
|
||||||
resolvedPath: "/opt/homebrew/bin/rg",
|
|
||||||
executableName: "rg",
|
|
||||||
cwd: nil)
|
|
||||||
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: resolved) == resolved.resolvedPath)
|
|
||||||
|
|
||||||
let rawOnly = ExecCommandResolution(
|
|
||||||
rawExecutable: "rg",
|
|
||||||
resolvedPath: nil,
|
|
||||||
executableName: "rg",
|
|
||||||
cwd: nil)
|
|
||||||
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: rawOnly) == "rg")
|
|
||||||
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: nil) == "rg")
|
|
||||||
#expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func requiresAskMatchesPolicy() {
|
|
||||||
let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil)
|
|
||||||
#expect(ExecApprovalHelpers.requiresAsk(
|
|
||||||
ask: .always,
|
|
||||||
security: .deny,
|
|
||||||
allowlistMatch: nil,
|
|
||||||
skillAllow: false))
|
|
||||||
#expect(ExecApprovalHelpers.requiresAsk(
|
|
||||||
ask: .onMiss,
|
|
||||||
security: .allowlist,
|
|
||||||
allowlistMatch: nil,
|
|
||||||
skillAllow: false))
|
|
||||||
#expect(!ExecApprovalHelpers.requiresAsk(
|
|
||||||
ask: .onMiss,
|
|
||||||
security: .allowlist,
|
|
||||||
allowlistMatch: entry,
|
|
||||||
skillAllow: false))
|
|
||||||
#expect(!ExecApprovalHelpers.requiresAsk(
|
|
||||||
ask: .onMiss,
|
|
||||||
security: .allowlist,
|
|
||||||
allowlistMatch: nil,
|
|
||||||
skillAllow: true))
|
|
||||||
#expect(!ExecApprovalHelpers.requiresAsk(
|
|
||||||
ask: .off,
|
|
||||||
security: .allowlist,
|
|
||||||
allowlistMatch: nil,
|
|
||||||
skillAllow: false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import Testing
|
|
||||||
@testable import Clawdbot
|
|
||||||
|
|
||||||
@Suite
|
|
||||||
@MainActor
|
|
||||||
struct ExecApprovalsGatewayPrompterTests {
|
|
||||||
@Test func sessionMatchPrefersActiveSession() {
|
|
||||||
let matches = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: " main ",
|
|
||||||
requestSession: "main",
|
|
||||||
lastInputSeconds: nil)
|
|
||||||
#expect(matches)
|
|
||||||
|
|
||||||
let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: "other",
|
|
||||||
requestSession: "main",
|
|
||||||
lastInputSeconds: 0)
|
|
||||||
#expect(!mismatched)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func sessionFallbackUsesRecentActivity() {
|
|
||||||
let recent = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: nil,
|
|
||||||
requestSession: "main",
|
|
||||||
lastInputSeconds: 10,
|
|
||||||
thresholdSeconds: 120)
|
|
||||||
#expect(recent)
|
|
||||||
|
|
||||||
let stale = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: nil,
|
|
||||||
requestSession: "main",
|
|
||||||
lastInputSeconds: 200,
|
|
||||||
thresholdSeconds: 120)
|
|
||||||
#expect(!stale)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func defaultBehaviorMatchesMode() {
|
|
||||||
let local = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .local,
|
|
||||||
activeSession: nil,
|
|
||||||
requestSession: nil,
|
|
||||||
lastInputSeconds: 400)
|
|
||||||
#expect(local)
|
|
||||||
|
|
||||||
let remote = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: nil,
|
|
||||||
requestSession: nil,
|
|
||||||
lastInputSeconds: 400)
|
|
||||||
#expect(!remote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true)
|
|
||||||
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||||
}
|
}
|
||||||
@@ -20,7 +19,6 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel(raw: nil) == .last)
|
#expect(GatewayAgentChannel(raw: nil) == .last)
|
||||||
#expect(GatewayAgentChannel(raw: " ") == .last)
|
#expect(GatewayAgentChannel(raw: " ") == .last)
|
||||||
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
||||||
#expect(GatewayAgentChannel(raw: "googlechat") == .googlechat)
|
|
||||||
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
||||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,14 +140,14 @@ import Testing
|
|||||||
#expect(resolved.mode == .remote)
|
#expect(resolved.mode == .remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
|
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
|
||||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
bindMode: "auto",
|
bindMode: "auto",
|
||||||
tailscaleIP: "100.64.1.2")
|
tailscaleIP: "100.64.1.2")
|
||||||
#expect(host == "127.0.0.1")
|
#expect(host == "100.64.1.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() {
|
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
|
||||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
bindMode: "auto",
|
bindMode: "auto",
|
||||||
tailscaleIP: nil)
|
tailscaleIP: nil)
|
||||||
@@ -175,10 +175,4 @@ import Testing
|
|||||||
customBindHost: "192.168.1.10")
|
customBindHost: "192.168.1.10")
|
||||||
#expect(host == "192.168.1.10")
|
#expect(host == "192.168.1.10")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
|
|
||||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
|
|
||||||
#expect(url?.port == 18789)
|
|
||||||
#expect(url?.absoluteString == "ws://gateway:18789")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -48,10 +48,7 @@ import Testing
|
|||||||
|
|
||||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
|
||||||
major: 2026,
|
|
||||||
minor: 1,
|
|
||||||
patch: 11))
|
|
||||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import ClawdbotKit
|
|
||||||
import Foundation
|
|
||||||
import os
|
|
||||||
import Testing
|
|
||||||
@testable import Clawdbot
|
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
@MainActor
|
|
||||||
struct GatewayProcessManagerTests {
|
|
||||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
|
||||||
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
|
||||||
private let pendingReceiveHandler =
|
|
||||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
|
|
||||||
-> Void)?>(initialState: nil)
|
|
||||||
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
|
||||||
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
|
|
||||||
|
|
||||||
var state: URLSessionTask.State = .suspended
|
|
||||||
|
|
||||||
func resume() {
|
|
||||||
self.state = .running
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
|
||||||
_ = (closeCode, reason)
|
|
||||||
self.state = .canceling
|
|
||||||
self.cancelCount.withLock { $0 += 1 }
|
|
||||||
let handler = self.pendingReceiveHandler.withLock { handler in
|
|
||||||
defer { handler = nil }
|
|
||||||
return handler
|
|
||||||
}
|
|
||||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
|
||||||
let currentSendCount = self.sendCount.withLock { count in
|
|
||||||
defer { count += 1 }
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentSendCount == 0 {
|
|
||||||
guard case let .data(data) = message else { return }
|
|
||||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
||||||
(obj["type"] as? String) == "req",
|
|
||||||
(obj["method"] as? String) == "connect",
|
|
||||||
let id = obj["id"] as? String
|
|
||||||
{
|
|
||||||
self.connectRequestID.withLock { $0 = id }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard case let .data(data) = message else { return }
|
|
||||||
guard
|
|
||||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
||||||
(obj["type"] as? String) == "req",
|
|
||||||
let id = obj["id"] as? String
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = Self.responseData(id: id)
|
|
||||||
let handler = self.pendingReceiveHandler.withLock { $0 }
|
|
||||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
|
||||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
|
||||||
return .data(Self.connectOkData(id: id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive(
|
|
||||||
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
|
||||||
{
|
|
||||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func connectOkData(id: String) -> Data {
|
|
||||||
let json = """
|
|
||||||
{
|
|
||||||
"type": "res",
|
|
||||||
"id": "\(id)",
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"type": "hello-ok",
|
|
||||||
"protocol": 2,
|
|
||||||
"server": { "version": "test", "connId": "test" },
|
|
||||||
"features": { "methods": [], "events": [] },
|
|
||||||
"snapshot": {
|
|
||||||
"presence": [ { "ts": 1 } ],
|
|
||||||
"health": {},
|
|
||||||
"stateVersion": { "presence": 0, "health": 0 },
|
|
||||||
"uptimeMs": 0
|
|
||||||
},
|
|
||||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
return Data(json.utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func responseData(id: String) -> Data {
|
|
||||||
let json = """
|
|
||||||
{
|
|
||||||
"type": "res",
|
|
||||||
"id": "\(id)",
|
|
||||||
"ok": true,
|
|
||||||
"payload": { "ok": true }
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
return Data(json.utf8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
|
||||||
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
|
|
||||||
|
|
||||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
|
||||||
_ = url
|
|
||||||
let task = FakeWebSocketTask()
|
|
||||||
self.tasks.withLock { $0.append(task) }
|
|
||||||
return WebSocketTaskBox(task: task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func clearsLastFailureWhenHealthSucceeds() async {
|
|
||||||
let session = FakeWebSocketSession()
|
|
||||||
let url = URL(string: "ws://example.invalid")!
|
|
||||||
let connection = GatewayConnection(
|
|
||||||
configProvider: { (url: url, token: nil, password: nil) },
|
|
||||||
sessionBox: WebSocketSessionBox(session: session))
|
|
||||||
|
|
||||||
let manager = GatewayProcessManager.shared
|
|
||||||
manager.setTestingConnection(connection)
|
|
||||||
manager.setTestingDesiredActive(true)
|
|
||||||
manager.setTestingLastFailureReason("health failed")
|
|
||||||
defer {
|
|
||||||
manager.setTestingConnection(nil)
|
|
||||||
manager.setTestingDesiredActive(false)
|
|
||||||
manager.setTestingLastFailureReason(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
let ready = await manager.waitForGatewayReady(timeout: 0.5)
|
|
||||||
#expect(ready)
|
|
||||||
#expect(manager.lastFailureReason == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,22 +7,20 @@ struct SessionMenuPreviewTests {
|
|||||||
@Test func loaderReturnsCachedItems() async {
|
@Test func loaderReturnsCachedItems() async {
|
||||||
await SessionPreviewCache.shared._testReset()
|
await SessionPreviewCache.shared._testReset()
|
||||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||||
let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready)
|
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
||||||
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
|
||||||
|
|
||||||
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
#expect(loaded.status == .ready)
|
#expect(snapshot.status == .ready)
|
||||||
#expect(loaded.items.count == 1)
|
#expect(snapshot.items.count == 1)
|
||||||
#expect(loaded.items.first?.text == "Hi")
|
#expect(snapshot.items.first?.text == "Hi")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||||
await SessionPreviewCache.shared._testReset()
|
await SessionPreviewCache.shared._testReset()
|
||||||
let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty)
|
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
||||||
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
|
||||||
|
|
||||||
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
#expect(loaded.status == .empty)
|
#expect(snapshot.status == .empty)
|
||||||
#expect(loaded.items.isEmpty)
|
#expect(snapshot.items.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,18 +12,6 @@ struct VoiceWakeHelpersTests {
|
|||||||
#expect(cleaned == defaultVoiceWakeTriggers)
|
#expect(cleaned == defaultVoiceWakeTriggers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func sanitizeTriggersLimitsWordLength() {
|
|
||||||
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
|
|
||||||
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
|
|
||||||
#expect(cleaned[1].count == voiceWakeMaxWordLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func sanitizeTriggersLimitsWordCount() {
|
|
||||||
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
|
|
||||||
let cleaned = sanitizeVoiceWakeTriggers(words)
|
|
||||||
#expect(cleaned.count == voiceWakeMaxWords)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizeLocaleStripsCollation() {
|
@Test func normalizeLocaleStripsCollation() {
|
||||||
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
|
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,27 +235,6 @@ public struct ClawdbotChatHistoryPayload: Codable, Sendable {
|
|||||||
public let thinkingLevel: String?
|
public let thinkingLevel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ClawdbotSessionPreviewItem: Codable, Hashable, Sendable {
|
|
||||||
public let role: String
|
|
||||||
public let text: String
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ClawdbotSessionPreviewEntry: Codable, Sendable {
|
|
||||||
public let key: String
|
|
||||||
public let status: String
|
|
||||||
public let items: [ClawdbotSessionPreviewItem]
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ClawdbotSessionsPreviewPayload: Codable, Sendable {
|
|
||||||
public let ts: Int
|
|
||||||
public let previews: [ClawdbotSessionPreviewEntry]
|
|
||||||
|
|
||||||
public init(ts: Int, previews: [ClawdbotSessionPreviewEntry]) {
|
|
||||||
self.ts = ts
|
|
||||||
self.previews = previews
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ClawdbotChatSendResponse: Codable, Sendable {
|
public struct ClawdbotChatSendResponse: Codable, Sendable {
|
||||||
public let runId: String
|
public let runId: String
|
||||||
public let status: String
|
public let status: String
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public struct ClawdbotChatView: View {
|
|||||||
@State private var scrollPosition: UUID?
|
@State private var scrollPosition: UUID?
|
||||||
@State private var showSessions = false
|
@State private var showSessions = false
|
||||||
@State private var hasPerformedInitialScroll = false
|
@State private var hasPerformedInitialScroll = false
|
||||||
@State private var isPinnedToBottom = true
|
|
||||||
private let showsSessionSwitcher: Bool
|
private let showsSessionSwitcher: Bool
|
||||||
private let style: Style
|
private let style: Style
|
||||||
private let markdownVariant: ChatMarkdownVariant
|
private let markdownVariant: ChatMarkdownVariant
|
||||||
@@ -88,28 +87,36 @@ public struct ClawdbotChatView: View {
|
|||||||
private var messageList: some View {
|
private var messageList: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: Layout.messageSpacing) {
|
#if os(macOS)
|
||||||
self.messageListRows
|
VStack(spacing: 0) {
|
||||||
|
LazyVStack(spacing: Layout.messageSpacing) {
|
||||||
|
self.messageListRows
|
||||||
|
}
|
||||||
|
|
||||||
Color.clear
|
Color.clear
|
||||||
#if os(macOS)
|
|
||||||
.frame(height: Layout.messageListPaddingBottom)
|
.frame(height: Layout.messageListPaddingBottom)
|
||||||
#else
|
|
||||||
.frame(height: Layout.messageListPaddingBottom + 1)
|
|
||||||
#endif
|
|
||||||
.id(self.scrollerBottomID)
|
.id(self.scrollerBottomID)
|
||||||
}
|
}
|
||||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||||
.scrollTargetLayout()
|
.scrollTargetLayout()
|
||||||
.padding(.top, Layout.messageListPaddingTop)
|
.padding(.top, Layout.messageListPaddingTop)
|
||||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||||
|
#else
|
||||||
|
LazyVStack(spacing: Layout.messageSpacing) {
|
||||||
|
self.messageListRows
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(height: Layout.messageListPaddingBottom + 1)
|
||||||
|
.id(self.scrollerBottomID)
|
||||||
|
}
|
||||||
|
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||||
|
.scrollTargetLayout()
|
||||||
|
.padding(.top, Layout.messageListPaddingTop)
|
||||||
|
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
// Keep the scroll pinned to the bottom for new messages.
|
// Keep the scroll pinned to the bottom for new messages.
|
||||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||||
.onChange(of: self.scrollPosition) { _, position in
|
|
||||||
guard let position else { return }
|
|
||||||
self.isPinnedToBottom = position == self.scrollerBottomID
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.viewModel.isLoading {
|
if self.viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -126,26 +133,18 @@ public struct ClawdbotChatView: View {
|
|||||||
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
self.hasPerformedInitialScroll = true
|
self.hasPerformedInitialScroll = true
|
||||||
self.isPinnedToBottom = true
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
||||||
self.hasPerformedInitialScroll = false
|
self.hasPerformedInitialScroll = false
|
||||||
self.isPinnedToBottom = true
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
guard self.hasPerformedInitialScroll else { return }
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
guard self.hasPerformedInitialScroll else { return }
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
|
||||||
self.scrollPosition = self.scrollerBottomID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: self.viewModel.streamingAssistantText) { _, _ in
|
|
||||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,13 +94,6 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum GatewayAuthSource: String, Sendable {
|
|
||||||
case deviceToken = "device-token"
|
|
||||||
case sharedToken = "shared-token"
|
|
||||||
case password = "password"
|
|
||||||
case none = "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid ambiguity with the app's own AnyCodable type.
|
// Avoid ambiguity with the app's own AnyCodable type.
|
||||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
@@ -124,7 +117,6 @@ public actor GatewayChannelActor {
|
|||||||
private var lastSeq: Int?
|
private var lastSeq: Int?
|
||||||
private var lastTick: Date?
|
private var lastTick: Date?
|
||||||
private var tickIntervalMs: Double = 30000
|
private var tickIntervalMs: Double = 30000
|
||||||
private var lastAuthSource: GatewayAuthSource = .none
|
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let connectTimeoutSeconds: Double = 6
|
private let connectTimeoutSeconds: Double = 6
|
||||||
@@ -157,8 +149,6 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
|
|
||||||
|
|
||||||
public func shutdown() async {
|
public func shutdown() async {
|
||||||
self.shouldReconnect = false
|
self.shouldReconnect = false
|
||||||
self.connected = false
|
self.connected = false
|
||||||
@@ -310,18 +300,6 @@ public actor GatewayChannelActor {
|
|||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
||||||
let authToken = storedToken ?? self.token
|
let authToken = storedToken ?? self.token
|
||||||
let authSource: GatewayAuthSource
|
|
||||||
if storedToken != nil {
|
|
||||||
authSource = .deviceToken
|
|
||||||
} else if authToken != nil {
|
|
||||||
authSource = .sharedToken
|
|
||||||
} else if self.password != nil {
|
|
||||||
authSource = .password
|
|
||||||
} else {
|
|
||||||
authSource = .none
|
|
||||||
}
|
|
||||||
self.lastAuthSource = authSource
|
|
||||||
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
|
||||||
let canFallbackToShared = storedToken != nil && self.token != nil
|
let canFallbackToShared = storedToken != nil && self.token != nil
|
||||||
if let authToken {
|
if let authToken {
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||||
@@ -593,14 +571,7 @@ public actor GatewayChannelActor {
|
|||||||
id: id,
|
id: id,
|
||||||
method: method,
|
method: method,
|
||||||
params: paramsObject)
|
params: paramsObject)
|
||||||
let data: Data
|
let data = try self.encoder.encode(frame)
|
||||||
do {
|
|
||||||
data = try self.encoder.encode(frame)
|
|
||||||
} catch {
|
|
||||||
self.logger.error(
|
|
||||||
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||||
self.pending[id] = cont
|
self.pending[id] = cont
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
|
|||||||
@@ -219,8 +219,8 @@ public actor GatewayNodeSession {
|
|||||||
}
|
}
|
||||||
if let error = response.error {
|
if let error = response.error {
|
||||||
params["error"] = AnyCodable([
|
params["error"] = AnyCodable([
|
||||||
"code": error.code.rawValue,
|
"code": AnyCodable(error.code.rawValue),
|
||||||
"message": error.message,
|
"message": AnyCodable(error.message),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
public var agentId: String?
|
public var agentId: String?
|
||||||
public var sessionKey: String?
|
public var sessionKey: String?
|
||||||
public var approved: Bool?
|
public var approved: Bool?
|
||||||
public var approvalDecision: String?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: [String],
|
command: [String],
|
||||||
@@ -41,8 +40,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
needsScreenRecording: Bool? = nil,
|
needsScreenRecording: Bool? = nil,
|
||||||
agentId: String? = nil,
|
agentId: String? = nil,
|
||||||
sessionKey: String? = nil,
|
sessionKey: String? = nil,
|
||||||
approved: Bool? = nil,
|
approved: Bool? = nil)
|
||||||
approvalDecision: String? = nil)
|
|
||||||
{
|
{
|
||||||
self.command = command
|
self.command = command
|
||||||
self.rawCommand = rawCommand
|
self.rawCommand = rawCommand
|
||||||
@@ -53,7 +51,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
self.agentId = agentId
|
self.agentId = agentId
|
||||||
self.sessionKey = sessionKey
|
self.sessionKey = sessionKey
|
||||||
self.approved = approved
|
self.approved = approved
|
||||||
self.approvalDecision = approvalDecision
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
public let commands: [String]?
|
public let commands: [String]?
|
||||||
public let permissions: [String: AnyCodable]?
|
public let permissions: [String: AnyCodable]?
|
||||||
public let pathenv: String?
|
|
||||||
public let role: String?
|
public let role: String?
|
||||||
public let scopes: [String]?
|
public let scopes: [String]?
|
||||||
public let device: [String: AnyCodable]?
|
public let device: [String: AnyCodable]?
|
||||||
@@ -33,7 +32,6 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
commands: [String]?,
|
commands: [String]?,
|
||||||
permissions: [String: AnyCodable]?,
|
permissions: [String: AnyCodable]?,
|
||||||
pathenv: String?,
|
|
||||||
role: String?,
|
role: String?,
|
||||||
scopes: [String]?,
|
scopes: [String]?,
|
||||||
device: [String: AnyCodable]?,
|
device: [String: AnyCodable]?,
|
||||||
@@ -47,7 +45,6 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
self.caps = caps
|
self.caps = caps
|
||||||
self.commands = commands
|
self.commands = commands
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
self.pathenv = pathenv
|
|
||||||
self.role = role
|
self.role = role
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -62,7 +59,6 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
case caps
|
case caps
|
||||||
case commands
|
case commands
|
||||||
case permissions
|
case permissions
|
||||||
case pathenv = "pathEnv"
|
|
||||||
case role
|
case role
|
||||||
case scopes
|
case scopes
|
||||||
case device
|
case device
|
||||||
@@ -385,7 +381,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
public let to: String
|
public let to: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: String?
|
public let mediaurl: String?
|
||||||
public let mediaurls: [String]?
|
|
||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
@@ -396,7 +391,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
to: String,
|
to: String,
|
||||||
message: String,
|
message: String,
|
||||||
mediaurl: String?,
|
mediaurl: String?,
|
||||||
mediaurls: [String]?,
|
|
||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
@@ -406,7 +400,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
self.to = to
|
self.to = to
|
||||||
self.message = message
|
self.message = message
|
||||||
self.mediaurl = mediaurl
|
self.mediaurl = mediaurl
|
||||||
self.mediaurls = mediaurls
|
|
||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
@@ -417,7 +410,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case to
|
case to
|
||||||
case message
|
case message
|
||||||
case mediaurl = "mediaUrl"
|
case mediaurl = "mediaUrl"
|
||||||
case mediaurls = "mediaUrls"
|
|
||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
@@ -482,9 +474,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
public let threadid: String?
|
public let threadid: String?
|
||||||
public let groupid: String?
|
|
||||||
public let groupchannel: String?
|
|
||||||
public let groupspace: String?
|
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -507,9 +496,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
threadid: String?,
|
threadid: String?,
|
||||||
groupid: String?,
|
|
||||||
groupchannel: String?,
|
|
||||||
groupspace: String?,
|
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -531,9 +517,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
self.threadid = threadid
|
self.threadid = threadid
|
||||||
self.groupid = groupid
|
|
||||||
self.groupchannel = groupchannel
|
|
||||||
self.groupspace = groupspace
|
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -556,9 +539,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
case threadid = "threadId"
|
case threadid = "threadId"
|
||||||
case groupid = "groupId"
|
|
||||||
case groupchannel = "groupChannel"
|
|
||||||
case groupspace = "groupSpace"
|
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -568,44 +548,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AgentIdentityParams: Codable, Sendable {
|
|
||||||
public let agentid: String?
|
|
||||||
public let sessionkey: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
agentid: String?,
|
|
||||||
sessionkey: String?
|
|
||||||
) {
|
|
||||||
self.agentid = agentid
|
|
||||||
self.sessionkey = sessionkey
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case agentid = "agentId"
|
|
||||||
case sessionkey = "sessionKey"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AgentIdentityResult: Codable, Sendable {
|
|
||||||
public let agentid: String
|
|
||||||
public let name: String?
|
|
||||||
public let avatar: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
agentid: String,
|
|
||||||
name: String?,
|
|
||||||
avatar: String?
|
|
||||||
) {
|
|
||||||
self.agentid = agentid
|
|
||||||
self.name = name
|
|
||||||
self.avatar = avatar
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case agentid = "agentId"
|
|
||||||
case name
|
|
||||||
case avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AgentWaitParams: Codable, Sendable {
|
public struct AgentWaitParams: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
@@ -941,30 +883,8 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsPreviewParams: Codable, Sendable {
|
|
||||||
public let keys: [String]
|
|
||||||
public let limit: Int?
|
|
||||||
public let maxchars: Int?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
keys: [String],
|
|
||||||
limit: Int?,
|
|
||||||
maxchars: Int?
|
|
||||||
) {
|
|
||||||
self.keys = keys
|
|
||||||
self.limit = limit
|
|
||||||
self.maxchars = maxchars
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case keys
|
|
||||||
case limit
|
|
||||||
case maxchars = "maxChars"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SessionsResolveParams: Codable, Sendable {
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
public let key: String?
|
public let key: String?
|
||||||
public let sessionid: String?
|
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
@@ -973,7 +893,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
key: String?,
|
key: String?,
|
||||||
sessionid: String?,
|
|
||||||
label: String?,
|
label: String?,
|
||||||
agentid: String?,
|
agentid: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
@@ -981,7 +900,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
includeunknown: Bool?
|
includeunknown: Bool?
|
||||||
) {
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.sessionid = sessionid
|
|
||||||
self.label = label
|
self.label = label
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
@@ -990,7 +908,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case key
|
case key
|
||||||
case sessionid = "sessionId"
|
|
||||||
case label
|
case label
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
@@ -1167,29 +1084,17 @@ public struct ConfigApplyParams: Codable, Sendable {
|
|||||||
public struct ConfigPatchParams: Codable, Sendable {
|
public struct ConfigPatchParams: Codable, Sendable {
|
||||||
public let raw: String
|
public let raw: String
|
||||||
public let basehash: String?
|
public let basehash: String?
|
||||||
public let sessionkey: String?
|
|
||||||
public let note: String?
|
|
||||||
public let restartdelayms: Int?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
raw: String,
|
raw: String,
|
||||||
basehash: String?,
|
basehash: String?
|
||||||
sessionkey: String?,
|
|
||||||
note: String?,
|
|
||||||
restartdelayms: Int?
|
|
||||||
) {
|
) {
|
||||||
self.raw = raw
|
self.raw = raw
|
||||||
self.basehash = basehash
|
self.basehash = basehash
|
||||||
self.sessionkey = sessionkey
|
|
||||||
self.note = note
|
|
||||||
self.restartdelayms = restartdelayms
|
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case raw
|
case raw
|
||||||
case basehash = "baseHash"
|
case basehash = "baseHash"
|
||||||
case sessionkey = "sessionKey"
|
|
||||||
case note
|
|
||||||
case restartdelayms = "restartDelayMs"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1538,21 +1443,17 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
|||||||
public struct AgentSummary: Codable, Sendable {
|
public struct AgentSummary: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
public let identity: [String: AnyCodable]?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String?,
|
name: String?
|
||||||
identity: [String: AnyCodable]?
|
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.identity = identity
|
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
case identity
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2003,30 +1904,27 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||||
public let id: String?
|
|
||||||
public let command: String
|
public let command: String
|
||||||
public let cwd: AnyCodable?
|
public let cwd: String?
|
||||||
public let host: AnyCodable?
|
public let host: String?
|
||||||
public let security: AnyCodable?
|
public let security: String?
|
||||||
public let ask: AnyCodable?
|
public let ask: String?
|
||||||
public let agentid: AnyCodable?
|
public let agentid: String?
|
||||||
public let resolvedpath: AnyCodable?
|
public let resolvedpath: String?
|
||||||
public let sessionkey: AnyCodable?
|
public let sessionkey: String?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String?,
|
|
||||||
command: String,
|
command: String,
|
||||||
cwd: AnyCodable?,
|
cwd: String?,
|
||||||
host: AnyCodable?,
|
host: String?,
|
||||||
security: AnyCodable?,
|
security: String?,
|
||||||
ask: AnyCodable?,
|
ask: String?,
|
||||||
agentid: AnyCodable?,
|
agentid: String?,
|
||||||
resolvedpath: AnyCodable?,
|
resolvedpath: String?,
|
||||||
sessionkey: AnyCodable?,
|
sessionkey: String?,
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -2038,7 +1936,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
|||||||
self.timeoutms = timeoutms
|
self.timeoutms = timeoutms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
|
||||||
case command
|
case command
|
||||||
case cwd
|
case cwd
|
||||||
case host
|
case host
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";:root{--bg: #0c0d12;--bg-accent: #0d0e14;--bg-elevated: #181a21;--bg-hover: #252830;--bg-muted: #252830;--card: #13151c;--card-foreground: #f8fafc;--card-highlight: rgba(255, 255, 255, .04);--popover: #13151c;--popover-foreground: #f8fafc;--panel: #0c0d12;--panel-strong: #181a21;--panel-hover: #252830;--chrome: rgba(12, 13, 18, .95);--chrome-strong: rgba(12, 13, 18, .98);--text: #f8fafc;--text-strong: #ffffff;--chat-text: #f8fafc;--muted: #94a3b8;--muted-strong: #64748b;--muted-foreground: #94a3b8;--border: #333842;--border-strong: #454d5c;--border-hover: #5a6373;--input: #333842;--ring: #ff4d4d;--accent: #ff4d4d;--accent-hover: #ff6666;--accent-muted: #ff4d4d;--accent-subtle: rgba(255, 77, 77, .12);--accent-foreground: #f8fafc;--primary: #ff4d4d;--primary-foreground: #ffffff;--secondary: #252830;--secondary-foreground: #f8fafc;--accent-2: #3b82f6;--accent-2-muted: rgba(59, 130, 246, .7);--ok: #22c55e;--ok-muted: rgba(34, 197, 94, .7);--ok-subtle: rgba(34, 197, 94, .1);--destructive: #ef4444;--destructive-foreground: #fafafa;--warn: #eab308;--warn-muted: rgba(234, 179, 8, .7);--warn-subtle: rgba(234, 179, 8, .1);--danger: #ef4444;--danger-muted: rgba(239, 68, 68, .7);--danger-subtle: rgba(239, 68, 68, .1);--info: #3b82f6;--focus: rgba(255, 77, 77, .2);--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);--grid-line: rgba(255, 255, 255, .03);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -4px rgba(0, 0, 0, .1);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1);--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--radius-full: 9999px;--radius: 6px;--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-in-out: cubic-bezier(.4, 0, .2, 1);--duration-fast: .15s;--duration-normal: .2s;--duration-slow: .3s;color-scheme:dark}:root[data-theme=light]{--bg: #f8f8f7;--bg-accent: #f3f2f0;--bg-elevated: #ffffff;--bg-hover: #eae8e6;--bg-muted: #eae8e6;--bg-content: #f0efed;--card: #ffffff;--card-foreground: #1c1917;--card-highlight: rgba(0, 0, 0, .04);--popover: #ffffff;--popover-foreground: #1c1917;--panel: #f8f8f7;--panel-strong: #f0efed;--panel-hover: #e5e3e1;--chrome: rgba(248, 248, 247, .95);--chrome-strong: rgba(248, 248, 247, .98);--text: #44403c;--text-strong: #292524;--chat-text: #44403c;--muted: #5c5856;--muted-strong: #44403c;--muted-foreground: #5c5856;--border: #e0dedc;--border-strong: #d6d3d1;--border-hover: #a8a5a0;--input: #e0dedc;--accent: #b91c1c;--accent-hover: #dc2626;--accent-muted: #b91c1c;--accent-subtle: rgba(185, 28, 28, .18);--accent-foreground: #ffffff;--primary: #b91c1c;--primary-foreground: #ffffff;--secondary: #eae8e6;--secondary-foreground: #44403c;--ok: #15803d;--ok-muted: rgba(21, 128, 61, .75);--ok-subtle: rgba(21, 128, 61, .12);--destructive: #b91c1c;--destructive-foreground: #fafafa;--warn: #a16207;--warn-muted: rgba(161, 98, 7, .75);--warn-subtle: rgba(161, 98, 7, .12);--danger: #b91c1c;--danger-muted: rgba(185, 28, 28, .75);--danger-subtle: rgba(185, 28, 28, .12);--info: #1d4ed8;--focus: rgba(185, 28, 28, .25);--grid-line: rgba(0, 0, 0, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:400 14px/1.5 var(--font-body);letter-spacing:-.011em;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .4s var(--ease-out) forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,input,textarea,select{font:inherit;color:inherit}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}::-webLine truncated
|
|
||||||
-3119
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"index-DQcOTEYz.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/icons.ts","../../../src/shared/text/reasoning-tags.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.googlechat.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyLine truncated
|
|
||||||
Vendored
-15
@@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Clawdbot Control</title>
|
|
||||||
<meta name="color-scheme" content="dark light" />
|
|
||||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
|
||||||
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<clawdbot-app></clawdbot-app>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+1
-1
@@ -20,7 +20,7 @@ services:
|
|||||||
[
|
[
|
||||||
"node",
|
"node",
|
||||||
"dist/index.js",
|
"dist/index.js",
|
||||||
"gateway",
|
"gateway-daemon",
|
||||||
"--bind",
|
"--bind",
|
||||||
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
||||||
"--port",
|
"--port",
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ summary: "Cron jobs + wakeups for the Gateway scheduler"
|
|||||||
read_when:
|
read_when:
|
||||||
- Scheduling background jobs or wakeups
|
- Scheduling background jobs or wakeups
|
||||||
- Wiring automation that should run with or alongside heartbeats
|
- Wiring automation that should run with or alongside heartbeats
|
||||||
- Deciding between heartbeat and cron for scheduled tasks
|
|
||||||
---
|
---
|
||||||
# Cron jobs (Gateway scheduler)
|
# Cron jobs (Gateway scheduler)
|
||||||
|
|
||||||
> **Cron vs Heartbeat?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each.
|
|
||||||
|
|
||||||
Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at
|
Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at
|
||||||
the right time, and can optionally deliver output back to a chat.
|
the right time, and can optionally deliver output back to a chat.
|
||||||
|
|
||||||
@@ -124,7 +121,7 @@ Resolution priority:
|
|||||||
|
|
||||||
### Delivery (channel + target)
|
### Delivery (channel + target)
|
||||||
Isolated jobs can deliver output to a channel. The job payload can specify:
|
Isolated jobs can deliver output to a channel. The job payload can specify:
|
||||||
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`
|
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
|
||||||
- `to`: channel-specific recipient target
|
- `to`: channel-specific recipient target
|
||||||
|
|
||||||
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
||||||
@@ -136,7 +133,7 @@ Delivery notes:
|
|||||||
- Use `deliver: false` to keep output internal even if a `to` is present.
|
- Use `deliver: false` to keep output internal even if a `to` is present.
|
||||||
|
|
||||||
Target format reminders:
|
Target format reminders:
|
||||||
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
- Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||||
- Telegram topics should use the `:topic:` form (see below).
|
- Telegram topics should use the `:topic:` form (see below).
|
||||||
|
|
||||||
#### Telegram delivery targets (topics / forum threads)
|
#### Telegram delivery targets (topics / forum threads)
|
||||||
@@ -263,15 +260,15 @@ Run history:
|
|||||||
clawdbot cron runs --id <jobId> --limit 50
|
clawdbot cron runs --id <jobId> --limit 50
|
||||||
```
|
```
|
||||||
|
|
||||||
Immediate system event without creating a job:
|
Immediate wake without creating a job:
|
||||||
```bash
|
```bash
|
||||||
clawdbot system event --mode now --text "Next heartbeat: check battery."
|
clawdbot wake --mode now --text "Next heartbeat: check battery."
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gateway API surface
|
## Gateway API surface
|
||||||
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
||||||
- `cron.run` (force or due), `cron.runs`
|
- `cron.run` (force or due), `cron.runs`
|
||||||
For immediate system events without a job, use [`clawdbot system event`](/cli/system).
|
- `wake` (enqueue system event + optional heartbeat)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Guidance for choosing between heartbeat and cron jobs for automation"
|
|
||||||
read_when:
|
|
||||||
- Deciding how to schedule recurring tasks
|
|
||||||
- Setting up background monitoring or notifications
|
|
||||||
- Optimizing token usage for periodic checks
|
|
||||||
---
|
|
||||||
# Cron vs Heartbeat: When to Use Each
|
|
||||||
|
|
||||||
Both heartbeats and cron jobs let you run tasks on a schedule. This guide helps you choose the right mechanism for your use case.
|
|
||||||
|
|
||||||
## Quick Decision Guide
|
|
||||||
|
|
||||||
| Use Case | Recommended | Why |
|
|
||||||
|----------|-------------|-----|
|
|
||||||
| Check inbox every 30 min | Heartbeat | Batches with other checks, context-aware |
|
|
||||||
| Send daily report at 9am sharp | Cron (isolated) | Exact timing needed |
|
|
||||||
| Monitor calendar for upcoming events | Heartbeat | Natural fit for periodic awareness |
|
|
||||||
| Run weekly deep analysis | Cron (isolated) | Standalone task, can use different model |
|
|
||||||
| Remind me in 20 minutes | Cron (main, `--at`) | One-shot with precise timing |
|
|
||||||
| Background project health check | Heartbeat | Piggybacks on existing cycle |
|
|
||||||
|
|
||||||
## Heartbeat: Periodic Awareness
|
|
||||||
|
|
||||||
Heartbeats run in the **main session** at a regular interval (default: 30 min). They're designed for the agent to check on things and surface anything important.
|
|
||||||
|
|
||||||
### When to use heartbeat
|
|
||||||
|
|
||||||
- **Multiple periodic checks**: Instead of 5 separate cron jobs checking inbox, calendar, weather, notifications, and project status, a single heartbeat can batch all of these.
|
|
||||||
- **Context-aware decisions**: The agent has full main-session context, so it can make smart decisions about what's urgent vs. what can wait.
|
|
||||||
- **Conversational continuity**: Heartbeat runs share the same session, so the agent remembers recent conversations and can follow up naturally.
|
|
||||||
- **Low-overhead monitoring**: One heartbeat replaces many small polling tasks.
|
|
||||||
|
|
||||||
### Heartbeat advantages
|
|
||||||
|
|
||||||
- **Batches multiple checks**: One agent turn can review inbox, calendar, and notifications together.
|
|
||||||
- **Reduces API calls**: A single heartbeat is cheaper than 5 isolated cron jobs.
|
|
||||||
- **Context-aware**: The agent knows what you've been working on and can prioritize accordingly.
|
|
||||||
- **Smart suppression**: If nothing needs attention, the agent replies `HEARTBEAT_OK` and no message is delivered.
|
|
||||||
- **Natural timing**: Drifts slightly based on queue load, which is fine for most monitoring.
|
|
||||||
|
|
||||||
### Heartbeat example: HEARTBEAT.md checklist
|
|
||||||
|
|
||||||
```md
|
|
||||||
# Heartbeat checklist
|
|
||||||
|
|
||||||
- Check email for urgent messages
|
|
||||||
- Review calendar for events in next 2 hours
|
|
||||||
- If a background task finished, summarize results
|
|
||||||
- If idle for 8+ hours, send a brief check-in
|
|
||||||
```
|
|
||||||
|
|
||||||
The agent reads this on each heartbeat and handles all items in one turn.
|
|
||||||
|
|
||||||
### Configuring heartbeat
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
heartbeat: {
|
|
||||||
every: "30m", // interval
|
|
||||||
target: "last", // where to deliver alerts
|
|
||||||
activeHours: { start: "08:00", end: "22:00" } // optional
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Heartbeat](/gateway/heartbeat) for full configuration.
|
|
||||||
|
|
||||||
## Cron: Precise Scheduling
|
|
||||||
|
|
||||||
Cron jobs run at **exact times** and can run in isolated sessions without affecting main context.
|
|
||||||
|
|
||||||
### When to use cron
|
|
||||||
|
|
||||||
- **Exact timing required**: "Send this at 9:00 AM every Monday" (not "sometime around 9").
|
|
||||||
- **Standalone tasks**: Tasks that don't need conversational context.
|
|
||||||
- **Different model/thinking**: Heavy analysis that warrants a more powerful model.
|
|
||||||
- **One-shot reminders**: "Remind me in 20 minutes" with `--at`.
|
|
||||||
- **Noisy/frequent tasks**: Tasks that would clutter main session history.
|
|
||||||
- **External triggers**: Tasks that should run independently of whether the agent is otherwise active.
|
|
||||||
|
|
||||||
### Cron advantages
|
|
||||||
|
|
||||||
- **Exact timing**: 5-field cron expressions with timezone support.
|
|
||||||
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
|
|
||||||
- **Model overrides**: Use a cheaper or more powerful model per job.
|
|
||||||
- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable).
|
|
||||||
- **No agent context needed**: Runs even if main session is idle or compacted.
|
|
||||||
- **One-shot support**: `--at` for precise future timestamps.
|
|
||||||
|
|
||||||
### Cron example: Daily morning briefing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot cron add \
|
|
||||||
--name "Morning briefing" \
|
|
||||||
--cron "0 7 * * *" \
|
|
||||||
--tz "America/New_York" \
|
|
||||||
--session isolated \
|
|
||||||
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
|
||||||
--model opus \
|
|
||||||
--deliver \
|
|
||||||
--channel whatsapp \
|
|
||||||
--to "+15551234567"
|
|
||||||
```
|
|
||||||
|
|
||||||
This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp.
|
|
||||||
|
|
||||||
### Cron example: One-shot reminder
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot cron add \
|
|
||||||
--name "Meeting reminder" \
|
|
||||||
--at "20m" \
|
|
||||||
--session main \
|
|
||||||
--system-event "Reminder: standup meeting starts in 10 minutes." \
|
|
||||||
--wake now \
|
|
||||||
--delete-after-run
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Cron jobs](/automation/cron-jobs) for full CLI reference.
|
|
||||||
|
|
||||||
## Decision Flowchart
|
|
||||||
|
|
||||||
```
|
|
||||||
Does the task need to run at an EXACT time?
|
|
||||||
YES -> Use cron
|
|
||||||
NO -> Continue...
|
|
||||||
|
|
||||||
Does the task need isolation from main session?
|
|
||||||
YES -> Use cron (isolated)
|
|
||||||
NO -> Continue...
|
|
||||||
|
|
||||||
Can this task be batched with other periodic checks?
|
|
||||||
YES -> Use heartbeat (add to HEARTBEAT.md)
|
|
||||||
NO -> Use cron
|
|
||||||
|
|
||||||
Is this a one-shot reminder?
|
|
||||||
YES -> Use cron with --at
|
|
||||||
NO -> Continue...
|
|
||||||
|
|
||||||
Does it need a different model or thinking level?
|
|
||||||
YES -> Use cron (isolated) with --model/--thinking
|
|
||||||
NO -> Use heartbeat
|
|
||||||
```
|
|
||||||
|
|
||||||
## Combining Both
|
|
||||||
|
|
||||||
The most efficient setup uses **both**:
|
|
||||||
|
|
||||||
1. **Heartbeat** handles routine monitoring (inbox, calendar, notifications) in one batched turn every 30 minutes.
|
|
||||||
2. **Cron** handles precise schedules (daily reports, weekly reviews) and one-shot reminders.
|
|
||||||
|
|
||||||
### Example: Efficient automation setup
|
|
||||||
|
|
||||||
**HEARTBEAT.md** (checked every 30 min):
|
|
||||||
```md
|
|
||||||
# Heartbeat checklist
|
|
||||||
- Scan inbox for urgent emails
|
|
||||||
- Check calendar for events in next 2h
|
|
||||||
- Review any pending tasks
|
|
||||||
- Light check-in if quiet for 8+ hours
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cron jobs** (precise timing):
|
|
||||||
```bash
|
|
||||||
# Daily morning briefing at 7am
|
|
||||||
clawdbot cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
|
|
||||||
|
|
||||||
# Weekly project review on Mondays at 9am
|
|
||||||
clawdbot cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
|
||||||
|
|
||||||
# One-shot reminder
|
|
||||||
clawdbot cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Lobster: Deterministic workflows with approvals
|
|
||||||
|
|
||||||
Lobster is the workflow runtime for **multi-step tool pipelines** that need deterministic execution and explicit approvals.
|
|
||||||
Use it when the task is more than a single agent turn, and you want a resumable workflow with human checkpoints.
|
|
||||||
|
|
||||||
### When Lobster fits
|
|
||||||
|
|
||||||
- **Multi-step automation**: You need a fixed pipeline of tool calls, not a one-off prompt.
|
|
||||||
- **Approval gates**: Side effects should pause until you approve, then resume.
|
|
||||||
- **Resumable runs**: Continue a paused workflow without re-running earlier steps.
|
|
||||||
|
|
||||||
### How it pairs with heartbeat and cron
|
|
||||||
|
|
||||||
- **Heartbeat/cron** decide *when* a run happens.
|
|
||||||
- **Lobster** defines *what steps* happen once the run starts.
|
|
||||||
|
|
||||||
For scheduled workflows, use cron or heartbeat to trigger an agent turn that calls Lobster.
|
|
||||||
For ad-hoc workflows, call Lobster directly.
|
|
||||||
|
|
||||||
### Operational notes (from the code)
|
|
||||||
|
|
||||||
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
|
|
||||||
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
|
|
||||||
- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
|
|
||||||
- If you pass `lobsterPath`, it must be an **absolute path**.
|
|
||||||
|
|
||||||
See [Lobster](/tools/lobster) for full usage and examples.
|
|
||||||
|
|
||||||
## Main Session vs Isolated Session
|
|
||||||
|
|
||||||
Both heartbeat and cron can interact with the main session, but differently:
|
|
||||||
|
|
||||||
| | Heartbeat | Cron (main) | Cron (isolated) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Session | Main | Main (via system event) | `cron:<jobId>` |
|
|
||||||
| History | Shared | Shared | Fresh each run |
|
|
||||||
| Context | Full | Full | None (starts clean) |
|
|
||||||
| Model | Main session model | Main session model | Can override |
|
|
||||||
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main |
|
|
||||||
|
|
||||||
### When to use main session cron
|
|
||||||
|
|
||||||
Use `--session main` with `--system-event` when you want:
|
|
||||||
- The reminder/event to appear in main session context
|
|
||||||
- The agent to handle it during the next heartbeat with full context
|
|
||||||
- No separate isolated run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot cron add \
|
|
||||||
--name "Check project" \
|
|
||||||
--every "4h" \
|
|
||||||
--session main \
|
|
||||||
--system-event "Time for a project health check" \
|
|
||||||
--wake now
|
|
||||||
```
|
|
||||||
|
|
||||||
### When to use isolated cron
|
|
||||||
|
|
||||||
Use `--session isolated` when you want:
|
|
||||||
- A clean slate without prior context
|
|
||||||
- Different model or thinking settings
|
|
||||||
- Output delivered directly to a channel (summary still posts to main by default)
|
|
||||||
- History that doesn't clutter main session
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot cron add \
|
|
||||||
--name "Deep analysis" \
|
|
||||||
--cron "0 6 * * 0" \
|
|
||||||
--session isolated \
|
|
||||||
--message "Weekly codebase analysis..." \
|
|
||||||
--model opus \
|
|
||||||
--thinking high \
|
|
||||||
--deliver
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cost Considerations
|
|
||||||
|
|
||||||
| Mechanism | Cost Profile |
|
|
||||||
|-----------|--------------|
|
|
||||||
| Heartbeat | One turn every N minutes; scales with HEARTBEAT.md size |
|
|
||||||
| Cron (main) | Adds event to next heartbeat (no isolated turn) |
|
|
||||||
| Cron (isolated) | Full agent turn per job; can use cheaper model |
|
|
||||||
|
|
||||||
**Tips**:
|
|
||||||
- Keep `HEARTBEAT.md` small to minimize token overhead.
|
|
||||||
- Batch similar checks into heartbeat instead of multiple cron jobs.
|
|
||||||
- Use `target: "none"` on heartbeat if you only want internal processing.
|
|
||||||
- Use isolated cron with a cheaper model for routine tasks.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration
|
|
||||||
- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference
|
|
||||||
- [System](/cli/system) - system events + heartbeat controls
|
|
||||||
@@ -71,8 +71,8 @@ Payload:
|
|||||||
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
|
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
|
||||||
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
||||||
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
||||||
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
|
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
||||||
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
||||||
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
||||||
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
||||||
|
|||||||
+4
-99
@@ -17,37 +17,6 @@ not an API key.
|
|||||||
- Auth: AWS credentials (env vars, shared config, or instance role)
|
- Auth: AWS credentials (env vars, shared config, or instance role)
|
||||||
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
|
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
|
||||||
|
|
||||||
## Automatic model discovery
|
|
||||||
|
|
||||||
If AWS credentials are detected, Clawdbot can automatically discover Bedrock
|
|
||||||
models that support **streaming** and **text output**. Discovery uses
|
|
||||||
`bedrock:ListFoundationModels` and is cached (default: 1 hour).
|
|
||||||
|
|
||||||
Config options live under `models.bedrockDiscovery`:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
models: {
|
|
||||||
bedrockDiscovery: {
|
|
||||||
enabled: true,
|
|
||||||
region: "us-east-1",
|
|
||||||
providerFilter: ["anthropic", "amazon"],
|
|
||||||
refreshInterval: 3600,
|
|
||||||
defaultContextWindow: 32000,
|
|
||||||
defaultMaxTokens: 4096
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `enabled` defaults to `true` when AWS credentials are present.
|
|
||||||
- `region` defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`, then `us-east-1`.
|
|
||||||
- `providerFilter` matches Bedrock provider names (for example `anthropic`).
|
|
||||||
- `refreshInterval` is seconds; set to `0` to disable caching.
|
|
||||||
- `defaultContextWindow` (default: `32000`) and `defaultMaxTokens` (default: `4096`)
|
|
||||||
are used for discovered models (override if you know your model limits).
|
|
||||||
|
|
||||||
## Setup (manual)
|
## Setup (manual)
|
||||||
|
|
||||||
1) Ensure AWS credentials are available on the **gateway host**:
|
1) Ensure AWS credentials are available on the **gateway host**:
|
||||||
@@ -75,10 +44,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
|||||||
auth: "aws-sdk",
|
auth: "aws-sdk",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "anthropic.claude-opus-4-5-20251101-v1:0",
|
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||||
name: "Claude Opus 4.5 (Bedrock)",
|
name: "Claude 3.7 Sonnet (Bedrock)",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text"],
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
contextWindow: 200000,
|
contextWindow: 200000,
|
||||||
maxTokens: 8192
|
maxTokens: 8192
|
||||||
@@ -89,79 +58,15 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
|
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## EC2 Instance Roles
|
|
||||||
|
|
||||||
When running Clawdbot on an EC2 instance with an IAM role attached, the AWS SDK
|
|
||||||
will automatically use the instance metadata service (IMDS) for authentication.
|
|
||||||
However, Clawdbot's credential detection currently only checks for environment
|
|
||||||
variables, not IMDS credentials.
|
|
||||||
|
|
||||||
**Workaround:** Set `AWS_PROFILE=default` to signal that AWS credentials are
|
|
||||||
available. The actual authentication still uses the instance role via IMDS.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add to ~/.bashrc or your shell profile
|
|
||||||
export AWS_PROFILE=default
|
|
||||||
export AWS_REGION=us-east-1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Required IAM permissions** for the EC2 instance role:
|
|
||||||
- `bedrock:InvokeModel`
|
|
||||||
- `bedrock:InvokeModelWithResponseStream`
|
|
||||||
- `bedrock:ListFoundationModels` (for automatic discovery)
|
|
||||||
|
|
||||||
Or attach the managed policy `AmazonBedrockFullAccess`.
|
|
||||||
|
|
||||||
**Quick setup:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create IAM role and instance profile
|
|
||||||
aws iam create-role --role-name EC2-Bedrock-Access \
|
|
||||||
--assume-role-policy-document '{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": {"Service": "ec2.amazonaws.com"},
|
|
||||||
"Action": "sts:AssumeRole"
|
|
||||||
}]
|
|
||||||
}'
|
|
||||||
|
|
||||||
aws iam attach-role-policy --role-name EC2-Bedrock-Access \
|
|
||||||
--policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
|
|
||||||
|
|
||||||
aws iam create-instance-profile --instance-profile-name EC2-Bedrock-Access
|
|
||||||
aws iam add-role-to-instance-profile \
|
|
||||||
--instance-profile-name EC2-Bedrock-Access \
|
|
||||||
--role-name EC2-Bedrock-Access
|
|
||||||
|
|
||||||
# 2. Attach to your EC2 instance
|
|
||||||
aws ec2 associate-iam-instance-profile \
|
|
||||||
--instance-id i-xxxxx \
|
|
||||||
--iam-instance-profile Name=EC2-Bedrock-Access
|
|
||||||
|
|
||||||
# 3. On the EC2 instance, enable discovery
|
|
||||||
clawdbot config set models.bedrockDiscovery.enabled true
|
|
||||||
clawdbot config set models.bedrockDiscovery.region us-east-1
|
|
||||||
|
|
||||||
# 4. Set the workaround env vars
|
|
||||||
echo 'export AWS_PROFILE=default' >> ~/.bashrc
|
|
||||||
echo 'export AWS_REGION=us-east-1' >> ~/.bashrc
|
|
||||||
source ~/.bashrc
|
|
||||||
|
|
||||||
# 5. Verify models are discovered
|
|
||||||
clawdbot models list
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Bedrock requires **model access** enabled in your AWS account/region.
|
- Bedrock requires **model access** enabled in your AWS account/region.
|
||||||
- Automatic discovery needs the `bedrock:ListFoundationModels` permission.
|
|
||||||
- If you use profiles, set `AWS_PROFILE` on the gateway host.
|
- If you use profiles, set `AWS_PROFILE` on the gateway host.
|
||||||
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
|
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
|
||||||
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the
|
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the
|
||||||
|
|||||||
@@ -147,8 +147,7 @@ Available actions:
|
|||||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
||||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
|
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
|
||||||
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
|
|
||||||
|
|
||||||
### Message IDs (short vs full)
|
### Message IDs (short vs full)
|
||||||
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
||||||
@@ -196,7 +195,6 @@ Provider options:
|
|||||||
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
||||||
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
||||||
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
||||||
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline and sends each line immediately during streaming.
|
|
||||||
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
||||||
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
||||||
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
||||||
@@ -213,7 +211,6 @@ Prefer `chat_guid` for stable routing:
|
|||||||
- `chat_id:123`
|
- `chat_id:123`
|
||||||
- `chat_identifier:...`
|
- `chat_identifier:...`
|
||||||
- Direct handles: `+15555550123`, `user@example.com`
|
- Direct handles: `+15555550123`, `user@example.com`
|
||||||
- If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled.
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ Notes:
|
|||||||
## Capabilities & limits
|
## Capabilities & limits
|
||||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||||
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
||||||
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on each line before length chunking.
|
|
||||||
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
||||||
- Mention-gated guild replies by default to avoid noisy bots.
|
- Mention-gated guild replies by default to avoid noisy bots.
|
||||||
- Reply context is injected when a message references another message (quoted content + ids).
|
- Reply context is injected when a message references another message (quoted content + ids).
|
||||||
@@ -307,7 +306,6 @@ ack reaction after the bot replies.
|
|||||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||||
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
||||||
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking.
|
|
||||||
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Google Chat app support status, capabilities, and configuration"
|
|
||||||
read_when:
|
|
||||||
- Working on Google Chat channel features
|
|
||||||
---
|
|
||||||
# Google Chat (Chat API)
|
|
||||||
|
|
||||||
Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
|
|
||||||
|
|
||||||
## Quick setup (beginner)
|
|
||||||
1) Create a Google Cloud project and enable the **Google Chat API**.
|
|
||||||
- Go to: [Google Chat API Credentials](https://console.cloud.google.com/apis/api/chat.googleapis.com/credentials)
|
|
||||||
- Enable the API if it is not already enabled.
|
|
||||||
2) Create a **Service Account**:
|
|
||||||
- Press **Create Credentials** > **Service Account**.
|
|
||||||
- Name it whatever you want (e.g., `clawdbot-chat`).
|
|
||||||
- Leave permissions blank (press **Continue**).
|
|
||||||
- Leave principals with access blank (press **Done**).
|
|
||||||
3) Create and download the **JSON Key**:
|
|
||||||
- In the list of service accounts, click on the one you just created.
|
|
||||||
- Go to the **Keys** tab.
|
|
||||||
- Click **Add Key** > **Create new key**.
|
|
||||||
- Select **JSON** and press **Create**.
|
|
||||||
4) Store the downloaded JSON file on your gateway host (e.g., `~/.clawdbot/googlechat-service-account.json`).
|
|
||||||
5) Create a Google Chat app in the [Google Cloud Console Chat Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat):
|
|
||||||
- Fill in the **Application info**:
|
|
||||||
- **App name**: (e.g. `Clawdbot`)
|
|
||||||
- **Avatar URL**: (e.g. `https://clawd.bot/logo.png`)
|
|
||||||
- **Description**: (e.g. `Personal AI Assistant`)
|
|
||||||
- Enable **Interactive features**.
|
|
||||||
- Under **Functionality**, check **Join spaces and group conversations**.
|
|
||||||
- Under **Connection settings**, select **HTTP endpoint URL**.
|
|
||||||
- Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`.
|
|
||||||
- *Tip: Run `clawdbot status` to find your gateway's public URL.*
|
|
||||||
- Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**.
|
|
||||||
- Enter your email address (e.g. `user@example.com`) in the text box.
|
|
||||||
- Click **Save** at the bottom.
|
|
||||||
6) **Enable the app status**:
|
|
||||||
- After saving, **refresh the page**.
|
|
||||||
- Look for the **App status** section (usually near the top or bottom after saving).
|
|
||||||
- Change the status to **Live - available to users**.
|
|
||||||
- Click **Save** again.
|
|
||||||
7) Configure Clawdbot with the service account path + webhook audience:
|
|
||||||
- Env: `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE=/path/to/service-account.json`
|
|
||||||
- Or config: `channels.googlechat.serviceAccountFile: "/path/to/service-account.json"`.
|
|
||||||
8) Set the webhook audience type + value (matches your Chat app config).
|
|
||||||
9) Start the gateway. Google Chat will POST to your webhook path.
|
|
||||||
|
|
||||||
## Add to Google Chat
|
|
||||||
Once the gateway is running and your email is added to the visibility list:
|
|
||||||
1) Go to [Google Chat](https://chat.google.com/).
|
|
||||||
2) Click the **+** (plus) icon next to **Direct Messages**.
|
|
||||||
3) In the search bar (where you usually add people), type the **App name** you configured in the Google Cloud Console.
|
|
||||||
- **Note**: The bot will *not* appear in the "Marketplace" browse list because it is a private app. You must search for it by name.
|
|
||||||
4) Select your bot from the results.
|
|
||||||
5) Click **Add** or **Chat** to start a 1:1 conversation.
|
|
||||||
6) Send "Hello" to trigger the assistant!
|
|
||||||
|
|
||||||
## Public URL (Webhook-only)
|
|
||||||
Google Chat webhooks require a public HTTPS endpoint. For security, **only expose the `/googlechat` path** to the internet. Keep the Clawdbot dashboard and other sensitive endpoints on your private network.
|
|
||||||
|
|
||||||
### Option A: Tailscale Funnel (Recommended)
|
|
||||||
Use Tailscale Serve for the private dashboard and Funnel for the public webhook path. This keeps `/` private while exposing only `/googlechat`.
|
|
||||||
|
|
||||||
1. **Check what address your gateway is bound to:**
|
|
||||||
```bash
|
|
||||||
ss -tlnp | grep 18789
|
|
||||||
```
|
|
||||||
Note the IP address (e.g., `127.0.0.1`, `0.0.0.0`, or your Tailscale IP like `100.x.x.x`).
|
|
||||||
|
|
||||||
2. **Expose the dashboard to the tailnet only (port 8443):**
|
|
||||||
```bash
|
|
||||||
# If bound to localhost (127.0.0.1 or 0.0.0.0):
|
|
||||||
tailscale serve --bg --https 8443 http://127.0.0.1:18789
|
|
||||||
|
|
||||||
# If bound to Tailscale IP only (e.g., 100.106.161.80):
|
|
||||||
tailscale serve --bg --https 8443 http://100.106.161.80:18789
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Expose only the webhook path publicly:**
|
|
||||||
```bash
|
|
||||||
# If bound to localhost (127.0.0.1 or 0.0.0.0):
|
|
||||||
tailscale funnel --bg --set-path /googlechat http://127.0.0.1:18789/googlechat
|
|
||||||
|
|
||||||
# If bound to Tailscale IP only (e.g., 100.106.161.80):
|
|
||||||
tailscale funnel --bg --set-path /googlechat http://100.106.161.80:18789/googlechat
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Authorize the node for Funnel access:**
|
|
||||||
If prompted, visit the authorization URL shown in the output to enable Funnel for this node in your tailnet policy.
|
|
||||||
|
|
||||||
5. **Verify the configuration:**
|
|
||||||
```bash
|
|
||||||
tailscale serve status
|
|
||||||
tailscale funnel status
|
|
||||||
```
|
|
||||||
|
|
||||||
Your public webhook URL will be:
|
|
||||||
`https://<node-name>.<tailnet>.ts.net/googlechat`
|
|
||||||
|
|
||||||
Your private dashboard stays tailnet-only:
|
|
||||||
`https://<node-name>.<tailnet>.ts.net:8443/`
|
|
||||||
|
|
||||||
Use the public URL (without `:8443`) in the Google Chat app config.
|
|
||||||
|
|
||||||
> Note: This configuration persists across reboots. To remove it later, run `tailscale funnel reset` and `tailscale serve reset`.
|
|
||||||
|
|
||||||
### Option B: Reverse Proxy (Caddy)
|
|
||||||
If you use a reverse proxy like Caddy, only proxy the specific path:
|
|
||||||
```caddy
|
|
||||||
your-domain.com {
|
|
||||||
reverse_proxy /googlechat* localhost:18789
|
|
||||||
}
|
|
||||||
```
|
|
||||||
With this config, any request to `your-domain.com/` will be ignored or returned as 404, while `your-domain.com/googlechat` is safely routed to Clawdbot.
|
|
||||||
|
|
||||||
### Option C: Cloudflare Tunnel
|
|
||||||
Configure your tunnel's ingress rules to only route the webhook path:
|
|
||||||
- **Path**: `/googlechat` -> `http://localhost:18789/googlechat`
|
|
||||||
- **Default Rule**: HTTP 404 (Not Found)
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer <token>` header.
|
|
||||||
2. Clawdbot verifies the token against the configured `audienceType` + `audience`:
|
|
||||||
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
|
|
||||||
- `audienceType: "project-number"` → audience is the Cloud project number.
|
|
||||||
3. Messages are routed by space:
|
|
||||||
- DMs use session key `agent:<agentId>:googlechat:dm:<spaceId>`.
|
|
||||||
- Spaces use session key `agent:<agentId>:googlechat:group:<spaceId>`.
|
|
||||||
4. DM access is pairing by default. Unknown senders receive a pairing code; approve with:
|
|
||||||
- `clawdbot pairing approve googlechat <code>`
|
|
||||||
5. Group spaces require @-mention by default. Use `botUser` if mention detection needs the app’s user name.
|
|
||||||
|
|
||||||
## Targets
|
|
||||||
Use these identifiers for delivery and allowlists:
|
|
||||||
- Direct messages: `users/<userId>` or `users/<email>` (email addresses are accepted).
|
|
||||||
- Spaces: `spaces/<spaceId>`.
|
|
||||||
|
|
||||||
## Config highlights
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
"googlechat": {
|
|
||||||
enabled: true,
|
|
||||||
serviceAccountFile: "/path/to/service-account.json",
|
|
||||||
audienceType: "app-url",
|
|
||||||
audience: "https://gateway.example.com/googlechat",
|
|
||||||
webhookPath: "/googlechat",
|
|
||||||
botUser: "users/1234567890", // optional; helps mention detection
|
|
||||||
dm: {
|
|
||||||
policy: "pairing",
|
|
||||||
allowFrom: ["users/1234567890", "name@example.com"]
|
|
||||||
},
|
|
||||||
groupPolicy: "allowlist",
|
|
||||||
groups: {
|
|
||||||
"spaces/AAAA": {
|
|
||||||
allow: true,
|
|
||||||
requireMention: true,
|
|
||||||
users: ["users/1234567890"],
|
|
||||||
systemPrompt: "Short answers only."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions: { reactions: true },
|
|
||||||
typingIndicator: "message",
|
|
||||||
mediaMaxMb: 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
|
|
||||||
- Default webhook path is `/googlechat` if `webhookPath` isn’t set.
|
|
||||||
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
|
|
||||||
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
|
|
||||||
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### 405 Method Not Allowed
|
|
||||||
If Google Cloud Logs Explorer shows errors like:
|
|
||||||
```
|
|
||||||
status code: 405, reason phrase: HTTP error response: HTTP/1.1 405 Method Not Allowed
|
|
||||||
```
|
|
||||||
|
|
||||||
This means the webhook handler isn't registered. Common causes:
|
|
||||||
1. **Channel not configured**: The `channels.googlechat` section is missing from your config. Verify with:
|
|
||||||
```bash
|
|
||||||
clawdbot config get channels.googlechat
|
|
||||||
```
|
|
||||||
If it returns "Config path not found", add the configuration (see [Config highlights](#config-highlights)).
|
|
||||||
|
|
||||||
2. **Plugin not enabled**: Check plugin status:
|
|
||||||
```bash
|
|
||||||
clawdbot plugins list | grep googlechat
|
|
||||||
```
|
|
||||||
If it shows "disabled", add `plugins.entries.googlechat.enabled: true` to your config.
|
|
||||||
|
|
||||||
3. **Gateway not restarted**: After adding config, restart the gateway:
|
|
||||||
```bash
|
|
||||||
clawdbot gateway restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify the channel is running:
|
|
||||||
```bash
|
|
||||||
clawdbot channels status
|
|
||||||
# Should show: Google Chat default: enabled, configured, ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Other issues
|
|
||||||
- Check `clawdbot channels status --probe` for auth errors or missing audience config.
|
|
||||||
- If no messages arrive, confirm the Chat app's webhook URL + event subscriptions.
|
|
||||||
- If mention gating blocks replies, set `botUser` to the app's user resource name and verify `requireMention`.
|
|
||||||
- Use `clawdbot logs --follow` while sending a test message to see if requests reach the gateway.
|
|
||||||
|
|
||||||
Related docs:
|
|
||||||
- [Gateway configuration](/gateway/configuration)
|
|
||||||
- [Security](/gateway/security)
|
|
||||||
- [Reactions](/tools/reactions)
|
|
||||||
@@ -17,7 +17,7 @@ read_when:
|
|||||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
|
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
|
||||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||||
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
||||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||||
|
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ This is useful when you want an isolated personality/model for a specific thread
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
||||||
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on each line before length chunking.
|
|
||||||
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
||||||
|
|
||||||
## Addressing / delivery targets
|
## Addressing / delivery targets
|
||||||
@@ -254,7 +253,6 @@ Provider options:
|
|||||||
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
||||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
||||||
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
||||||
|
|||||||
Loaded 100 of 1486 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user