Compare commits
2 Commits
line-plugi
...
matrix-wit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b92925e0 | ||
|
|
73957ca92b |
143
.github/workflows/docker-release.yml
vendored
143
.github/workflows/docker-release.yml
vendored
@@ -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 }}
|
||||
1
.github/workflows/install-smoke.yml
vendored
1
.github/workflows/install-smoke.yml
vendored
@@ -29,6 +29,5 @@ jobs:
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||
CLAWDBOT_NO_ONBOARD: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||
run: pnpm test:install:smoke
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -66,6 +66,3 @@ apps/ios/*.mobileprovision
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
BIN
.serena/cache/typescript/document_symbols.pkl
vendored
Normal file
BIN
.serena/cache/typescript/document_symbols.pkl
vendored
Normal file
Binary file not shown.
BIN
.serena/cache/typescript/raw_document_symbols.pkl
vendored
Normal file
BIN
.serena/cache/typescript/raw_document_symbols.pkl
vendored
Normal file
Binary file not shown.
87
.serena/project.yml
Normal file
87
.serena/project.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran fsharp
|
||||
# go groovy haskell java julia kotlin
|
||||
# lua markdown nix pascal perl php
|
||||
# powershell python python_jedi r rego ruby
|
||||
# ruby_solargraph rust scala swift terraform toml
|
||||
# typescript typescript_vts yaml zig
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal / Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "clawdbot"
|
||||
included_optional_tools: []
|
||||
22
AGENTS.md
22
AGENTS.md
@@ -7,7 +7,6 @@
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `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`).
|
||||
- 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/`
|
||||
@@ -18,22 +17,11 @@
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
- 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)`).
|
||||
- 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 you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
## 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
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
@@ -41,7 +29,6 @@
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
@@ -63,7 +50,6 @@
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (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`.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
@@ -78,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 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
@@ -107,7 +92,6 @@
|
||||
## Agent-Specific Notes
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- 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 answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
@@ -116,7 +100,7 @@
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
@@ -133,10 +117,6 @@
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
||||
- Lint/format churn:
|
||||
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
||||
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
||||
- Only ask when changes are semantic (logic/data/behavior).
|
||||
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
|
||||
608
CHANGELOG.md
608
CHANGELOG.md
@@ -2,392 +2,254 @@
|
||||
|
||||
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
|
||||
## 2026.1.20-1
|
||||
|
||||
### 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
|
||||
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
||||
- 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.
|
||||
|
||||
### Fixes
|
||||
- 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
|
||||
|
||||
### 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: exec approvals mutations render tables instead of raw JSON.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
- 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)
|
||||
- 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.
|
||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||
- 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.
|
||||
- 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: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||
- Model picker: list the full catalog when no model allowlist is configured.
|
||||
- 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.
|
||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
### Changes
|
||||
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
|
||||
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui
|
||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui
|
||||
- TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui
|
||||
- TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui
|
||||
- ACP: add `clawdbot acp` for IDE integrations. https://docs.clawd.bot/cli/acp
|
||||
- ACP: add `clawdbot acp client` interactive harness for debugging. https://docs.clawd.bot/cli/acp
|
||||
- Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills
|
||||
- Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory
|
||||
- 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
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
|
||||
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser
|
||||
- Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr
|
||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix
|
||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack
|
||||
- Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram
|
||||
- Discord: fall back to `/skill` when native command limits are exceeded. (#1287)
|
||||
- Discord: expose `/skill` globally. (#1287)
|
||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser
|
||||
- Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) 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/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: 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 the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo
|
||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser
|
||||
- Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||
- 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: 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: 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
|
||||
- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security
|
||||
- Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec
|
||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec
|
||||
- Exec approvals: migrate approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals
|
||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`. https://docs.clawd.bot/cli/node
|
||||
- Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node
|
||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||
- 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
|
||||
- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding
|
||||
- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android
|
||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock
|
||||
- Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp
|
||||
- Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows
|
||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login
|
||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
||||
- Build: update workspace + core/plugin deps.
|
||||
- Build: use tsgo for dev/watch builds by default (opt out with `CLAWDBOT_TS_COMPILER=tsc`).
|
||||
- Deps: update workspace + memory-lancedb dependencies.
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
||||
- macOS: stop syncing Peekaboo in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
||||
### Fixes
|
||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `clawdbot doctor --fix` to repair, then update plugins (`clawdbot plugins update`) if you use any.
|
||||
## 2026.1.19-3
|
||||
|
||||
### Changes
|
||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
|
||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
|
||||
|
||||
### Fixes
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)
|
||||
- Diagnostics: gate heartbeat/webhook logging. (#1244)
|
||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||
- Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)
|
||||
- Gateway: clarify connect/validation errors for gateway params. (#1347)
|
||||
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337)
|
||||
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
||||
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
||||
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)
|
||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)
|
||||
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||
- Sessions: fall back to session labels when listing display names. (#1124)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
|
||||
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
||||
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226)
|
||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)
|
||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322)
|
||||
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)
|
||||
- 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)
|
||||
- CLI: skip runner rebuilds when dist is fresh. (#1231)
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101)
|
||||
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)
|
||||
- UI: preserve ordered list numbering in chat markdown. (#1341)
|
||||
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)
|
||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212)
|
||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)
|
||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298)
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201)
|
||||
- TUI: highlight model search matches and stabilize search ordering.
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182)
|
||||
- Memory: show total file counts + scan issues in `clawdbot memory status`.
|
||||
- Memory: fall back to non-batch embeddings after repeated batch failures.
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||
- Memory: split overly long lines to keep embeddings under token limits.
|
||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Exec approvals: enforce allowlist when ask is off.
|
||||
- Exec approvals: prefer raw command for node approvals/events.
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
|
||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)
|
||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)
|
||||
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
|
||||
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
|
||||
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)
|
||||
- Slack: resolve Bolt import interop for Bun + Node. (#1191)
|
||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)
|
||||
- Browser: register AI snapshot refs for act commands. (#1282)
|
||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||
- Anthropic: default API prompt caching to 1h with configurable TTL override.
|
||||
- Anthropic: ignore TTL for OAuth.
|
||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)
|
||||
- Auth profiles: user pins stay locked. (#1138)
|
||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)
|
||||
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
|
||||
|
||||
## 2026.1.19-2
|
||||
|
||||
### Changes
|
||||
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
||||
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
||||
- Docs: refresh Android node discovery docs for the Gateway WS service type.
|
||||
|
||||
### Fixes
|
||||
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
|
||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||
- Windows: install gateway scheduled task as the current user.
|
||||
- Windows: show friendly guidance instead of failing on access denied.
|
||||
- CLI: skip runner rebuilds when dist is fresh. (#1231) — thanks @mukhtharcm, @thewilloftheshadow.
|
||||
|
||||
## 2026.1.19-1
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
||||
|
||||
### Changes
|
||||
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
|
||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||
|
||||
### Fixes
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||
- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31.
|
||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
|
||||
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||
|
||||
## 2026.1.18-5
|
||||
|
||||
### Changes
|
||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
||||
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
||||
|
||||
### Fixes
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||
- macOS: suppress usage error text in the menubar cost view.
|
||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)
|
||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
- TUI: highlight model search matches and stabilize search ordering.
|
||||
- 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) — thanks @gumadeiras.
|
||||
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
|
||||
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
||||
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
||||
|
||||
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.
|
||||
## 2026.1.18-4
|
||||
|
||||
### Changes
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
||||
- macOS: stop syncing Peekaboo in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
||||
|
||||
### Fixes
|
||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
|
||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||
|
||||
## 2026.1.18-3
|
||||
|
||||
### Changes
|
||||
- Exec: add host/security/ask routing for gateway + node exec.
|
||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
|
||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
|
||||
|
||||
### Fixes
|
||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||
|
||||
## 2026.1.18-2
|
||||
|
||||
### Fixes
|
||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||
|
||||
## 2026.1.18-1
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
|
||||
## 2026.1.17-6
|
||||
|
||||
### Changes
|
||||
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
||||
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
||||
- Docs: document plugin slots and memory plugin behavior.
|
||||
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
|
||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
|
||||
|
||||
## 2026.1.17-5
|
||||
|
||||
### Changes
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
||||
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
|
||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
|
||||
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Tools: centralize plugin tool policy helpers.
|
||||
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
|
||||
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
||||
|
||||
### Fixes
|
||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||
|
||||
## 2026.1.18-1
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
## 2026.1.17-3
|
||||
|
||||
### Changes
|
||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||
|
||||
### Fixes
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
|
||||
## 2026.1.17-2
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||
- Memory: split overly long lines to keep embeddings under token limits.
|
||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
|
||||
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
||||
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
||||
- CLI: surface update availability in `clawdbot status`.
|
||||
- CLI: add `clawdbot memory status --deep/--index` probes.
|
||||
- CLI: add playful update completion quips.
|
||||
|
||||
### Fixes
|
||||
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
|
||||
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
|
||||
71
README.md
71
README.md
@@ -17,7 +17,7 @@
|
||||
</p>
|
||||
|
||||
**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.
|
||||
|
||||
@@ -65,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
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.
|
||||
- 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`).
|
||||
@@ -116,7 +116,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[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).
|
||||
- **[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).
|
||||
@@ -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).
|
||||
|
||||
### 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).
|
||||
|
||||
### Apps + nodes
|
||||
@@ -169,7 +169,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## 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
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
@@ -471,38 +471,35 @@ by Peter Steinberger and the community.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||
|
||||
Core contributors:
|
||||
- @cpojer — Telegram onboarding UX + docs
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/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>
|
||||
<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/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/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/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/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/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/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/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/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/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/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/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
|
||||
"originHash" : "2e6f580ad7d1e839d513aa883350369bf2e4193fad872030fdaea7827f34d8ef",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
@@ -10,6 +10,24 @@
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -27,6 +45,24 @@
|
||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||
"version" : "0.99.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||
"state" : {
|
||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textual",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/textual",
|
||||
"state" : {
|
||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
430
appcast.xml
430
appcast.xml
@@ -3,213 +3,273 @@
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.23</title>
|
||||
<pubDate>Sat, 24 Jan 2026 13:02:18 +0000</pubDate>
|
||||
<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>7750</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.23</sparkle:shortVersionString>
|
||||
<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.23</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).</li>
|
||||
</ul>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.</li>
|
||||
<li>Agents: keep system prompt time zone-only and move current time to <code>session_status</code> for better cache hits.</li>
|
||||
<li>Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.</li>
|
||||
<li>Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).</li>
|
||||
<li>Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.</li>
|
||||
<li>Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.</li>
|
||||
<li>CLI: restart the gateway by default after <code>clawdbot update</code>; add <code>--no-restart</code> to skip it.</li>
|
||||
<li>CLI: add live auth probes to <code>clawdbot models status</code> for per-profile verification.</li>
|
||||
<li>CLI: add <code>clawdbot system</code> for system events + heartbeat controls; remove standalone <code>wake</code>.</li>
|
||||
<li>Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.</li>
|
||||
<li>Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.</li>
|
||||
<li>Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.</li>
|
||||
<li>Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.</li>
|
||||
<li>Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.</li>
|
||||
<li>Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.</li>
|
||||
<li>TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)</li>
|
||||
<li>Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.</li>
|
||||
<li>Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)</li>
|
||||
<li>Sessions: normalize session key casing to lowercase for consistent routing.</li>
|
||||
<li>BlueBubbles: normalize group session keys for outbound mirroring. (#1520)</li>
|
||||
<li>Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.</li>
|
||||
<li>Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.</li>
|
||||
<li>Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).</li>
|
||||
<li>Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)</li>
|
||||
<li>Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.</li>
|
||||
<li>Docker: update gateway command in docker-compose and Hetzner guide. (#1514)</li>
|
||||
<li>Sessions: reject array-backed session stores to prevent silent wipes. (#1469)</li>
|
||||
<li>Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.</li>
|
||||
<li>UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.</li>
|
||||
<li>UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.</li>
|
||||
<li>Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.</li>
|
||||
<li>Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.</li>
|
||||
<li>Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.</li>
|
||||
<li>Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.</li>
|
||||
<li>Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).</li>
|
||||
<li>Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.</li>
|
||||
<li>Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).</li>
|
||||
<li>TUI: forward unknown slash commands (for example, <code>/context</code>) to the Gateway.</li>
|
||||
<li>TUI: include Gateway slash commands in autocomplete and <code>/help</code>.</li>
|
||||
<li>CLI: skip usage lines in <code>clawdbot models status</code> when provider usage is unavailable.</li>
|
||||
<li>CLI: suppress diagnostic session/run noise during auth probes.</li>
|
||||
<li>CLI: hide auth probe timeout warnings from embedded runs.</li>
|
||||
<li>CLI: render auth probe results as a table in <code>clawdbot models status</code>.</li>
|
||||
<li>CLI: suppress probe-only embedded logs unless <code>--verbose</code> is set.</li>
|
||||
<li>CLI: move auth probe errors below the table to reduce wrapping.</li>
|
||||
<li>CLI: prevent ANSI color bleed when table cells wrap.</li>
|
||||
<li>CLI: explain when auth profiles are excluded by auth.order in probe details.</li>
|
||||
<li>CLI: drop the em dash when the banner tagline wraps to a second line.</li>
|
||||
<li>CLI: inline auth probe errors in status rows to reduce wrapping.</li>
|
||||
<li>Telegram: render markdown in media captions. (#1478)</li>
|
||||
<li>Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.</li>
|
||||
<li>Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)</li>
|
||||
<li>Daemon: use platform PATH delimiters when building minimal service paths.</li>
|
||||
<li>Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.</li>
|
||||
<li>Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.</li>
|
||||
<li>TUI: render Gateway slash-command replies as system output (for example, <code>/context</code>).</li>
|
||||
<li>Media: only parse <code>MEDIA:</code> tags when they start the line to avoid stripping prose mentions. (#1206)</li>
|
||||
<li>Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.</li>
|
||||
<li>Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)</li>
|
||||
<li>Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.</li>
|
||||
<li>MS Teams (plugin): remove <code>.default</code> suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.</li>
|
||||
<li>MS Teams (plugin): remove <code>.default</code> suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.</li>
|
||||
<li>Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)</li>
|
||||
<li>Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)</li>
|
||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</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.23/Clawdbot-2026.1.23.zip" length="22326233" type="application/octet-stream" sparkle:edSignature="p40dFczUfmMpsif4BrEUYVqUPG2WiBXleWgefwu4WiqjuyXbw7CAaH5CpQKig/k2qRLlE59kX7AR/qJqmy+yCA=="/>
|
||||
<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.22</title>
|
||||
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
|
||||
<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>7530</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
|
||||
<sparkle:version>5998</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.15</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>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</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>
|
||||
<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> 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> Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.</li>
|
||||
<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>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>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>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>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</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: 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>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>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</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>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</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>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
|
||||
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
|
||||
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
|
||||
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
|
||||
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
|
||||
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</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>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
|
||||
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
|
||||
<li>Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.</li>
|
||||
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</li>
|
||||
<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.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.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.14-1</title>
|
||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5825</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<h4>Web Tools</h4>
|
||||
<ul>
|
||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
||||
</ul>
|
||||
<h4>Browser / Control UI</h4>
|
||||
<ul>
|
||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</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: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
||||
</ul>
|
||||
<h4>Plugins</h4>
|
||||
<ul>
|
||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
||||
</ul>
|
||||
<h4>Security</h4>
|
||||
<ul>
|
||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
||||
</ul>
|
||||
<h4>Onboarding / Daemon</h4>
|
||||
<ul>
|
||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
||||
</ul>
|
||||
<h4>Auth / Usage / Config</h4>
|
||||
<ul>
|
||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
||||
</ul>
|
||||
<h4>Channels</h4>
|
||||
<ul>
|
||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
||||
</ul>
|
||||
<h4>Docs</h4>
|
||||
<ul>
|
||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<h4>Gateway / Daemon / Sessions</h4>
|
||||
<ul>
|
||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
||||
</ul>
|
||||
<h4>CLI / Onboarding</h4>
|
||||
<ul>
|
||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
||||
</ul>
|
||||
<h4>Control UI / TUI</h4>
|
||||
<ul>
|
||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
||||
</ul>
|
||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
||||
<ul>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
</ul>
|
||||
<h4>macOS / Apps</h4>
|
||||
<ul>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h4>Channels / Messaging</h4>
|
||||
<ul>
|
||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
||||
<li>WhatsApp: harden owner command auth.</li>
|
||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
||||
</ul>
|
||||
<h4>Config / Doctor / Packaging</h4>
|
||||
<ul>
|
||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</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.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -1,6 +1,6 @@
|
||||
## Clawdbot Node (Android) (internal)
|
||||
|
||||
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||
|
||||
Notes:
|
||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601240
|
||||
versionName = "2026.1.24"
|
||||
versionCode = 202601114
|
||||
versionName = "2026.1.11-4"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,14 +8,10 @@ object WakeWords {
|
||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun parseIfChanged(input: String, current: List<String>): List<String>? {
|
||||
val parsed = parseCommaSeparated(input)
|
||||
return if (parsed == current) null else parsed
|
||||
}
|
||||
|
||||
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||
val cleaned =
|
||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||
return cleaned.ifEmpty { defaults }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class GatewayDiscovery(
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val serviceType = "_clawdbot-gw._tcp."
|
||||
private val serviceType = "_clawdbot-gateway._tcp."
|
||||
private val wideAreaDomain = "clawdbot.internal."
|
||||
private val logTag = "Clawdbot/GatewayDiscovery"
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
@@ -51,10 +49,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -63,7 +58,6 @@ import com.clawdbot.android.LocationMode
|
||||
import com.clawdbot.android.MainViewModel
|
||||
import com.clawdbot.android.NodeForegroundService
|
||||
import com.clawdbot.android.VoiceWakeMode
|
||||
import com.clawdbot.android.WakeWords
|
||||
|
||||
@Composable
|
||||
fun SettingsSheet(viewModel: MainViewModel) {
|
||||
@@ -92,8 +86,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val listState = rememberLazyListState()
|
||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
var wakeWordsHadFocus by remember { mutableStateOf(false) }
|
||||
val deviceModel =
|
||||
remember {
|
||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
@@ -112,12 +104,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
|
||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||
val commitWakeWords = {
|
||||
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
||||
if (parsed != null) {
|
||||
viewModel.setWakeWords(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
@@ -495,27 +481,25 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
value = wakeWordsText,
|
||||
onValueChange = setWakeWordsText,
|
||||
label = { Text("Wake Words (comma-separated)") },
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) {
|
||||
wakeWordsHadFocus = true
|
||||
} else if (wakeWordsHadFocus) {
|
||||
wakeWordsHadFocus = false
|
||||
commitWakeWords()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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 {
|
||||
Text(
|
||||
if (isConnected) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class WakeWordsTest {
|
||||
@@ -33,18 +32,5 @@ class WakeWordsTest {
|
||||
assertEquals("w1", sanitized.first())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.24</string>
|
||||
<string>2026.1.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260124</string>
|
||||
<string>20260109</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
@@ -29,7 +29,7 @@
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_clawdbot-gw._tcp</string>
|
||||
<string>_clawdbot-gateway._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct VoiceWakeWordsSettingsView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||
@FocusState private var focusedTriggerIndex: Int?
|
||||
@State private var syncTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
@@ -14,10 +12,6 @@ struct VoiceWakeWordsSettingsView: View {
|
||||
TextField("Wake word", text: self.binding(for: index))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused(self.$focusedTriggerIndex, equals: index)
|
||||
.onSubmit {
|
||||
self.commitTriggerWords()
|
||||
}
|
||||
}
|
||||
.onDelete(perform: self.removeWords)
|
||||
|
||||
@@ -45,18 +39,17 @@ struct VoiceWakeWordsSettingsView: View {
|
||||
.onAppear {
|
||||
if self.triggerWords.isEmpty {
|
||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||
self.commitTriggerWords()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
|
||||
guard oldValue != nil, oldValue != newValue else { return }
|
||||
self.commitTriggerWords()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
||||
guard self.focusedTriggerIndex == nil else { return }
|
||||
let updated = VoiceWakePreferences.loadTriggerWords()
|
||||
if updated != self.triggerWords {
|
||||
self.triggerWords = updated
|
||||
.onChange(of: self.triggerWords) { _, newValue in
|
||||
// Keep local voice wake responsive even if the gateway isn't connected yet.
|
||||
VoiceWakePreferences.saveTriggerWords(newValue)
|
||||
|
||||
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
|
||||
self.syncTask?.cancel()
|
||||
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
||||
try? await Task.sleep(nanoseconds: 650_000_000)
|
||||
await appModel?.setGlobalWakeWords(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +63,6 @@ struct VoiceWakeWordsSettingsView: View {
|
||||
if self.triggerWords.isEmpty {
|
||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||
}
|
||||
self.commitTriggerWords()
|
||||
}
|
||||
|
||||
private func binding(for index: Int) -> Binding<String> {
|
||||
@@ -84,15 +76,4 @@ struct VoiceWakeWordsSettingsView: View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +132,6 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private func startRecognition() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
#endif
|
||||
|
||||
self.stopRecognition()
|
||||
self.speechRecognizer = SFSpeechRecognizer()
|
||||
guard let recognizer = self.speechRecognizer else {
|
||||
@@ -152,11 +146,6 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
let input = self.audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
guard format.sampleRate > 0, format.channelCount > 0 else {
|
||||
throw NSError(domain: "TalkMode", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Invalid audio input format",
|
||||
])
|
||||
}
|
||||
input.removeTap(onBus: 0)
|
||||
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
||||
|
||||
@@ -6,8 +6,6 @@ enum VoiceWakePreferences {
|
||||
|
||||
// Keep defaults aligned with the mac app.
|
||||
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
||||
static let maxWords = 32
|
||||
static let maxWordLength = 64
|
||||
|
||||
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
||||
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
||||
@@ -32,8 +30,6 @@ enum VoiceWakePreferences {
|
||||
let cleaned = words
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.prefix(Self.maxWords)
|
||||
.map { String($0.prefix(Self.maxWordLength)) }
|
||||
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import Testing
|
||||
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Gateway \\032 Node\n",
|
||||
type: "_clawdbot-gw._tcp",
|
||||
type: "_clawdbot-gateway._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node")
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
|
||||
}
|
||||
|
||||
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
||||
@@ -22,7 +22,7 @@ import Testing
|
||||
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Gateway",
|
||||
type: "_clawdbot-gw._tcp",
|
||||
type: "_clawdbot-gateway._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.24</string>
|
||||
<string>2026.1.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260124</string>
|
||||
<string>20260109</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,18 +11,6 @@ import Testing
|
||||
#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() {
|
||||
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.24"
|
||||
CFBundleVersion: "20260124"
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -92,7 +92,7 @@ targets:
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
NSBonjourServices:
|
||||
- _clawdbot-gw._tcp
|
||||
- _clawdbot-gateway._tcp
|
||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
|
||||
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: ClawdbotTests
|
||||
CFBundleShortVersionString: "2026.1.24"
|
||||
CFBundleVersion: "20260124"
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2",
|
||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -24,7 +24,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ let package = Package(
|
||||
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
||||
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
||||
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
||||
.executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]),
|
||||
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
|
||||
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||
@@ -66,13 +67,21 @@ let package = Package(
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotMacCLI",
|
||||
name: "ClawdbotDiscoveryCLI",
|
||||
dependencies: [
|
||||
"ClawdbotDiscovery",
|
||||
],
|
||||
path: "Sources/ClawdbotDiscoveryCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotWizardCLI",
|
||||
dependencies: [
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||
],
|
||||
path: "Sources/ClawdbotMacCLI",
|
||||
path: "Sources/ClawdbotWizardCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
|
||||
@@ -81,7 +81,7 @@ private struct EventRow: View {
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? {
|
||||
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
|
||||
let normalized = dict.mapValues { $0.value }
|
||||
guard JSONSerialization.isValidJSONObject(normalized),
|
||||
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
|
||||
@@ -98,10 +98,7 @@ struct AgentEventsWindow_Previews: PreviewProvider {
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
data: [
|
||||
"phase": ClawdbotProtocol.AnyCodable("start"),
|
||||
"name": ClawdbotProtocol.AnyCodable("bash"),
|
||||
],
|
||||
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
|
||||
summary: nil)
|
||||
AgentEventStore.shared.append(sample)
|
||||
return AgentEventsWindow()
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
|
||||
typealias AnyCodable = ClawdbotKit.AnyCodable
|
||||
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
|
||||
|
||||
extension AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
@@ -25,23 +20,3 @@ extension AnyCodable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ClawdbotProtocol.AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
var intValue: Int? { self.value as? Int }
|
||||
var doubleValue: Double? { self.value as? Double }
|
||||
var dictionaryValue: [String: ClawdbotProtocol.AnyCodable]? { self.value as? [String: ClawdbotProtocol.AnyCodable] }
|
||||
var arrayValue: [ClawdbotProtocol.AnyCodable]? { self.value as? [ClawdbotProtocol.AnyCodable] }
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: ClawdbotProtocol.AnyCodable]:
|
||||
dict.mapValues { $0.foundationValue }
|
||||
case let array as [ClawdbotProtocol.AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,6 @@ final class AppState {
|
||||
case remote
|
||||
}
|
||||
|
||||
enum RemoteTransport: String {
|
||||
case ssh
|
||||
case direct
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
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 {
|
||||
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 {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||
}
|
||||
@@ -276,15 +263,13 @@ final class AppState {
|
||||
}
|
||||
|
||||
let configRoot = ClawdbotConfigFile.loadDict()
|
||||
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
||||
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.remoteTransport = configRemoteTransport
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
if resolvedConnectionMode == .remote,
|
||||
configRemoteTransport != .direct,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||
{
|
||||
@@ -292,7 +277,6 @@ final class AppState {
|
||||
} else {
|
||||
self.remoteTarget = storedRemoteTarget
|
||||
}
|
||||
self.remoteUrl = configRemoteUrl ?? ""
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
@@ -370,11 +354,10 @@ final class AppState {
|
||||
private func applyConfigOverrides(_ root: [String: Any]) {
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
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?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
|
||||
|
||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||
case "local":
|
||||
@@ -395,17 +378,8 @@ final class AppState {
|
||||
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
|
||||
if targetMode == .remote,
|
||||
remoteTransport != .direct,
|
||||
let host = AppState.remoteHost(from: remoteUrl)
|
||||
{
|
||||
self.updateRemoteTarget(host: host)
|
||||
@@ -428,8 +402,6 @@ final class AppState {
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let remoteIdentity = self.remoteIdentity
|
||||
let remoteTransport = self.remoteTransport
|
||||
let remoteUrl = self.remoteUrl
|
||||
let desiredMode: String? = switch connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
@@ -463,63 +435,39 @@ final class AppState {
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
var remoteChanged = false
|
||||
|
||||
if remoteTransport == .direct {
|
||||
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedUrl.isEmpty {
|
||||
if remote["url"] != nil {
|
||||
remote.removeValue(forKey: "url")
|
||||
remoteChanged = true
|
||||
}
|
||||
} else {
|
||||
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
|
||||
if (remote["url"] as? String) != normalizedUrl {
|
||||
remote["url"] = normalizedUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
|
||||
remote["transport"] = RemoteTransport.direct.rawValue
|
||||
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
|
||||
}
|
||||
} 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)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
if remoteChanged {
|
||||
@@ -673,10 +621,8 @@ extension AppState {
|
||||
state.iconOverride = .system
|
||||
state.heartbeatsEnabled = true
|
||||
state.connectionMode = .local
|
||||
state.remoteTransport = .ssh
|
||||
state.canvasEnabled = true
|
||||
state.remoteTarget = "user@example.com"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||
state.remoteCliPath = ""
|
||||
|
||||
@@ -40,16 +40,6 @@ extension ChannelsSettings {
|
||||
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 {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return .secondary }
|
||||
@@ -95,14 +85,6 @@ extension ChannelsSettings {
|
||||
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 {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return "Checking…" }
|
||||
@@ -211,37 +193,6 @@ extension ChannelsSettings {
|
||||
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? {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return nil }
|
||||
@@ -293,7 +244,7 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
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 channels = order.enumerated().map { index, id in
|
||||
ChannelItem(
|
||||
@@ -356,8 +307,6 @@ extension ChannelsSettings {
|
||||
return self.telegramTint
|
||||
case "discord":
|
||||
return self.discordTint
|
||||
case "googlechat":
|
||||
return self.googlechatTint
|
||||
case "signal":
|
||||
return self.signalTint
|
||||
case "imessage":
|
||||
@@ -377,8 +326,6 @@ extension ChannelsSettings {
|
||||
return self.telegramSummary
|
||||
case "discord":
|
||||
return self.discordSummary
|
||||
case "googlechat":
|
||||
return self.googlechatSummary
|
||||
case "signal":
|
||||
return self.signalSummary
|
||||
case "imessage":
|
||||
@@ -398,8 +345,6 @@ extension ChannelsSettings {
|
||||
return self.telegramDetails
|
||||
case "discord":
|
||||
return self.discordDetails
|
||||
case "googlechat":
|
||||
return self.googlechatDetails
|
||||
case "signal":
|
||||
return self.signalDetails
|
||||
case "imessage":
|
||||
@@ -432,10 +377,6 @@ extension ChannelsSettings {
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.lastProbeAt)
|
||||
case "googlechat":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)?
|
||||
.lastProbeAt)
|
||||
case "signal":
|
||||
return self
|
||||
.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)
|
||||
else { return 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":
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
@@ -495,11 +432,11 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||
self.store.resolveChannelDetailLabel(id)
|
||||
return self.store.resolveChannelDetailLabel(id)
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
self.store.resolveChannelSystemImage(id)
|
||||
return self.store.resolveChannelSystemImage(id)
|
||||
}
|
||||
|
||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||
|
||||
@@ -85,28 +85,6 @@ struct ChannelsStatusSnapshot: Codable {
|
||||
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 {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
@@ -185,9 +163,9 @@ struct ChannelsStatusSnapshot: Codable {
|
||||
let ts: Double
|
||||
let channelOrder: [String]
|
||||
let channelLabels: [String: String]
|
||||
let channelDetailLabels: [String: String]?
|
||||
let channelSystemImages: [String: String]?
|
||||
let channelMeta: [ChannelUiMetaEntry]?
|
||||
let channelDetailLabels: [String: String]? = nil
|
||||
let channelSystemImages: [String: String]? = nil
|
||||
let channelMeta: [ChannelUiMetaEntry]? = nil
|
||||
let channels: [String: AnyCodable]
|
||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
||||
let channelDefaultAccountId: [String: String]
|
||||
@@ -285,7 +263,7 @@ final class ChannelsStore {
|
||||
|
||||
func orderedChannelIds() -> [String] {
|
||||
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
|
||||
return meta.map(\.id)
|
||||
return meta.map { $0.id }
|
||||
}
|
||||
return self.snapshot?.channelOrder ?? []
|
||||
}
|
||||
|
||||
@@ -284,16 +284,13 @@ enum CommandResolver {
|
||||
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identity.isEmpty {
|
||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args.append(contentsOf: ["-i", settings.identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
@@ -6,19 +6,15 @@ struct ConfigSettings: View {
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
@Bindable var store: ChannelsStore
|
||||
@State private var hasLoaded = false
|
||||
@State private var activeSectionKey: String?
|
||||
@State private var activeSubsection: SubsectionSelection?
|
||||
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
self.sidebar
|
||||
self.detail
|
||||
ScrollView {
|
||||
self.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
@@ -26,125 +22,42 @@ struct ConfigSettings: View {
|
||||
await self.store.loadConfigSchema()
|
||||
await self.store.loadConfig()
|
||||
}
|
||||
.onAppear { self.ensureSelection() }
|
||||
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||
if !loading { self.ensureSelection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private enum SubsectionSelection: Hashable {
|
||||
case all
|
||||
case key(String)
|
||||
}
|
||||
|
||||
private struct ConfigSection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
|
||||
var id: String { self.key }
|
||||
}
|
||||
|
||||
private struct ConfigSubsection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
|
||||
var id: String { self.key }
|
||||
}
|
||||
|
||||
private var sections: [ConfigSection] {
|
||||
guard let schema = self.store.configSchema else { return [] }
|
||||
return self.resolveSections(schema)
|
||||
}
|
||||
|
||||
private var activeSection: ConfigSection? {
|
||||
self.sections.first { $0.key == self.activeSectionKey }
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if self.sections.isEmpty {
|
||||
Text("No config sections available.")
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.actionRow
|
||||
Group {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = self.store.configSchema {
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
||||
.disabled(self.isNixMode)
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(self.sections) { section in
|
||||
self.sidebarRow(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
private var detail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let section = self.activeSection {
|
||||
self.sectionDetail(section)
|
||||
} else if self.store.configSchema != nil {
|
||||
self.emptyDetail
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var emptyDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.header
|
||||
Text("Select a config section to view settings.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.actionRow
|
||||
self.sectionHeader(section)
|
||||
self.subsectionNav(section)
|
||||
self.sectionForm(section)
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -158,18 +71,6 @@ extension ConfigSettings {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ section: ConfigSection) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(section.label)
|
||||
.font(.title3.weight(.semibold))
|
||||
if let help = section.help {
|
||||
Text(help)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var actionRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button("Reload") {
|
||||
@@ -184,204 +85,6 @@ extension ConfigSettings {
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private func sidebarRow(_ section: ConfigSection) -> some View {
|
||||
let isSelected = self.activeSectionKey == section.key
|
||||
return Button {
|
||||
self.selectSection(section)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section.label)
|
||||
if let help = section.help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.background(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func subsectionNav(_ section: ConfigSection) -> some View {
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
if subsections.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
self.subsectionButton(
|
||||
title: "All",
|
||||
isSelected: self.activeSubsection == .all)
|
||||
{
|
||||
self.activeSubsection = .all
|
||||
}
|
||||
ForEach(subsections) { subsection in
|
||||
self.subsectionButton(
|
||||
title: subsection.label,
|
||||
isSelected: self.activeSubsection == .key(subsection.key))
|
||||
{
|
||||
self.activeSubsection = .key(subsection.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func subsectionButton(
|
||||
title: String,
|
||||
isSelected: Bool,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(isSelected ? Color.accentColor : .primary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||
let subsection = self.activeSubsection
|
||||
let defaultPath: ConfigPath = [.key(section.key)]
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
let resolved: (ConfigSchemaNode, ConfigPath) = {
|
||||
if case let .key(key) = subsection,
|
||||
let match = subsections.first(where: { $0.key == key })
|
||||
{
|
||||
return (match.node, match.path)
|
||||
}
|
||||
return (self.resolvedSchemaNode(section.node), defaultPath)
|
||||
}()
|
||||
|
||||
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
|
||||
.disabled(self.isNixMode)
|
||||
}
|
||||
|
||||
private func ensureSelection() {
|
||||
guard let schema = self.store.configSchema else { return }
|
||||
let sections = self.resolveSections(schema)
|
||||
guard !sections.isEmpty else { return }
|
||||
|
||||
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
|
||||
if self.activeSectionKey != active.key {
|
||||
self.activeSectionKey = active.key
|
||||
}
|
||||
self.ensureSubsection(for: active)
|
||||
}
|
||||
|
||||
private func ensureSubsection(for section: ConfigSection) {
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
guard !subsections.isEmpty else {
|
||||
self.activeSubsection = nil
|
||||
return
|
||||
}
|
||||
|
||||
switch self.activeSubsection {
|
||||
case .all:
|
||||
return
|
||||
case let .key(key):
|
||||
if subsections.contains(where: { $0.key == key }) { return }
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectSection(_ section: ConfigSection) {
|
||||
guard self.activeSectionKey != section.key else { return }
|
||||
self.activeSectionKey = section.key
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
} else {
|
||||
self.activeSubsection = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
|
||||
let node = self.resolvedSchemaNode(root)
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSection(key: key, label: label, help: help, node: child)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
|
||||
let node = self.resolvedSchemaNode(section.node)
|
||||
guard node.schemaType == "object" else { return [] }
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(section.key), .key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSubsection(
|
||||
key: key,
|
||||
label: label,
|
||||
help: help,
|
||||
node: child,
|
||||
path: path)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
|
||||
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
|
||||
if !variants.isEmpty {
|
||||
let nonNull = variants.filter { !$0.isNullSchema }
|
||||
if nonNull.count == 1, let only = nonNull.first { return only }
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
private func humanize(_ key: String) -> String {
|
||||
key.replacingOccurrences(of: "_", with: " ")
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -6,20 +6,15 @@ final class ConnectionModeCoordinator {
|
||||
static let shared = ConnectionModeCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
|
||||
private var lastMode: AppState.ConnectionMode?
|
||||
|
||||
/// Apply the requested connection mode by starting/stopping local gateway,
|
||||
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
|
||||
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 {
|
||||
case .unconfigured:
|
||||
_ = await NodeServiceManager.stop()
|
||||
NodesStore.shared.lastError = nil
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
GatewayProcessManager.shared.stop()
|
||||
@@ -28,8 +23,9 @@ final class ConnectionModeCoordinator {
|
||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||
|
||||
case .local:
|
||||
_ = await NodeServiceManager.stop()
|
||||
NodesStore.shared.lastError = nil
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||
@@ -60,7 +56,6 @@ final class ConnectionModeCoordinator {
|
||||
WebChatManager.shared.resetTunnels()
|
||||
|
||||
do {
|
||||
NodesStore.shared.lastError = nil
|
||||
if let error = await NodeServiceManager.start() {
|
||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
|
||||
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
||||
let showDockIconKey = "clawdbot.showDockIcon"
|
||||
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||
let voiceWakeMaxWords = 32
|
||||
let voiceWakeMaxWordLength = 64
|
||||
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
||||
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
||||
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
||||
|
||||
@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
|
||||
let seq: Int
|
||||
let stream: String
|
||||
let ts: Double
|
||||
let data: [String: ClawdbotProtocol.AnyCodable]
|
||||
let data: [String: AnyCodable]
|
||||
let summary: String?
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ final class ControlChannel {
|
||||
}
|
||||
|
||||
private(set) var lastPingMs: Double?
|
||||
private(set) var authSourceLabel: String?
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||
|
||||
@@ -88,7 +87,15 @@ final class ControlChannel {
|
||||
|
||||
func configure() async {
|
||||
self.logger.info("control channel configure mode=local")
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
func configure(mode: Mode = .local) async throws {
|
||||
@@ -104,7 +111,7 @@ final class ControlChannel {
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
await self.configure()
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
throw error
|
||||
@@ -112,24 +119,10 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshEndpoint(reason: String) async {
|
||||
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.establishGatewayConnection()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
self.lastPingMs = nil
|
||||
self.authSourceLabel = nil
|
||||
}
|
||||
|
||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||
@@ -163,8 +156,8 @@ final class ControlChannel {
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) {
|
||||
$0[$1.key] = ClawdbotKit.AnyCodable($1.value.base)
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) {
|
||||
$0[$1.key] = AnyCodable($1.value.base)
|
||||
}
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: method,
|
||||
@@ -190,11 +183,8 @@ final class ControlChannel {
|
||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||
{
|
||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||
let tokenKey = CommandResolver.connectionModeIsRemote()
|
||||
? "gateway.remote.token"
|
||||
: "gateway.auth.token"
|
||||
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. " +
|
||||
"Reason: \(reason)"
|
||||
}
|
||||
@@ -285,49 +275,18 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||
if case .connected = self.state {
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.logger.info("control channel recovery finished")
|
||||
} else if case let .degraded(message) = self.state {
|
||||
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
self.recoveryTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||
if ok == false {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
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 {
|
||||
var merged = params
|
||||
merged["text"] = AnyHashable(text)
|
||||
@@ -387,7 +346,7 @@ final class ControlChannel {
|
||||
let phase = event.data["phase"]?.value as? String ?? ""
|
||||
let name = event.data["name"]?.value as? String
|
||||
let meta = event.data["meta"]?.value as? String
|
||||
let args = Self.bridgeToProtocolArgs(event.data["args"])
|
||||
let args = event.data["args"]?.value as? [String: AnyCodable]
|
||||
WorkActivityStore.shared.handleTool(
|
||||
sessionKey: sessionKey,
|
||||
phase: phase,
|
||||
@@ -398,27 +357,6 @@ final class ControlChannel {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static func bridgeToProtocolArgs(
|
||||
_ value: ClawdbotProtocol.AnyCodable?) -> [String: ClawdbotProtocol.AnyCodable]?
|
||||
{
|
||||
guard let value else { return nil }
|
||||
if let dict = value.value as? [String: ClawdbotProtocol.AnyCodable] {
|
||||
return dict
|
||||
}
|
||||
if let dict = value.value as? [String: ClawdbotKit.AnyCodable],
|
||||
let data = try? JSONEncoder().encode(dict),
|
||||
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||
{
|
||||
return decoded
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(value),
|
||||
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||
{
|
||||
return decoded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
@@ -16,8 +16,6 @@ struct DebugSettings: View {
|
||||
@State private var modelsError: String?
|
||||
private let gatewayManager = GatewayProcessManager.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 sessionStorePath: String = SessionLoader.defaultStorePath
|
||||
@State private var sessionStoreSaveError: String?
|
||||
@@ -49,7 +47,6 @@ struct DebugSettings: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
|
||||
self.launchdSection
|
||||
self.appInfoSection
|
||||
self.gatewaySection
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
@@ -522,22 +484,6 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(
|
||||
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button {
|
||||
LaunchdManager.startClawdbot()
|
||||
} label: {
|
||||
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Restart app") { DebugActions.restartApp() }
|
||||
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
||||
|
||||
@@ -84,52 +84,11 @@ enum ExecApprovalDecision: String, Codable, Sendable {
|
||||
case deny
|
||||
}
|
||||
|
||||
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
|
||||
var id: UUID
|
||||
struct ExecAllowlistEntry: Codable, Hashable {
|
||||
var pattern: String
|
||||
var lastUsedAt: Double?
|
||||
var lastUsedCommand: 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 {
|
||||
@@ -190,7 +149,6 @@ struct ExecApprovalsResolvedDefaults {
|
||||
|
||||
enum ExecApprovalsStore {
|
||||
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 defaultAsk: ExecAsk = .onMiss
|
||||
private static let defaultAskFallback: ExecSecurity = .deny
|
||||
@@ -207,22 +165,13 @@ enum ExecApprovalsStore {
|
||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||
let socketPath = file.socket?.path?.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(
|
||||
version: 1,
|
||||
socket: ExecApprovalsSocketConfig(
|
||||
path: socketPath.isEmpty ? nil : socketPath,
|
||||
token: token.isEmpty ? nil : token),
|
||||
defaults: file.defaults,
|
||||
agents: agents)
|
||||
agents: file.agents)
|
||||
}
|
||||
|
||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||
@@ -323,20 +272,18 @@ enum ExecApprovalsStore {
|
||||
ask: defaults.ask ?? self.defaultAsk,
|
||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||
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 wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback
|
||||
?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
|
||||
?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||
security: agentEntry.security ?? resolvedDefaults.security,
|
||||
ask: agentEntry.ask ?? resolvedDefaults.ask,
|
||||
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = (agentEntry.allowlist ?? [])
|
||||
.map { entry in
|
||||
ExecAllowlistEntry(
|
||||
id: entry.id,
|
||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
lastUsedAt: entry.lastUsedAt,
|
||||
lastUsedCommand: entry.lastUsedCommand,
|
||||
@@ -421,7 +368,6 @@ enum ExecApprovalsStore {
|
||||
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||
guard item.pattern == pattern else { return item }
|
||||
return ExecAllowlistEntry(
|
||||
id: item.id,
|
||||
pattern: item.pattern,
|
||||
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||
lastUsedCommand: command,
|
||||
@@ -441,7 +387,6 @@ enum ExecApprovalsStore {
|
||||
let cleaned = allowlist
|
||||
.map { item in
|
||||
ExecAllowlistEntry(
|
||||
id: item.id,
|
||||
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
lastUsedAt: item.lastUsedAt,
|
||||
lastUsedCommand: item.lastUsedCommand,
|
||||
@@ -509,40 +454,7 @@ enum ExecApprovalsStore {
|
||||
|
||||
private static func agentKey(_ agentId: String?) -> String {
|
||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? self.defaultAgentId : 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)
|
||||
return trimmed.isEmpty ? "default" : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,30 +553,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 {
|
||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||
guard let resolution, !entries.isEmpty else { return nil }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -45,7 +44,6 @@ final class ExecApprovalsGatewayPrompter {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||
guard self.shouldPresent(request: request) else { return }
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||
try await GatewayConnection.shared.requestVoid(
|
||||
method: .execApprovalResolve,
|
||||
@@ -58,66 +56,4 @@ final class ExecApprovalsGatewayPrompter {
|
||||
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 agentId: String?
|
||||
var resolvedPath: String?
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
private struct ExecApprovalSocketRequest: Codable {
|
||||
@@ -216,15 +215,36 @@ enum ExecApprovalsPromptPresenter {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
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: "Always 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() {
|
||||
case .alertFirstButtonReturn:
|
||||
@@ -235,128 +255,10 @@ enum ExecApprovalsPromptPresenter {
|
||||
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
|
||||
private enum ExecHostExecutor {
|
||||
private struct ExecApprovalContext {
|
||||
let command: [String]
|
||||
let displayCommand: String
|
||||
let trimmedAgent: String?
|
||||
let approvals: ExecApprovalsResolved
|
||||
let security: ExecSecurity
|
||||
let ask: ExecAsk
|
||||
let autoAllowSkills: Bool
|
||||
let env: [String: String]?
|
||||
let resolution: ExecCommandResolution?
|
||||
let allowlistMatch: ExecAllowlistEntry?
|
||||
let skillAllow: Bool
|
||||
}
|
||||
|
||||
private static let blockedEnvKeys: Set<String> = [
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
@@ -375,94 +277,14 @@ private enum ExecHostExecutor {
|
||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !command.isEmpty else {
|
||||
return self.errorResponse(
|
||||
code: "INVALID_REQUEST",
|
||||
message: "command required",
|
||||
reason: "invalid")
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
||||
}
|
||||
|
||||
let context = await self.buildContext(request: request, command: command)
|
||||
if context.security == .deny {
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||
reason: "security=deny")
|
||||
}
|
||||
|
||||
let approvalDecision = request.approvalDecision
|
||||
if approvalDecision == .deny {
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied")
|
||||
}
|
||||
|
||||
var approvedByAsk = approvalDecision != nil
|
||||
if ExecApprovalHelpers.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
skillAllow: context.skillAllow),
|
||||
approvalDecision == nil
|
||||
{
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: context.displayCommand,
|
||||
cwd: request.cwd,
|
||||
host: "node",
|
||||
security: context.security.rawValue,
|
||||
ask: context.ask.rawValue,
|
||||
agentId: context.trimmedAgent,
|
||||
resolvedPath: context.resolution?.resolvedPath,
|
||||
sessionKey: request.sessionKey))
|
||||
|
||||
switch decision {
|
||||
case .deny:
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied")
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
self.persistAllowlistEntry(decision: decision, context: context)
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
self.persistAllowlistEntry(decision: approvalDecision, context: context)
|
||||
|
||||
if context.security == .allowlist,
|
||||
context.allowlistMatch == nil,
|
||||
!context.skillAllow,
|
||||
!approvedByAsk
|
||||
{
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
||||
reason: "allowlist-miss")
|
||||
}
|
||||
|
||||
if let match = context.allowlistMatch {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: context.trimmedAgent,
|
||||
pattern: match.pattern,
|
||||
command: context.displayCommand,
|
||||
resolvedPath: context.resolution?.resolvedPath)
|
||||
}
|
||||
|
||||
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
|
||||
return errorResponse
|
||||
}
|
||||
|
||||
return await self.runCommand(
|
||||
command: command,
|
||||
cwd: request.cwd,
|
||||
env: context.env,
|
||||
timeoutMs: request.timeoutMs)
|
||||
}
|
||||
|
||||
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
|
||||
let displayCommand = ExecCommandFormatter.displayString(
|
||||
for: command,
|
||||
rawCommand: request.rawCommand)
|
||||
@@ -488,56 +310,122 @@ private enum ExecHostExecutor {
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
return ExecApprovalContext(
|
||||
command: command,
|
||||
displayCommand: displayCommand,
|
||||
trimmedAgent: trimmedAgent,
|
||||
approvals: approvals,
|
||||
security: security,
|
||||
ask: ask,
|
||||
autoAllowSkills: autoAllowSkills,
|
||||
env: env,
|
||||
resolution: resolution,
|
||||
allowlistMatch: allowlistMatch,
|
||||
skillAllow: skillAllow)
|
||||
}
|
||||
|
||||
private static func persistAllowlistEntry(
|
||||
decision: ExecApprovalDecision?,
|
||||
context: ExecApprovalContext)
|
||||
{
|
||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: context.resolution)
|
||||
else {
|
||||
return
|
||||
if security == .deny {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||
reason: "security=deny"))
|
||||
}
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||
}
|
||||
|
||||
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||
guard needsScreenRecording == true else { return nil }
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if authorized { return nil }
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "PERMISSION_MISSING: screenRecording",
|
||||
reason: "permission:screenRecording")
|
||||
}
|
||||
let requiresAsk: Bool = {
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}()
|
||||
|
||||
private static func runCommand(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
timeoutMs: Int?) async -> ExecHostResponse
|
||||
{
|
||||
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
let approvalDecision = request.approvalDecision
|
||||
if approvalDecision == .deny {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied"))
|
||||
}
|
||||
|
||||
var approvedByAsk = approvalDecision != nil
|
||||
if requiresAsk, approvalDecision == nil {
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: request.cwd,
|
||||
host: "node",
|
||||
security: security.rawValue,
|
||||
ask: ask.rawValue,
|
||||
agentId: trimmedAgent,
|
||||
resolvedPath: resolution?.resolvedPath))
|
||||
|
||||
switch decision {
|
||||
case .deny:
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied"))
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
if !pattern.isEmpty {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
||||
}
|
||||
}
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
if approvalDecision == .allowAlways, security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
if !pattern.isEmpty {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
||||
}
|
||||
}
|
||||
|
||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
||||
reason: "allowlist-miss"))
|
||||
}
|
||||
|
||||
if let match = allowlistMatch {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: trimmedAgent,
|
||||
pattern: match.pattern,
|
||||
command: displayCommand,
|
||||
resolvedPath: resolution?.resolvedPath)
|
||||
}
|
||||
|
||||
if request.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if !authorized {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "PERMISSION_MISSING: screenRecording",
|
||||
reason: "permission:screenRecording"))
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||
await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
cwd: request.cwd,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
}.value
|
||||
@@ -548,24 +436,7 @@ private enum ExecHostExecutor {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage)
|
||||
return self.successResponse(payload)
|
||||
}
|
||||
|
||||
private static func errorResponse(
|
||||
code: String,
|
||||
message: String,
|
||||
reason: String?) -> ExecHostResponse
|
||||
{
|
||||
ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: code, message: message, reason: reason))
|
||||
}
|
||||
|
||||
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
|
||||
ExecHostResponse(
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: true,
|
||||
|
||||
@@ -11,7 +11,6 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
case googlechat
|
||||
case slack
|
||||
case signal
|
||||
case imessage
|
||||
@@ -70,7 +69,6 @@ actor GatewayConnection {
|
||||
case channelsLogout = "channels.logout"
|
||||
case modelsList = "models.list"
|
||||
case chatHistory = "chat.history"
|
||||
case sessionsPreview = "sessions.preview"
|
||||
case chatSend = "chat.send"
|
||||
case chatAbort = "chat.abort"
|
||||
case skillsStatus = "skills.status"
|
||||
@@ -150,27 +148,6 @@ actor GatewayConnection {
|
||||
}
|
||||
}
|
||||
|
||||
let nsError = lastError as NSError
|
||||
if nsError.domain == URLError.errorDomain,
|
||||
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
|
||||
{
|
||||
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
|
||||
for delayMs in [150, 400, 900] {
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
do {
|
||||
guard let client = self.client else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||
}
|
||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
case .remote:
|
||||
let nsError = error as NSError
|
||||
@@ -251,11 +228,6 @@ actor GatewayConnection {
|
||||
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 {
|
||||
if let client {
|
||||
await client.shutdown()
|
||||
@@ -272,9 +244,9 @@ actor GatewayConnection {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String {
|
||||
let raw = defaults?[key]?.value as? String
|
||||
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
|
||||
(defaults?[key]?.stringValue ?? "")
|
||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func cachedMainSessionKey() -> String? {
|
||||
@@ -542,30 +514,6 @@ extension GatewayConnection {
|
||||
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
|
||||
|
||||
func chatHistory(
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayConnectivityCoordinator {
|
||||
static let shared = GatewayConnectivityCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
|
||||
private var endpointTask: Task<Void, Never>?
|
||||
private var lastResolvedURL: URL?
|
||||
|
||||
private(set) var endpointState: GatewayEndpointState?
|
||||
private(set) var resolvedURL: URL?
|
||||
private(set) var resolvedMode: AppState.ConnectionMode?
|
||||
private(set) var resolvedHostLabel: String?
|
||||
|
||||
private init() {
|
||||
self.start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.endpointTask == nil else { return }
|
||||
self.endpointTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayEndpointStore.shared.subscribe()
|
||||
for await state in stream {
|
||||
await MainActor.run { self.handleEndpointState(state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var localEndpointHostLabel: String? {
|
||||
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
|
||||
return Self.hostLabel(for: url)
|
||||
}
|
||||
|
||||
private func handleEndpointState(_ state: GatewayEndpointState) {
|
||||
self.endpointState = state
|
||||
switch state {
|
||||
case let .ready(mode, url, _, _):
|
||||
self.resolvedMode = mode
|
||||
self.resolvedURL = url
|
||||
self.resolvedHostLabel = Self.hostLabel(for: url)
|
||||
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
|
||||
if urlChanged {
|
||||
self.lastResolvedURL = url
|
||||
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
|
||||
}
|
||||
case let .connecting(mode, _):
|
||||
self.resolvedMode = mode
|
||||
case let .unavailable(mode, _):
|
||||
self.resolvedMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
private static func hostLabel(for url: URL) -> String {
|
||||
let host = url.host ?? url.absoluteString
|
||||
if let port = url.port { return "\(host):\(port)" }
|
||||
return host
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
var discovery: GatewayDiscoveryModel
|
||||
var currentTarget: String?
|
||||
var currentUrl: String?
|
||||
var transport: AppState.RemoteTransport
|
||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||
|
||||
@@ -27,8 +25,9 @@ struct GatewayDiscoveryInlineList: View {
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||
let display = self.displayInfo(for: gateway)
|
||||
let selected = display.selected
|
||||
let target = self.suggestedSSHTarget(gateway)
|
||||
let selected = (target != nil && self.currentTarget?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
@@ -41,7 +40,7 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text(display.label)
|
||||
Text(target ?? "Gateway pairing only")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -84,26 +83,27 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
}
|
||||
.help(self.transport == .direct
|
||||
? "Click a discovered gateway to fill the gateway URL."
|
||||
: "Click a discovered gateway to fill the SSH target.")
|
||||
.help("Click a discovered gateway to fill the SSH target.")
|
||||
}
|
||||
|
||||
private func displayInfo(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
|
||||
{
|
||||
switch self.transport {
|
||||
case .direct:
|
||||
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
|
||||
let label = url ?? "Gateway pairing only"
|
||||
let selected = url != nil && self.trimmed(self.currentUrl) == url
|
||||
return (label, selected)
|
||||
case .ssh:
|
||||
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
|
||||
let label = target ?? "Gateway pairing only"
|
||||
let selected = target != nil && self.trimmed(self.currentTarget) == target
|
||||
return (label, selected)
|
||||
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||
guard let host else { return nil }
|
||||
let user = NSUserName()
|
||||
return GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
}
|
||||
|
||||
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host else { return nil }
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||
@@ -111,10 +111,6 @@ struct GatewayDiscoveryInlineList: View {
|
||||
if hovered { return Color.secondary.opacity(0.08) }
|
||||
return Color.clear
|
||||
}
|
||||
|
||||
private func trimmed(_ value: String?) -> String {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayDiscoveryMenu: View {
|
||||
|
||||
@@ -68,7 +68,6 @@ actor GatewayEndpointStore {
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
@@ -166,23 +165,19 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
|
||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||
!configToken.isEmpty
|
||||
{
|
||||
return configToken
|
||||
}
|
||||
|
||||
if isRemote {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -311,19 +306,6 @@ actor GatewayEndpointStore {
|
||||
token: token,
|
||||
password: password))
|
||||
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()
|
||||
guard let port else {
|
||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||
@@ -354,25 +336,6 @@ actor GatewayEndpointStore {
|
||||
code: 1,
|
||||
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)
|
||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||
throw NSError(
|
||||
@@ -433,21 +396,6 @@ actor GatewayEndpointStore {
|
||||
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)
|
||||
guard let ensure = self.remoteEnsure else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||
@@ -521,36 +469,6 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
}
|
||||
|
||||
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .local else { return nil }
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
guard bind == "tailnet" else { return nil }
|
||||
|
||||
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||
|
||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let port = self.deps.localPort()
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
|
||||
|
||||
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
|
||||
self.setState(.ready(mode: .local, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
}
|
||||
|
||||
private static func resolveGatewayBindMode(
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
@@ -606,10 +524,8 @@ actor GatewayEndpointStore {
|
||||
tailscaleIP: String?) -> String
|
||||
{
|
||||
switch bindMode {
|
||||
case "tailnet":
|
||||
case "tailnet", "auto":
|
||||
tailscaleIP ?? "127.0.0.1"
|
||||
case "auto":
|
||||
"127.0.0.1"
|
||||
case "custom":
|
||||
customBindHost ?? "127.0.0.1"
|
||||
default:
|
||||
@@ -684,12 +600,11 @@ extension GatewayEndpointStore {
|
||||
|
||||
static func _testResolveLocalGatewayHost(
|
||||
bindMode: String?,
|
||||
tailscaleIP: String?,
|
||||
customBindHost: String? = nil) -> String
|
||||
tailscaleIP: String?) -> String
|
||||
{
|
||||
self.resolveLocalGatewayHost(
|
||||
bindMode: bindMode,
|
||||
customBindHost: customBindHost,
|
||||
customBindHost: nil,
|
||||
tailscaleIP: tailscaleIP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,46 +4,11 @@ enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||
|
||||
private static var disableLaunchAgentMarkerURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
}
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.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 {
|
||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||
return loaded
|
||||
@@ -101,6 +66,12 @@ enum 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? {
|
||||
let result = await self.runDaemonCommandResult(
|
||||
["status", "--json", "--no-probe"],
|
||||
@@ -144,7 +115,7 @@ extension GatewayLaunchAgentManager {
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "gateway",
|
||||
subcommand: "daemon",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Launchd management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
|
||||
@@ -42,20 +42,10 @@ final class GatewayProcessManager {
|
||||
private var environmentRefreshTask: Task<Void, Never>?
|
||||
private var lastEnvironmentRefresh: Date?
|
||||
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 logLimit = 20000 // characters to keep in-memory
|
||||
private let environmentRefreshMinInterval: TimeInterval = 30
|
||||
private var connection: GatewayConnection {
|
||||
#if DEBUG
|
||||
return self.testingConnection ?? .shared
|
||||
#else
|
||||
return .shared
|
||||
#endif
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
// Remote mode should never spawn a local gateway; treat as stopped.
|
||||
@@ -79,11 +69,6 @@ final class GatewayProcessManager {
|
||||
|
||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||
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()
|
||||
guard !enabled else { return }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
@@ -141,10 +126,6 @@ final class GatewayProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
func clearLastFailure() {
|
||||
self.lastFailureReason = nil
|
||||
}
|
||||
|
||||
func refreshEnvironmentStatus(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force {
|
||||
@@ -197,7 +178,7 @@ final class GatewayProcessManager {
|
||||
let hasListener = instance != nil
|
||||
|
||||
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) {
|
||||
@@ -206,7 +187,6 @@ final class GatewayProcessManager {
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||
self.existingGatewayDetails = details
|
||||
self.clearLastFailure()
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
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 {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||
let linkId = order.first(where: { snap.channels[$0]?.linked == true })
|
||||
?? order.first(where: { snap.channels[$0]?.linked != nil })
|
||||
guard let linkId else {
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
let linked = snap.channels[linkId]?.linked ?? false
|
||||
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
let linkId = snap.channelOrder?.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.channels.keys.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
snap.channelLabels?[linkId] ??
|
||||
linkId.capitalized
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"channel"
|
||||
let linkText = linked ? "linked" : "not linked"
|
||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
@@ -311,15 +293,6 @@ final class GatewayProcessManager {
|
||||
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 port = GatewayEnvironment.gatewayPort()
|
||||
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||
@@ -337,10 +310,9 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return }
|
||||
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 details = instance.map { "pid \($0.pid)" }
|
||||
self.clearLastFailure()
|
||||
self.status = .running(details: details)
|
||||
self.logger.info("gateway started details=\(details ?? "ok")")
|
||||
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||
@@ -380,8 +352,7 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return false }
|
||||
do {
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
self.clearLastFailure()
|
||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||
return true
|
||||
} catch {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
@@ -414,19 +385,3 @@ final class GatewayProcessManager {
|
||||
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,26 +2,52 @@ import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettings: View {
|
||||
@Bindable var state: AppState
|
||||
@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 gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||
localDisplayName: InstanceIdentity.displayName)
|
||||
@State private var isInstallingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var cliInstalled = false
|
||||
@State private var cliInstallLocation: String?
|
||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||
@State private var remoteStatus: RemoteStatus = .idle
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
private var remoteLabelWidth: CGFloat { 88 }
|
||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !self.state.onboardingSeen {
|
||||
Button {
|
||||
DebugActions.restartOnboarding()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Label("Complete onboarding to finish setup", systemImage: "arrow.counterclockwise")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SettingsToggleRow(
|
||||
title: "Clawdbot active",
|
||||
@@ -57,6 +83,29 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
SystemRunSettingsView()
|
||||
|
||||
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(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||
@@ -81,13 +130,29 @@ struct GeneralSettings: View {
|
||||
}
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
self.refreshCLIStatus()
|
||||
self.refreshGatewayStatus()
|
||||
self.lastLocationModeRaw = self.locationModeRaw
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
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> {
|
||||
@@ -96,20 +161,39 @@ struct GeneralSettings: View {
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Clawdbot runs")
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Picker("Mode", selection: self.$state.connectionMode) {
|
||||
Picker("", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
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)
|
||||
.labelsHidden()
|
||||
.frame(width: 260, alignment: .leading)
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 380, alignment: .leading)
|
||||
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
Text("Pick Local or Remote to start the Gateway.")
|
||||
@@ -132,56 +216,67 @@ struct GeneralSettings: View {
|
||||
if self.state.connectionMode == .remote {
|
||||
self.remoteCard
|
||||
}
|
||||
|
||||
self.cliInstaller
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.remoteTransportRow
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
self.remoteSshRow
|
||||
} else {
|
||||
self.remoteDirectRow
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: 48, 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)
|
||||
}
|
||||
|
||||
GatewayDiscoveryInlineList(
|
||||
discovery: self.gatewayDiscovery,
|
||||
currentTarget: self.state.remoteTarget,
|
||||
currentUrl: self.state.remoteUrl,
|
||||
transport: self.state.remoteTransport)
|
||||
currentTarget: self.state.remoteTarget)
|
||||
{ gateway in
|
||||
self.applyDiscoveredGateway(gateway)
|
||||
}
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.leading, 58)
|
||||
|
||||
self.remoteStatusView
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.leading, 58)
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
@@ -204,96 +299,18 @@ struct GeneralSettings: View {
|
||||
.font(.caption)
|
||||
.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.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity)
|
||||
.onAppear { self.gatewayDiscovery.start() }
|
||||
.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 {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: "Connected"
|
||||
@@ -329,6 +346,59 @@ struct GeneralSettings: View {
|
||||
return message == self.controlStatusLine
|
||||
}
|
||||
|
||||
private var cliInstaller: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.isInstallingCLI ? 0 : 1)
|
||||
if self.isInstallingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 150)
|
||||
}
|
||||
.disabled(self.isInstallingCLI)
|
||||
|
||||
if self.isInstallingCLI {
|
||||
Text("Working...")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.cliInstalled {
|
||||
Label("Installed", systemImage: "checkmark.circle.fill")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Not installed")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let status = cliStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if let installLocation = self.cliInstallLocation {
|
||||
Text("Found at \(installLocation)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayInstallerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
@@ -384,6 +454,22 @@ struct GeneralSettings: View {
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
guard !self.isInstallingCLI else { return }
|
||||
self.isInstallingCLI = true
|
||||
defer { isInstallingCLI = false }
|
||||
await CLIInstaller.install { status in
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshCLIStatus() {
|
||||
let installLocation = CLIInstaller.installedLocation()
|
||||
self.cliInstallLocation = installLocation
|
||||
self.cliInstalled = installLocation != nil
|
||||
}
|
||||
|
||||
private func refreshGatewayStatus() {
|
||||
Task {
|
||||
let status = await Task.detached(priority: .utility) {
|
||||
@@ -523,36 +609,24 @@ extension GeneralSettings {
|
||||
func testRemote() async {
|
||||
self.remoteStatus = .checking
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
if self.state.remoteTransport == .direct {
|
||||
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedUrl.isEmpty else {
|
||||
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
|
||||
}
|
||||
guard !settings.target.isEmpty else {
|
||||
self.remoteStatus = .failed("Set an SSH target first")
|
||||
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
|
||||
do {
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
@@ -579,14 +653,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] {
|
||||
var args: [String] = [
|
||||
"/usr/bin/ssh",
|
||||
@@ -655,18 +721,12 @@ extension GeneralSettings {
|
||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||
guard let host else { return }
|
||||
let user = NSUserName()
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
} else {
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,9 +749,7 @@ extension GeneralSettings {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .remote
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@host:2222"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteIdentity = "/tmp/id_ed25519"
|
||||
state.remoteProjectRoot = "/tmp/clawdbot"
|
||||
state.remoteCliPath = "/tmp/clawdbot"
|
||||
@@ -705,6 +763,9 @@ extension GeneralSettings {
|
||||
message: "Gateway ready")
|
||||
view.remoteStatus = .failed("SSH failed")
|
||||
view.showRemoteAdvanced = true
|
||||
view.cliInstalled = true
|
||||
view.cliInstallLocation = "/usr/local/bin/clawdbot"
|
||||
view.cliStatus = "Installed"
|
||||
_ = view.body
|
||||
|
||||
state.connectionMode = .unconfigured
|
||||
|
||||
@@ -166,11 +166,6 @@ final class HealthStore {
|
||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||
{
|
||||
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 {
|
||||
if let summary = snap.channels[id], summary.linked != nil {
|
||||
return (id: id, summary: summary)
|
||||
@@ -240,8 +235,8 @@ final class HealthStore {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
||||
"restart Clawdbot to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||
|
||||
@@ -3,7 +3,6 @@ import Darwin
|
||||
import Foundation
|
||||
import MenuBarExtraAccess
|
||||
import Observation
|
||||
import OSLog
|
||||
import Security
|
||||
import SwiftUI
|
||||
|
||||
@@ -11,11 +10,9 @@ import SwiftUI
|
||||
struct ClawdbotApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@State private var state: AppState
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "app")
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
@@ -33,7 +30,6 @@ struct ClawdbotApp: App {
|
||||
|
||||
init() {
|
||||
ClawdbotLogging.bootstrapIfNeeded()
|
||||
Self.applyAttachOnlyOverrideIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
}
|
||||
|
||||
@@ -94,22 +90,6 @@ struct ClawdbotApp: App {
|
||||
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 {
|
||||
if self.state.isPaused { return false }
|
||||
switch self.state.connectionMode {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@@ -20,7 +18,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private var isMenuOpen = false
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private var menuOpenWidth: CGFloat?
|
||||
private var isObservingControlChannel = false
|
||||
|
||||
private var cachedSnapshot: SessionStoreSnapshot?
|
||||
private var cachedErrorText: String?
|
||||
@@ -53,7 +50,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||
}
|
||||
|
||||
self.startControlChannelObservation()
|
||||
self.nodesStore.start()
|
||||
}
|
||||
|
||||
@@ -100,50 +96,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
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) {
|
||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
||||
}
|
||||
@@ -189,23 +141,14 @@ extension MenuSessionsInjector {
|
||||
if rhs.key == mainKey { return false }
|
||||
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()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let statusText = self
|
||||
.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: rows.count,
|
||||
statusText: statusText)),
|
||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
@@ -518,29 +461,15 @@ extension MenuSessionsInjector {
|
||||
switch mode {
|
||||
case .remote:
|
||||
platform = "remote"
|
||||
if AppStateStore.shared.remoteTransport == .direct {
|
||||
let trimmedUrl = AppStateStore.shared.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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
|
||||
}
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
}
|
||||
host = target.nonEmpty
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
host = "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
@@ -669,11 +598,8 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
if self.cachedSnapshot != nil {
|
||||
self.cachedErrorText = "Gateway disconnected (showing cached)"
|
||||
} else {
|
||||
self.cachedErrorText = nil
|
||||
}
|
||||
self.cachedSnapshot = nil
|
||||
self.cachedErrorText = nil
|
||||
self.cacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
@@ -698,6 +624,8 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedUsageSummary = nil
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
@@ -720,6 +648,8 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = nil
|
||||
self.costCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,28 +2,14 @@ import Foundation
|
||||
import JavaScriptCore
|
||||
|
||||
enum ModelCatalogLoader {
|
||||
static var defaultPath: String { self.resolveDefaultPath() }
|
||||
static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
||||
private nonisolated static let appSupportDir: URL = {
|
||||
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||
}()
|
||||
|
||||
private static var cachePath: URL {
|
||||
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
|
||||
}
|
||||
|
||||
static func load(from path: String) async throws -> [ModelChoice] {
|
||||
let expanded = (path as NSString).expandingTildeInPath
|
||||
guard let resolved = self.resolvePath(preferred: expanded) else {
|
||||
self.logger.error("model catalog load failed: file not found")
|
||||
throw NSError(
|
||||
domain: "ModelCatalogLoader",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
|
||||
}
|
||||
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
|
||||
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
|
||||
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
|
||||
let source = try String(contentsOfFile: expanded, encoding: .utf8)
|
||||
let sanitized = self.sanitize(source: source)
|
||||
|
||||
let ctx = JSContext()
|
||||
@@ -59,82 +45,9 @@ enum ModelCatalogLoader {
|
||||
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
||||
}
|
||||
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
||||
if resolved.shouldCache {
|
||||
self.cacheCatalog(sourcePath: resolved.path)
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
private static func resolveDefaultPath() -> String {
|
||||
let cache = self.cachePath.path
|
||||
if FileManager().isReadableFile(atPath: cache) { return cache }
|
||||
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
|
||||
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
|
||||
return cache
|
||||
}
|
||||
|
||||
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
|
||||
if FileManager().isReadableFile(atPath: preferred) {
|
||||
return (preferred, preferred != self.cachePath.path)
|
||||
}
|
||||
|
||||
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
|
||||
self.logger.warning("model catalog path missing; falling back to bundled catalog")
|
||||
return (bundlePath, true)
|
||||
}
|
||||
|
||||
let cache = self.cachePath.path
|
||||
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
|
||||
self.logger.warning("model catalog path missing; falling back to cached catalog")
|
||||
return (cache, false)
|
||||
}
|
||||
|
||||
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
|
||||
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
|
||||
return (nodePath, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func bundleCatalogPath() -> String? {
|
||||
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
|
||||
return nil
|
||||
}
|
||||
return url.path
|
||||
}
|
||||
|
||||
private static func nodeModulesCatalogPath() -> String? {
|
||||
let roots = [
|
||||
URL(fileURLWithPath: CommandResolver.projectRootPath()),
|
||||
URL(fileURLWithPath: FileManager().currentDirectoryPath),
|
||||
]
|
||||
for root in roots {
|
||||
let candidate = root
|
||||
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
|
||||
if FileManager().isReadableFile(atPath: candidate.path) {
|
||||
return candidate.path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func cacheCatalog(sourcePath: String) {
|
||||
let destination = self.cachePath
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: destination.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if FileManager().fileExists(atPath: destination.path) {
|
||||
try FileManager().removeItem(at: destination)
|
||||
}
|
||||
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
|
||||
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
|
||||
} catch {
|
||||
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func sanitize(source: String) -> String {
|
||||
guard let exportRange = source.range(of: "export const MODELS"),
|
||||
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
||||
|
||||
@@ -480,26 +480,26 @@ actor MacNodeRuntime {
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||
}
|
||||
|
||||
let approval = await self.resolveSystemRunApproval(
|
||||
req: req,
|
||||
params: params,
|
||||
context: ExecRunContext(
|
||||
displayCommand: displayCommand,
|
||||
security: security,
|
||||
ask: ask,
|
||||
agentId: agentId,
|
||||
resolution: resolution,
|
||||
allowlistMatch: allowlistMatch,
|
||||
skillAllow: skillAllow,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId))
|
||||
if let response = approval.response { return response }
|
||||
let approvedByAsk = approval.approvedByAsk
|
||||
let persistAllowlist = approval.persistAllowlist
|
||||
if persistAllowlist, security == .allowlist,
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
||||
{
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
let requiresAsk: Bool = {
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}()
|
||||
|
||||
let approvedByAsk = params.approved == true
|
||||
if requiresAsk, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
|
||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||
@@ -619,100 +619,6 @@ actor MacNodeRuntime {
|
||||
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 {
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
|
||||
@@ -25,11 +25,7 @@ extension OnboardingView {
|
||||
self.preferredGatewayID = gateway.stableID
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
let user = NSUserName()
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
|
||||
@@ -177,67 +177,42 @@ extension OnboardingView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Text("Transport")
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, 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(width: fieldWidth)
|
||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
if self.state.remoteTransport == .direct {
|
||||
GridRow {
|
||||
Text("Gateway URL")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
if self.state.remoteTransport == .ssh {
|
||||
GridRow {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.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)
|
||||
}
|
||||
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
|
||||
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
|
||||
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -250,10 +225,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if self.state.remoteTransport == .direct {
|
||||
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
||||
}
|
||||
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||
return "\(host)\(portSuffix)"
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
|
||||
struct OnboardingWizardStepView: View {
|
||||
let step: WizardStep
|
||||
let isSubmitting: Bool
|
||||
let onStepSubmit: (AnyCodable?) -> Void
|
||||
let onSubmit: (AnyCodable?) -> Void
|
||||
|
||||
@State private var textValue: String
|
||||
@State private var confirmValue: Bool
|
||||
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
|
||||
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
|
||||
self.step = step
|
||||
self.isSubmitting = isSubmitting
|
||||
self.onStepSubmit = onSubmit
|
||||
self.onSubmit = onSubmit
|
||||
let options = parseWizardOptions(step.options).enumerated().map { index, option in
|
||||
WizardOptionItem(index: index, option: option)
|
||||
}
|
||||
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
|
||||
private func submit() {
|
||||
switch wizardStepType(self.step) {
|
||||
case "note", "progress":
|
||||
self.onStepSubmit(nil)
|
||||
self.onSubmit(nil)
|
||||
case "text":
|
||||
self.onStepSubmit(AnyCodable(self.textValue))
|
||||
self.onSubmit(AnyCodable(self.textValue))
|
||||
case "confirm":
|
||||
self.onStepSubmit(AnyCodable(self.confirmValue))
|
||||
self.onSubmit(AnyCodable(self.confirmValue))
|
||||
case "select":
|
||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||
self.onStepSubmit(nil)
|
||||
self.onSubmit(nil)
|
||||
return
|
||||
}
|
||||
let option = self.optionItems[self.selectedIndex].option
|
||||
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||
self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||
case "multiselect":
|
||||
let values = self.optionItems
|
||||
.filter { self.selectedIndices.contains($0.index) }
|
||||
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
||||
self.onStepSubmit(AnyCodable(values))
|
||||
self.onSubmit(AnyCodable(values))
|
||||
case "action":
|
||||
self.onStepSubmit(AnyCodable(true))
|
||||
self.onSubmit(AnyCodable(true))
|
||||
default:
|
||||
self.onStepSubmit(nil)
|
||||
self.onSubmit(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import SwiftUI
|
||||
|
||||
struct PermissionsSettings: View {
|
||||
@@ -10,8 +8,6 @@ struct PermissionsSettings: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
SystemRunSettingsView()
|
||||
|
||||
Text("Allow these so Clawdbot can notify and capture when needed.")
|
||||
.padding(.top, 4)
|
||||
|
||||
@@ -19,8 +15,6 @@ struct PermissionsSettings: View {
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
LocationAccessSettings()
|
||||
|
||||
Button("Restart onboarding") { self.showOnboarding() }
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
@@ -30,72 +24,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 {
|
||||
let status: [Capability: Bool]
|
||||
let refresh: () async -> Void
|
||||
@@ -117,6 +45,25 @@ struct PermissionStatusList: View {
|
||||
.font(.footnote)
|
||||
.padding(.top, 2)
|
||||
.help("Refresh status")
|
||||
|
||||
if (self.status[.accessibility] ?? false) == false || (self.status[.screenRecording] ?? false) == false {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(
|
||||
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button {
|
||||
LaunchdManager.startClawdbot()
|
||||
} label: {
|
||||
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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] {
|
||||
let res = await ShellExecutor.run(
|
||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||
|
||||
@@ -72,6 +72,7 @@ final class RemotePortTunnel {
|
||||
}
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
@@ -83,12 +84,7 @@ final class RemotePortTunnel {
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identity.isEmpty {
|
||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
}
|
||||
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
|
||||
@@ -20,13 +20,11 @@ actor RemoteTunnelManager {
|
||||
tunnel.process.isRunning,
|
||||
let local = tunnel.localPort
|
||||
{
|
||||
let pid = tunnel.process.processIdentifier
|
||||
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
||||
if await self.isTunnelHealthy(port: local) {
|
||||
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||
return local
|
||||
}
|
||||
self.logger.error(
|
||||
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
|
||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||
await self.beginRestart()
|
||||
tunnel.terminate()
|
||||
self.controlTunnel = nil
|
||||
@@ -37,11 +35,19 @@ actor RemoteTunnelManager {
|
||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||
self.isSshProcess(desc)
|
||||
{
|
||||
self.logger.info(
|
||||
"reusing existing SSH tunnel listener " +
|
||||
"localPort=\(desiredPort, privacy: .public) " +
|
||||
"pid=\(desc.pid, privacy: .public)")
|
||||
return desiredPort
|
||||
if await self.isTunnelHealthy(port: desiredPort) {
|
||||
self.logger.info(
|
||||
"reusing existing SSH tunnel listener " +
|
||||
"localPort=\(desiredPort, privacy: .public) " +
|
||||
"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
|
||||
}
|
||||
@@ -82,6 +88,10 @@ actor RemoteTunnelManager {
|
||||
self.controlTunnel = nil
|
||||
}
|
||||
|
||||
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
||||
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
||||
}
|
||||
|
||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||
let cmd = desc.command.lowercased()
|
||||
if cmd.contains("ssh") { return true }
|
||||
@@ -118,5 +128,21 @@ actor RemoteTunnelManager {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.24</string>
|
||||
<string>2026.1.11-4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601240</string>
|
||||
<string>202601113</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
@@ -32,80 +31,31 @@ actor SessionPreviewCache {
|
||||
static let shared = SessionPreviewCache()
|
||||
|
||||
private struct CacheEntry {
|
||||
let snapshot: SessionMenuPreviewSnapshot
|
||||
let items: [SessionPreviewItem]
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
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 Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
||||
return entry.snapshot
|
||||
return entry.items
|
||||
}
|
||||
|
||||
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
|
||||
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
|
||||
func store(items: [SessionPreviewItem], for sessionKey: String) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
|
||||
}
|
||||
|
||||
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
|
||||
self.entries[sessionKey]?.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
|
||||
self.entries[sessionKey]?.items
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension SessionPreviewCache {
|
||||
func _testSet(
|
||||
snapshot: SessionMenuPreviewSnapshot,
|
||||
for sessionKey: String,
|
||||
updatedAt: Date = Date())
|
||||
{
|
||||
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
|
||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||
}
|
||||
|
||||
func _testReset() {
|
||||
@@ -224,44 +174,36 @@ enum SessionMenuPreviewLoader {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||
private static let previewMaxChars = 240
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
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 {
|
||||
if let cached = await SessionPreviewCache.shared.cachedSnapshot(
|
||||
for: sessionKey,
|
||||
maxAge: cacheMaxAgeSeconds)
|
||||
{
|
||||
return cached
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||
return self.snapshot(from: cached)
|
||||
}
|
||||
|
||||
do {
|
||||
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
|
||||
return snapshot
|
||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||
let payload = 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)
|
||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||
return Self.snapshot(from: built)
|
||||
} catch is CancellationError {
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||
} catch {
|
||||
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
|
||||
return fallback
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||
if let fallback {
|
||||
return Self.snapshot(from: fallback)
|
||||
}
|
||||
let errorDescription = String(describing: error)
|
||||
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 {
|
||||
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 {
|
||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||
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())
|
||||
min(max(maxItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
private static func previewItems(
|
||||
from payload: ClawdbotChatHistoryPayload,
|
||||
maxItems: Int) -> [SessionPreviewItem]
|
||||
{
|
||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
||||
let messages = self.decodeMessages(raw)
|
||||
let built = messages.compactMap { message -> SessionPreviewItem? in
|
||||
@@ -395,7 +235,7 @@ enum SessionMenuPreviewLoader {
|
||||
return SessionPreviewItem(id: id, role: role, text: text)
|
||||
}
|
||||
|
||||
let trimmed = built.suffix(boundedItems)
|
||||
let trimmed = built.suffix(maxItems)
|
||||
return Array(trimmed.reversed())
|
||||
}
|
||||
|
||||
@@ -408,16 +248,12 @@ enum SessionMenuPreviewLoader {
|
||||
|
||||
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
||||
if isTool { return .tool }
|
||||
return self.previewRoleFromRaw(raw)
|
||||
}
|
||||
|
||||
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
|
||||
switch raw.lowercased() {
|
||||
case "user": .user
|
||||
case "assistant": .assistant
|
||||
case "system": .system
|
||||
case "tool": .tool
|
||||
default: .other
|
||||
case "user": return .user
|
||||
case "assistant": return .assistant
|
||||
case "system": return .system
|
||||
case "tool": return .tool
|
||||
default: return .other
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,16 +316,4 @@ enum SessionMenuPreviewLoader {
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.model.entries, id: \.id) { entry in
|
||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||
ExecAllowlistRow(
|
||||
entry: Binding(
|
||||
get: { self.model.entry(for: entry.id) ?? entry },
|
||||
set: { self.model.updateEntry($0, id: entry.id) }),
|
||||
onRemove: { self.model.removeEntry(id: entry.id) })
|
||||
get: { self.model.entries[index] },
|
||||
set: { self.model.updateEntry($0, at: index) }),
|
||||
onRemove: { self.model.removeEntry(at: index) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,24 +373,20 @@ final class ExecApprovalsSettingsModel {
|
||||
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 let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
|
||||
guard self.entries.indices.contains(index) else { return }
|
||||
self.entries[index] = entry
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
}
|
||||
|
||||
func removeEntry(id: UUID) {
|
||||
func removeEntry(at index: Int) {
|
||||
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)
|
||||
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 {
|
||||
guard self.autoAllowSkills else {
|
||||
self.skillBins = []
|
||||
|
||||
@@ -2,9 +2,6 @@ import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
/// Manages Tailscale integration and status checking.
|
||||
@Observable
|
||||
@@ -103,14 +100,16 @@ final class TailscaleService {
|
||||
}
|
||||
|
||||
func checkTailscaleStatus() async {
|
||||
let previousIP = self.tailscaleIP
|
||||
self.isInstalled = self.checkAppInstallation()
|
||||
if !self.isInstalled {
|
||||
guard self.isInstalled else {
|
||||
self.isRunning = false
|
||||
self.tailscaleHostname = nil
|
||||
self.tailscaleIP = nil
|
||||
self.statusError = "Tailscale is not installed"
|
||||
} else if let apiResponse = await fetchTailscaleStatus() {
|
||||
return
|
||||
}
|
||||
|
||||
if let apiResponse = await fetchTailscaleStatus() {
|
||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
||||
|
||||
if self.isRunning {
|
||||
@@ -139,19 +138,6 @@ final class TailscaleService {
|
||||
self.statusError = "Please start the Tailscale app"
|
||||
self.logger.info("Tailscale API not responding; app likely not running")
|
||||
}
|
||||
|
||||
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
|
||||
self.tailscaleIP = fallback
|
||||
if !self.isRunning {
|
||||
self.isRunning = true
|
||||
}
|
||||
self.statusError = nil
|
||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||
}
|
||||
|
||||
if previousIP != self.tailscaleIP {
|
||||
await GatewayEndpointStore.shared.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func openTailscaleApp() {
|
||||
@@ -177,50 +163,4 @@ final class TailscaleService {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
private nonisolated static func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if Self.isTailnetIPv4(ip) { return ip }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||
self.detectTailnetIPv4()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
|
||||
let cleaned = words
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.prefix(voiceWakeMaxWords)
|
||||
.map { String($0.prefix(voiceWakeMaxWordLength)) }
|
||||
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ struct VoiceWakeSettings: View {
|
||||
@State private var micObserver = AudioInputDeviceObserver()
|
||||
@State private var micRefreshTask: Task<Void, Never>?
|
||||
@State private var availableLocales: [Locale] = []
|
||||
@State private var triggerEntries: [TriggerEntry] = []
|
||||
private let fieldLabelWidth: CGFloat = 140
|
||||
private let controlWidth: CGFloat = 240
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
@@ -32,9 +31,9 @@ struct VoiceWakeSettings: View {
|
||||
var id: String { self.uid }
|
||||
}
|
||||
|
||||
private struct TriggerEntry: Identifiable {
|
||||
let id: UUID
|
||||
var value: String
|
||||
private struct IndexedWord: Identifiable {
|
||||
let id: Int
|
||||
let value: String
|
||||
}
|
||||
|
||||
private var voiceWakeBinding: Binding<Bool> {
|
||||
@@ -106,7 +105,6 @@ struct VoiceWakeSettings: View {
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
self.startMicObserver()
|
||||
self.loadTriggerEntries()
|
||||
}
|
||||
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
||||
guard !self.isPreview else { return }
|
||||
@@ -124,10 +122,8 @@ struct VoiceWakeSettings: View {
|
||||
self.micRefreshTask = nil
|
||||
Task { await self.meter.stop() }
|
||||
self.micObserver.stop()
|
||||
self.syncTriggerEntriesToState()
|
||||
} else {
|
||||
self.startMicObserver()
|
||||
self.loadTriggerEntries()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
@@ -140,16 +136,11 @@ struct VoiceWakeSettings: View {
|
||||
self.micRefreshTask = nil
|
||||
self.micObserver.stop()
|
||||
Task { await self.meter.stop() }
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTriggerEntries() {
|
||||
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
|
||||
}
|
||||
|
||||
private func syncTriggerEntriesToState() {
|
||||
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
|
||||
private var indexedWords: [IndexedWord] {
|
||||
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
|
||||
}
|
||||
|
||||
private var triggerTable: some View {
|
||||
@@ -163,42 +154,29 @@ struct VoiceWakeSettings: View {
|
||||
} label: {
|
||||
Label("Add word", systemImage: "plus")
|
||||
}
|
||||
.disabled(self.triggerEntries
|
||||
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||
.disabled(self.state.swabbleTriggerWords
|
||||
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||
|
||||
Button("Reset defaults") {
|
||||
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(self.$triggerEntries) { $entry in
|
||||
HStack(spacing: 8) {
|
||||
TextField("Wake word", text: $entry.value)
|
||||
.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()
|
||||
}
|
||||
Table(self.indexedWords) {
|
||||
TableColumn("Word") { row in
|
||||
TextField("Wake word", text: self.binding(for: row.id))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
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)
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
.frame(minHeight: 180)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
@@ -233,12 +211,24 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
|
||||
private func addWord() {
|
||||
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
|
||||
self.state.swabbleTriggerWords.append("")
|
||||
}
|
||||
|
||||
private func removeWord(id: UUID) {
|
||||
self.triggerEntries.removeAll { $0.id == id }
|
||||
self.syncTriggerEntriesToState()
|
||||
private func removeWord(at index: Int) {
|
||||
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
|
||||
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() {
|
||||
@@ -648,14 +638,13 @@ extension VoiceWakeSettings {
|
||||
state.voicePushToTalkEnabled = true
|
||||
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.availableLocales = [Locale(identifier: "en_US")]
|
||||
view.meterLevel = 0.42
|
||||
view.meterError = "No input"
|
||||
view.testState = .detected("ok")
|
||||
view.isTesting = true
|
||||
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
|
||||
|
||||
_ = view.body
|
||||
_ = view.localePicker
|
||||
@@ -665,9 +654,8 @@ extension VoiceWakeSettings {
|
||||
_ = view.chimeSection
|
||||
|
||||
view.addWord()
|
||||
if let entryId = view.triggerEntries.first?.id {
|
||||
view.removeWord(id: entryId)
|
||||
}
|
||||
_ = view.binding(for: 0).wrappedValue
|
||||
view.removeWord(at: 0)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -52,7 +52,7 @@ enum WideAreaGatewayDiscovery {
|
||||
|
||||
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||
guard let ptrLines = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
|
||||
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if ptr.isEmpty { continue }
|
||||
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
||||
let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)"
|
||||
let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||
let rawInstanceName = ptrName.hasSuffix(suffix)
|
||||
? String(ptrName.dropLast(suffix.count))
|
||||
: ptrName
|
||||
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
|
||||
{
|
||||
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||
|
||||
let ips = candidates
|
||||
candidates.removeAll(keepingCapacity: true)
|
||||
|
||||
150
apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift
Normal file
150
apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import ClawdbotDiscovery
|
||||
import Foundation
|
||||
|
||||
struct DiscoveryOptions {
|
||||
var timeoutMs: Int = 2000
|
||||
var json: Bool = false
|
||||
var includeLocal: Bool = false
|
||||
var help: Bool = false
|
||||
|
||||
static func parse(_ args: [String]) -> DiscoveryOptions {
|
||||
var opts = DiscoveryOptions()
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "-h", "--help":
|
||||
opts.help = true
|
||||
case "--json":
|
||||
opts.json = true
|
||||
case "--include-local":
|
||||
opts.includeLocal = true
|
||||
case "--timeout":
|
||||
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
||||
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
opts.timeoutMs = max(100, parsed)
|
||||
i += 1
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveryOutput: Encodable {
|
||||
struct Gateway: Encodable {
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var isLocal: Bool
|
||||
}
|
||||
|
||||
var status: String
|
||||
var timeoutMs: Int
|
||||
var includeLocal: Bool
|
||||
var count: Int
|
||||
var gateways: [Gateway]
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdbotDiscoveryCLI {
|
||||
static func main() async {
|
||||
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac-discovery
|
||||
|
||||
Usage:
|
||||
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
|
||||
|
||||
Options:
|
||||
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
||||
--json Emit JSON
|
||||
--include-local Include gateways considered local
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
let model = GatewayDiscoveryModel(
|
||||
localDisplayName: displayName,
|
||||
filterLocalGateways: !opts.includeLocal)
|
||||
|
||||
await MainActor.run {
|
||||
model.start()
|
||||
}
|
||||
|
||||
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
|
||||
let gateways = await MainActor.run { model.gateways }
|
||||
let status = await MainActor.run { model.statusText }
|
||||
|
||||
await MainActor.run {
|
||||
model.stop()
|
||||
}
|
||||
|
||||
if opts.json {
|
||||
let payload = DiscoveryOutput(
|
||||
status: status,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
includeLocal: opts.includeLocal,
|
||||
count: gateways.count,
|
||||
gateways: gateways.map {
|
||||
DiscoveryOutput.Gateway(
|
||||
displayName: $0.displayName,
|
||||
lanHost: $0.lanHost,
|
||||
tailnetDns: $0.tailnetDns,
|
||||
sshPort: $0.sshPort,
|
||||
gatewayPort: $0.gatewayPort,
|
||||
cliPath: $0.cliPath,
|
||||
stableID: $0.stableID,
|
||||
debugID: $0.debugID,
|
||||
isLocal: $0.isLocal)
|
||||
})
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(json)
|
||||
} else {
|
||||
print("{\"error\":\"failed to encode JSON\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
print("Gateway Discovery (macOS NWBrowser)")
|
||||
print("Status: \(status)")
|
||||
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
||||
if gateways.isEmpty { return }
|
||||
|
||||
for gateway in gateways {
|
||||
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ", ")
|
||||
print("- \(gateway.displayName)")
|
||||
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
||||
print(" ssh: \(gateway.sshPort)")
|
||||
if let port = gateway.gatewayPort {
|
||||
print(" gatewayPort: \(port)")
|
||||
}
|
||||
if let cliPath = gateway.cliPath {
|
||||
print(" cliPath: \(cliPath)")
|
||||
}
|
||||
print(" isLocal: \(gateway.isLocal)")
|
||||
print(" stableID: \(gateway.stableID)")
|
||||
print(" debugID: \(gateway.debugID)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,7 +408,8 @@ extension Request: Codable {
|
||||
}
|
||||
|
||||
// Shared transport settings
|
||||
public let controlSocketPath = FileManager()
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||
.path
|
||||
public let controlSocketPath =
|
||||
FileManager()
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||
.path
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
struct ConnectOptions {
|
||||
var url: String?
|
||||
var token: String?
|
||||
var password: String?
|
||||
var mode: String?
|
||||
var timeoutMs: Int = 15000
|
||||
var json: Bool = false
|
||||
var probe: Bool = false
|
||||
var clientId: String = "clawdbot-macos"
|
||||
var clientMode: String = "ui"
|
||||
var displayName: String?
|
||||
var role: String = "operator"
|
||||
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
|
||||
var help: Bool = false
|
||||
|
||||
static func parse(_ args: [String]) -> ConnectOptions {
|
||||
var opts = ConnectOptions()
|
||||
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
|
||||
"-h": { $0.help = true },
|
||||
"--help": { $0.help = true },
|
||||
"--json": { $0.json = true },
|
||||
"--probe": { $0.probe = true },
|
||||
]
|
||||
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
|
||||
"--url": { $0.url = $1 },
|
||||
"--token": { $0.token = $1 },
|
||||
"--password": { $0.password = $1 },
|
||||
"--mode": { $0.mode = $1 },
|
||||
"--timeout": { opts, raw in
|
||||
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
opts.timeoutMs = max(250, parsed)
|
||||
}
|
||||
},
|
||||
"--client-id": { $0.clientId = $1 },
|
||||
"--client-mode": { $0.clientMode = $1 },
|
||||
"--display-name": { $0.displayName = $1 },
|
||||
"--role": { $0.role = $1 },
|
||||
"--scopes": { opts, raw in
|
||||
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
},
|
||||
]
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
if let handler = flagHandlers[arg] {
|
||||
handler(&opts)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
|
||||
handler(&opts, value)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
private static func nextValue(_ args: [String], index: inout Int) -> String? {
|
||||
guard index + 1 < args.count else { return nil }
|
||||
index += 1
|
||||
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectOutput: Encodable {
|
||||
var status: String
|
||||
var url: String
|
||||
var mode: String
|
||||
var role: String
|
||||
var clientId: String
|
||||
var clientMode: String
|
||||
var scopes: [String]
|
||||
var snapshot: HelloOk?
|
||||
var health: ProtoAnyCodable?
|
||||
var error: String?
|
||||
}
|
||||
|
||||
actor SnapshotStore {
|
||||
private var value: HelloOk?
|
||||
|
||||
func set(_ snapshot: HelloOk) {
|
||||
self.value = snapshot
|
||||
}
|
||||
|
||||
func get() -> HelloOk? {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
func runConnect(_ args: [String]) async {
|
||||
let opts = ConnectOptions.parse(args)
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac connect
|
||||
|
||||
Usage:
|
||||
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
|
||||
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
|
||||
[--role <role>] [--scopes <a,b,c>]
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL (overrides config)
|
||||
--token <token> Gateway token (if required)
|
||||
--password <pw> Gateway password (if required)
|
||||
--mode <mode> Resolve from config: local|remote (default: config or local)
|
||||
--timeout <ms> Request timeout (default: 15000)
|
||||
--probe Force a fresh health probe
|
||||
--json Emit JSON
|
||||
--client-id <id> Override client id (default: clawdbot-macos)
|
||||
--client-mode <m> Override client mode (default: ui)
|
||||
--display-name <n> Override display name
|
||||
--role <role> Override role (default: operator)
|
||||
--scopes <a,b,c> Override scopes list
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let config = loadGatewayConfig()
|
||||
do {
|
||||
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
|
||||
let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI"
|
||||
let connectOptions = GatewayConnectOptions(
|
||||
role: opts.role,
|
||||
scopes: opts.scopes,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: opts.clientId,
|
||||
clientMode: opts.clientMode,
|
||||
clientDisplayName: displayName)
|
||||
|
||||
let snapshotStore = SnapshotStore()
|
||||
let channel = GatewayChannelActor(
|
||||
url: endpoint.url,
|
||||
token: endpoint.token,
|
||||
password: endpoint.password,
|
||||
pushHandler: { push in
|
||||
if case let .snapshot(ok) = push {
|
||||
await snapshotStore.set(ok)
|
||||
}
|
||||
},
|
||||
connectOptions: connectOptions)
|
||||
|
||||
let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil
|
||||
let data = try await channel.request(
|
||||
method: "health",
|
||||
params: params,
|
||||
timeoutMs: Double(opts.timeoutMs))
|
||||
let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data)
|
||||
let snapshot = await snapshotStore.get()
|
||||
await channel.shutdown()
|
||||
|
||||
let output = ConnectOutput(
|
||||
status: "ok",
|
||||
url: endpoint.url.absoluteString,
|
||||
mode: endpoint.mode,
|
||||
role: opts.role,
|
||||
clientId: opts.clientId,
|
||||
clientMode: opts.clientMode,
|
||||
scopes: opts.scopes,
|
||||
snapshot: snapshot,
|
||||
health: health,
|
||||
error: nil)
|
||||
printConnectOutput(output, json: opts.json)
|
||||
} catch {
|
||||
let endpoint = bestEffortEndpoint(opts: opts, config: config)
|
||||
let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
||||
let output = ConnectOutput(
|
||||
status: "error",
|
||||
url: endpoint?.url.absoluteString ?? "unknown",
|
||||
mode: endpoint?.mode ?? fallbackMode,
|
||||
role: opts.role,
|
||||
clientId: opts.clientId,
|
||||
clientMode: opts.clientMode,
|
||||
scopes: opts.scopes,
|
||||
snapshot: nil,
|
||||
health: nil,
|
||||
error: error.localizedDescription)
|
||||
printConnectOutput(output, json: opts.json)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
|
||||
if json {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(output),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(text)
|
||||
} else {
|
||||
print("{\"error\":\"failed to encode JSON\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
print("Clawdbot macOS Gateway Connect")
|
||||
print("Status: \(output.status)")
|
||||
print("URL: \(output.url)")
|
||||
print("Mode: \(output.mode)")
|
||||
print("Client: \(output.clientId) (\(output.clientMode))")
|
||||
print("Role: \(output.role)")
|
||||
print("Scopes: \(output.scopes.joined(separator: ", "))")
|
||||
if let snapshot = output.snapshot {
|
||||
print("Protocol: \(snapshot._protocol)")
|
||||
if let version = snapshot.server["version"]?.value as? String {
|
||||
print("Server: \(version)")
|
||||
}
|
||||
}
|
||||
if let health = output.health,
|
||||
let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool
|
||||
{
|
||||
print("Health: \(ok ? "ok" : "error")")
|
||||
} else if output.health != nil {
|
||||
print("Health: received")
|
||||
}
|
||||
if let error = output.error {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
||||
if let raw = opts.url, !raw.isEmpty {
|
||||
guard let url = URL(string: raw) else {
|
||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||
}
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||
mode: resolvedMode)
|
||||
}
|
||||
|
||||
if resolvedMode == "remote" {
|
||||
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
||||
}
|
||||
guard let url = URL(string: raw) else {
|
||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||
}
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||
mode: resolvedMode)
|
||||
}
|
||||
|
||||
let port = config.port ?? 18789
|
||||
let host = resolveLocalHost(bind: config.bind)
|
||||
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
||||
}
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||
mode: resolvedMode)
|
||||
}
|
||||
|
||||
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
||||
try? resolveGatewayEndpoint(opts: opts, config: config)
|
||||
}
|
||||
|
||||
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||
if let token = opts.token, !token.isEmpty { return token }
|
||||
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
if mode == "remote" {
|
||||
return config.remoteToken
|
||||
}
|
||||
return config.token
|
||||
}
|
||||
|
||||
private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||
if let password = opts.password, !password.isEmpty { return password }
|
||||
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
|
||||
return password
|
||||
}
|
||||
if mode == "remote" {
|
||||
return config.remotePassword
|
||||
}
|
||||
return config.password
|
||||
}
|
||||
|
||||
private func resolveLocalHost(bind: String?) -> String {
|
||||
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let tailnetIP = detectTailnetIPv4()
|
||||
switch normalized {
|
||||
case "tailnet":
|
||||
return tailnetIP ?? "127.0.0.1"
|
||||
default:
|
||||
return "127.0.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
private func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if isTailnetIPv4(ip) { return ip }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import ClawdbotDiscovery
|
||||
import Foundation
|
||||
|
||||
struct DiscoveryOptions {
|
||||
var timeoutMs: Int = 2000
|
||||
var json: Bool = false
|
||||
var includeLocal: Bool = false
|
||||
var help: Bool = false
|
||||
|
||||
static func parse(_ args: [String]) -> DiscoveryOptions {
|
||||
var opts = DiscoveryOptions()
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "-h", "--help":
|
||||
opts.help = true
|
||||
case "--json":
|
||||
opts.json = true
|
||||
case "--include-local":
|
||||
opts.includeLocal = true
|
||||
case "--timeout":
|
||||
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
||||
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
opts.timeoutMs = max(100, parsed)
|
||||
i += 1
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveryOutput: Encodable {
|
||||
struct Gateway: Encodable {
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var isLocal: Bool
|
||||
}
|
||||
|
||||
var status: String
|
||||
var timeoutMs: Int
|
||||
var includeLocal: Bool
|
||||
var count: Int
|
||||
var gateways: [Gateway]
|
||||
}
|
||||
|
||||
func runDiscover(_ args: [String]) async {
|
||||
let opts = DiscoveryOptions.parse(args)
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac discover
|
||||
|
||||
Usage:
|
||||
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
|
||||
|
||||
Options:
|
||||
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
||||
--json Emit JSON
|
||||
--include-local Include gateways considered local
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
let model = await MainActor.run {
|
||||
GatewayDiscoveryModel(
|
||||
localDisplayName: displayName,
|
||||
filterLocalGateways: !opts.includeLocal)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
model.start()
|
||||
}
|
||||
|
||||
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
|
||||
let gateways = await MainActor.run { model.gateways }
|
||||
let status = await MainActor.run { model.statusText }
|
||||
|
||||
await MainActor.run {
|
||||
model.stop()
|
||||
}
|
||||
|
||||
if opts.json {
|
||||
let payload = DiscoveryOutput(
|
||||
status: status,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
includeLocal: opts.includeLocal,
|
||||
count: gateways.count,
|
||||
gateways: gateways.map {
|
||||
DiscoveryOutput.Gateway(
|
||||
displayName: $0.displayName,
|
||||
lanHost: $0.lanHost,
|
||||
tailnetDns: $0.tailnetDns,
|
||||
sshPort: $0.sshPort,
|
||||
gatewayPort: $0.gatewayPort,
|
||||
cliPath: $0.cliPath,
|
||||
stableID: $0.stableID,
|
||||
debugID: $0.debugID,
|
||||
isLocal: $0.isLocal)
|
||||
})
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(json)
|
||||
} else {
|
||||
print("{\"error\":\"failed to encode JSON\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
print("Gateway Discovery (macOS NWBrowser)")
|
||||
print("Status: \(status)")
|
||||
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
||||
if gateways.isEmpty { return }
|
||||
|
||||
for gateway in gateways {
|
||||
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ", ")
|
||||
print("- \(gateway.displayName)")
|
||||
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
||||
print(" ssh: \(gateway.sshPort)")
|
||||
if let port = gateway.gatewayPort {
|
||||
print(" gatewayPort: \(port)")
|
||||
}
|
||||
if let cliPath = gateway.cliPath {
|
||||
print(" cliPath: \(cliPath)")
|
||||
}
|
||||
print(" isLocal: \(gateway.isLocal)")
|
||||
print(" stableID: \(gateway.stableID)")
|
||||
print(" debugID: \(gateway.debugID)")
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
private struct RootCommand {
|
||||
var name: String
|
||||
var args: [String]
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdbotMacCLI {
|
||||
static func main() async {
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
let command = parseRootCommand(args)
|
||||
switch command?.name {
|
||||
case nil:
|
||||
printUsage()
|
||||
case "-h", "--help", "help":
|
||||
printUsage()
|
||||
case "connect":
|
||||
await runConnect(command?.args ?? [])
|
||||
case "discover":
|
||||
await runDiscover(command?.args ?? [])
|
||||
case "wizard":
|
||||
await runWizardCommand(command?.args ?? [])
|
||||
default:
|
||||
fputs("clawdbot-mac: unknown command\n", stderr)
|
||||
printUsage()
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func parseRootCommand(_ args: [String]) -> RootCommand? {
|
||||
guard let first = args.first else { return nil }
|
||||
return RootCommand(name: first, args: Array(args.dropFirst()))
|
||||
}
|
||||
|
||||
private func printUsage() {
|
||||
print("""
|
||||
clawdbot-mac
|
||||
|
||||
Usage:
|
||||
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
|
||||
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
|
||||
[--role <role>] [--scopes <a,b,c>]
|
||||
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
|
||||
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||
|
||||
Examples:
|
||||
clawdbot-mac connect
|
||||
clawdbot-mac connect --url ws://127.0.0.1:18789 --json
|
||||
clawdbot-mac discover --timeout 3000 --json
|
||||
clawdbot-mac wizard --mode local
|
||||
""")
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct GatewayConfig {
|
||||
var mode: String?
|
||||
var bind: String?
|
||||
var port: Int?
|
||||
var remoteUrl: String?
|
||||
var token: String?
|
||||
var password: String?
|
||||
var remoteToken: String?
|
||||
var remotePassword: String?
|
||||
}
|
||||
|
||||
struct GatewayEndpoint {
|
||||
let url: URL
|
||||
let token: String?
|
||||
let password: String?
|
||||
let mode: String
|
||||
}
|
||||
|
||||
func loadGatewayConfig() -> GatewayConfig {
|
||||
let url = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return GatewayConfig()
|
||||
}
|
||||
|
||||
var cfg = GatewayConfig()
|
||||
if let gateway = json["gateway"] as? [String: Any] {
|
||||
cfg.mode = gateway["mode"] as? String
|
||||
cfg.bind = gateway["bind"] as? String
|
||||
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
|
||||
|
||||
if let auth = gateway["auth"] as? [String: Any] {
|
||||
cfg.token = auth["token"] as? String
|
||||
cfg.password = auth["password"] as? String
|
||||
}
|
||||
if let remote = gateway["remote"] as? [String: Any] {
|
||||
cfg.remoteUrl = remote["url"] as? String
|
||||
cfg.remoteToken = remote["token"] as? String
|
||||
cfg.remotePassword = remote["password"] as? String
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func parseInt(_ value: Any?) -> Int? {
|
||||
switch value {
|
||||
case let number as Int:
|
||||
number
|
||||
case let number as Double:
|
||||
Int(number)
|
||||
case let raw as String:
|
||||
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
|
||||
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
typealias KitAnyCodable = ClawdbotKit.AnyCodable
|
||||
@@ -18,7 +18,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let pathenv: String?
|
||||
public let role: String?
|
||||
public let scopes: [String]?
|
||||
public let device: [String: AnyCodable]?
|
||||
@@ -33,7 +32,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
permissions: [String: AnyCodable]?,
|
||||
pathenv: String?,
|
||||
role: String?,
|
||||
scopes: [String]?,
|
||||
device: [String: AnyCodable]?,
|
||||
@@ -47,7 +45,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.pathenv = pathenv
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.device = device
|
||||
@@ -62,7 +59,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
case caps
|
||||
case commands
|
||||
case permissions
|
||||
case pathenv = "pathEnv"
|
||||
case role
|
||||
case scopes
|
||||
case device
|
||||
@@ -385,7 +381,6 @@ public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
@@ -396,7 +391,6 @@ public struct SendParams: Codable, Sendable {
|
||||
to: String,
|
||||
message: String,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
@@ -406,7 +400,6 @@ public struct SendParams: Codable, Sendable {
|
||||
self.to = to
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.mediaurls = mediaurls
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
@@ -417,7 +410,6 @@ public struct SendParams: Codable, Sendable {
|
||||
case to
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case mediaurls = "mediaUrls"
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
@@ -481,10 +473,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let replychannel: String?
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let threadid: String?
|
||||
public let groupid: String?
|
||||
public let groupchannel: String?
|
||||
public let groupspace: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -506,10 +494,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
replychannel: String?,
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
threadid: String?,
|
||||
groupid: String?,
|
||||
groupchannel: String?,
|
||||
groupspace: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -530,10 +514,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.replychannel = replychannel
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.threadid = threadid
|
||||
self.groupid = groupid
|
||||
self.groupchannel = groupchannel
|
||||
self.groupspace = groupspace
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -555,10 +535,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
case replychannel = "replyChannel"
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case threadid = "threadId"
|
||||
case groupid = "groupId"
|
||||
case groupchannel = "groupChannel"
|
||||
case groupspace = "groupSpace"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
@@ -568,44 +544,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 let runid: String
|
||||
public let timeoutms: Int?
|
||||
@@ -897,74 +835,40 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let includederivedtitles: Bool?
|
||||
public let includelastmessage: Bool?
|
||||
public let label: String?
|
||||
public let spawnedby: String?
|
||||
public let agentid: String?
|
||||
public let search: String?
|
||||
|
||||
public init(
|
||||
limit: Int?,
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
includederivedtitles: Bool?,
|
||||
includelastmessage: Bool?,
|
||||
label: String?,
|
||||
spawnedby: String?,
|
||||
agentid: String?,
|
||||
search: String?
|
||||
agentid: String?
|
||||
) {
|
||||
self.limit = limit
|
||||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.includederivedtitles = includederivedtitles
|
||||
self.includelastmessage = includelastmessage
|
||||
self.label = label
|
||||
self.spawnedby = spawnedby
|
||||
self.agentid = agentid
|
||||
self.search = search
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case limit
|
||||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case includederivedtitles = "includeDerivedTitles"
|
||||
case includelastmessage = "includeLastMessage"
|
||||
case label
|
||||
case spawnedby = "spawnedBy"
|
||||
case agentid = "agentId"
|
||||
case search
|
||||
}
|
||||
}
|
||||
|
||||
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 let key: String?
|
||||
public let sessionid: String?
|
||||
public let label: String?
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
@@ -973,7 +877,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
sessionid: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
spawnedby: String?,
|
||||
@@ -981,7 +884,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.label = label
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
@@ -990,7 +892,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case label
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
@@ -1167,29 +1068,17 @@ public struct ConfigApplyParams: Codable, Sendable {
|
||||
public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let basehash: String?
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?
|
||||
basehash: String?
|
||||
) {
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case raw
|
||||
case basehash = "baseHash"
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1538,21 +1427,17 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
||||
public struct AgentSummary: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let identity: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String?,
|
||||
identity: [String: AnyCodable]?
|
||||
name: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.identity = identity
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case identity
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2003,30 +1888,27 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let cwd: AnyCodable?
|
||||
public let host: AnyCodable?
|
||||
public let security: AnyCodable?
|
||||
public let ask: AnyCodable?
|
||||
public let agentid: AnyCodable?
|
||||
public let resolvedpath: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let cwd: String?
|
||||
public let host: String?
|
||||
public let security: String?
|
||||
public let ask: String?
|
||||
public let agentid: String?
|
||||
public let resolvedpath: String?
|
||||
public let sessionkey: String?
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
command: String,
|
||||
cwd: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
security: AnyCodable?,
|
||||
ask: AnyCodable?,
|
||||
agentid: AnyCodable?,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
cwd: String?,
|
||||
host: String?,
|
||||
security: String?,
|
||||
ask: String?,
|
||||
agentid: String?,
|
||||
resolvedpath: String?,
|
||||
sessionkey: String?,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.host = host
|
||||
@@ -2038,7 +1920,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case command
|
||||
case cwd
|
||||
case host
|
||||
|
||||
@@ -3,6 +3,8 @@ import ClawdbotProtocol
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
struct WizardCliOptions {
|
||||
var url: String?
|
||||
var token: String?
|
||||
@@ -49,6 +51,17 @@ struct WizardCliOptions {
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayConfig {
|
||||
var mode: String?
|
||||
var bind: String?
|
||||
var port: Int?
|
||||
var remoteUrl: String?
|
||||
var token: String?
|
||||
var password: String?
|
||||
var remoteToken: String?
|
||||
var remotePassword: String?
|
||||
}
|
||||
|
||||
enum WizardCliError: Error, CustomStringConvertible {
|
||||
case invalidUrl(String)
|
||||
case missingRemoteUrl
|
||||
@@ -67,56 +80,68 @@ enum WizardCliError: Error, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
func runWizardCommand(_ args: [String]) async {
|
||||
let opts = WizardCliOptions.parse(args)
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac wizard
|
||||
|
||||
Usage:
|
||||
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL (overrides config)
|
||||
--token <token> Gateway token (if required)
|
||||
--password <pw> Gateway password (if required)
|
||||
--mode <mode> Wizard mode (local|remote). Default: local
|
||||
--workspace <path> Wizard workspace override
|
||||
--json Print raw wizard responses
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let config = loadGatewayConfig()
|
||||
do {
|
||||
guard isatty(STDIN_FILENO) != 0 else {
|
||||
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
||||
@main
|
||||
struct ClawdbotWizardCLI {
|
||||
static func main() async {
|
||||
let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
if opts.help {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
let config = loadGatewayConfig()
|
||||
do {
|
||||
guard isatty(STDIN_FILENO) != 0 else {
|
||||
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
||||
}
|
||||
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
|
||||
let client = GatewayWizardClient(
|
||||
url: endpoint.url,
|
||||
token: endpoint.token,
|
||||
password: endpoint.password,
|
||||
json: opts.json)
|
||||
try await client.connect()
|
||||
defer { Task { await client.close() } }
|
||||
try await runWizard(client: client, opts: opts)
|
||||
} catch {
|
||||
fputs("wizard: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
|
||||
let client = GatewayWizardClient(
|
||||
url: endpoint.url,
|
||||
token: endpoint.token,
|
||||
password: endpoint.password,
|
||||
json: opts.json)
|
||||
try await client.connect()
|
||||
defer { Task { await client.close() } }
|
||||
try await runWizard(client: client, opts: opts)
|
||||
} catch {
|
||||
fputs("wizard: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||
private struct GatewayEndpoint {
|
||||
let url: URL
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
private func printUsage() {
|
||||
print("""
|
||||
clawdbot-mac-wizard
|
||||
|
||||
Usage:
|
||||
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL (overrides config)
|
||||
--token <token> Gateway token (if required)
|
||||
--password <pw> Gateway password (if required)
|
||||
--mode <mode> Wizard mode (local|remote). Default: local
|
||||
--workspace <path> Wizard workspace override
|
||||
--json Print raw wizard responses
|
||||
-h, --help Show help
|
||||
""")
|
||||
}
|
||||
|
||||
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||
if let raw = opts.url, !raw.isEmpty {
|
||||
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, config: config),
|
||||
password: resolvedPassword(opts: opts, config: config),
|
||||
mode: (config.mode ?? "local").lowercased())
|
||||
password: resolvedPassword(opts: opts, config: config))
|
||||
}
|
||||
|
||||
let mode = (config.mode ?? "local").lowercased()
|
||||
@@ -128,8 +153,7 @@ private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: Gatewa
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, config: config),
|
||||
password: resolvedPassword(opts: opts, config: config),
|
||||
mode: mode)
|
||||
password: resolvedPassword(opts: opts, config: config))
|
||||
}
|
||||
|
||||
let port = config.port ?? 18789
|
||||
@@ -140,8 +164,7 @@ private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: Gatewa
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, config: config),
|
||||
password: resolvedPassword(opts: opts, config: config),
|
||||
mode: mode)
|
||||
password: resolvedPassword(opts: opts, config: config))
|
||||
}
|
||||
|
||||
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
|
||||
@@ -166,6 +189,47 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
|
||||
return config.password
|
||||
}
|
||||
|
||||
private func loadGatewayConfig() -> GatewayConfig {
|
||||
let url = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return GatewayConfig()
|
||||
}
|
||||
|
||||
var cfg = GatewayConfig()
|
||||
if let gateway = json["gateway"] as? [String: Any] {
|
||||
cfg.mode = gateway["mode"] as? String
|
||||
cfg.bind = gateway["bind"] as? String
|
||||
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
|
||||
|
||||
if let auth = gateway["auth"] as? [String: Any] {
|
||||
cfg.token = auth["token"] as? String
|
||||
cfg.password = auth["password"] as? String
|
||||
}
|
||||
if let remote = gateway["remote"] as? [String: Any] {
|
||||
cfg.remoteUrl = remote["url"] as? String
|
||||
cfg.remoteToken = remote["token"] as? String
|
||||
cfg.remotePassword = remote["password"] as? String
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
private func parseInt(_ value: Any?) -> Int? {
|
||||
switch value {
|
||||
case let number as Int:
|
||||
number
|
||||
case let number as Double:
|
||||
Int(number)
|
||||
case let raw as String:
|
||||
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
actor GatewayWizardClient {
|
||||
private enum ConnectChallengeError: Error {
|
||||
case timeout
|
||||
@@ -301,8 +365,7 @@ actor GatewayWizardClient {
|
||||
}
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
|
||||
{
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
@@ -347,11 +410,10 @@ actor GatewayWizardClient {
|
||||
operation: {
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let frame = try await self.decodeFrame(message)
|
||||
let frame = try decodeFrame(message)
|
||||
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
||||
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||
let nonce = payload["nonce"]?.value as? String
|
||||
{
|
||||
let nonce = payload["nonce"]?.value as? String {
|
||||
return nonce
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ChannelsSettingsSmokeTests {
|
||||
@@ -19,11 +17,8 @@ struct ChannelsSettingsSmokeTests {
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channelDetailLabels: nil,
|
||||
channelSystemImages: nil,
|
||||
channelMeta: nil,
|
||||
channels: [
|
||||
"whatsapp": SnapshotAnyCodable([
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
"authAgeMs": 86_400_000,
|
||||
@@ -42,7 +37,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
"lastEventAt": 1_700_000_060_000,
|
||||
"lastError": "needs login",
|
||||
]),
|
||||
"telegram": SnapshotAnyCodable([
|
||||
"telegram": AnyCodable([
|
||||
"configured": true,
|
||||
"tokenSource": "env",
|
||||
"running": true,
|
||||
@@ -57,7 +52,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
"signal": SnapshotAnyCodable([
|
||||
"signal": AnyCodable([
|
||||
"configured": true,
|
||||
"baseUrl": "http://127.0.0.1:8080",
|
||||
"running": true,
|
||||
@@ -70,7 +65,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
"imessage": SnapshotAnyCodable([
|
||||
"imessage": AnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
@@ -105,18 +100,15 @@ struct ChannelsSettingsSmokeTests {
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channelDetailLabels: nil,
|
||||
channelSystemImages: nil,
|
||||
channelMeta: nil,
|
||||
channels: [
|
||||
"whatsapp": SnapshotAnyCodable([
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": false,
|
||||
"linked": false,
|
||||
"running": false,
|
||||
"connected": false,
|
||||
"reconnectAttempts": 0,
|
||||
]),
|
||||
"telegram": SnapshotAnyCodable([
|
||||
"telegram": AnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "bot missing",
|
||||
@@ -128,7 +120,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
],
|
||||
"lastProbeAt": 1_700_000_100_000,
|
||||
]),
|
||||
"signal": SnapshotAnyCodable([
|
||||
"signal": AnyCodable([
|
||||
"configured": false,
|
||||
"baseUrl": "http://127.0.0.1:8080",
|
||||
"running": false,
|
||||
@@ -141,7 +133,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
],
|
||||
"lastProbeAt": 1_700_000_200_000,
|
||||
]),
|
||||
"imessage": SnapshotAnyCodable([
|
||||
"imessage": AnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
|
||||
@@ -11,19 +11,16 @@ struct CronJobEditorSmokeTests {
|
||||
}
|
||||
|
||||
@Test func cronJobEditorBuildsBodyForNewJob() {
|
||||
let channelsStore = ChannelsStore(isPreview: true)
|
||||
let view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
channelsStore: channelsStore,
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
||||
let channelsStore = ChannelsStore(isPreview: true)
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
@@ -57,36 +54,31 @@ struct CronJobEditorSmokeTests {
|
||||
job: job,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
channelsStore: channelsStore,
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func cronJobEditorExercisesBuilders() {
|
||||
let channelsStore = ChannelsStore(isPreview: true)
|
||||
var view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
channelsStore: channelsStore,
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
view.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||
let channelsStore = ChannelsStore(isPreview: true)
|
||||
let view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
channelsStore: channelsStore,
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
|
||||
var root: [String: Any] = [:]
|
||||
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
|
||||
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
|
||||
let raw = root["deleteAfterRun"] as? Bool
|
||||
#expect(raw == true)
|
||||
}
|
||||
|
||||
@@ -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.whatsapp.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||
}
|
||||
@@ -20,7 +19,6 @@ import Testing
|
||||
#expect(GatewayAgentChannel(raw: nil) == .last)
|
||||
#expect(GatewayAgentChannel(raw: " ") == .last)
|
||||
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
||||
#expect(GatewayAgentChannel(raw: "googlechat") == .googlechat)
|
||||
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@@ -139,46 +139,4 @@ import Testing
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "auto",
|
||||
tailscaleIP: "100.64.1.2")
|
||||
#expect(host == "127.0.0.1")
|
||||
}
|
||||
|
||||
@Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "auto",
|
||||
tailscaleIP: nil)
|
||||
#expect(host == "127.0.0.1")
|
||||
}
|
||||
|
||||
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "tailnet",
|
||||
tailscaleIP: "100.64.1.5")
|
||||
#expect(host == "100.64.1.5")
|
||||
}
|
||||
|
||||
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "tailnet",
|
||||
tailscaleIP: nil)
|
||||
#expect(host == "127.0.0.1")
|
||||
}
|
||||
|
||||
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "custom",
|
||||
tailscaleIP: "100.64.1.9",
|
||||
customBindHost: "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() {
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(
|
||||
major: 2026,
|
||||
minor: 1,
|
||||
patch: 11))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
|
||||
#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,17 +7,15 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct LowCoverageHelperTests {
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
@Test func anyCodableHelperAccessors() throws {
|
||||
let payload: [String: ProtoAnyCodable] = [
|
||||
"title": ProtoAnyCodable("Hello"),
|
||||
"flag": ProtoAnyCodable(true),
|
||||
"count": ProtoAnyCodable(3),
|
||||
"ratio": ProtoAnyCodable(1.25),
|
||||
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
|
||||
let payload: [String: AnyCodable] = [
|
||||
"title": AnyCodable("Hello"),
|
||||
"flag": AnyCodable(true),
|
||||
"count": AnyCodable(3),
|
||||
"ratio": AnyCodable(1.25),
|
||||
"list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
|
||||
]
|
||||
let any = ProtoAnyCodable(payload)
|
||||
let any = AnyCodable(payload)
|
||||
let dict = try #require(any.dictionaryValue)
|
||||
#expect(dict["title"]?.stringValue == "Hello")
|
||||
#expect(dict["flag"]?.boolValue == true)
|
||||
@@ -78,27 +76,31 @@ struct LowCoverageHelperTests {
|
||||
#expect(result.stderr.contains("stderr-1999"))
|
||||
}
|
||||
|
||||
@Test func nodeInfoCodableRoundTrip() throws {
|
||||
let info = NodeInfo(
|
||||
@Test func pairedNodesStorePersists() async throws {
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let url = dir.appendingPathComponent("nodes.json")
|
||||
let store = PairedNodesStore(fileURL: url)
|
||||
await store.load()
|
||||
#expect(await store.all().isEmpty)
|
||||
|
||||
let node = PairedNode(
|
||||
nodeId: "node-1",
|
||||
displayName: "Node One",
|
||||
platform: "macOS",
|
||||
version: "1.0",
|
||||
coreVersion: "1.0-core",
|
||||
uiVersion: "1.0-ui",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro",
|
||||
remoteIp: "192.168.1.2",
|
||||
caps: ["chat"],
|
||||
commands: ["send"],
|
||||
permissions: ["send": true],
|
||||
paired: true,
|
||||
connected: false)
|
||||
let data = try JSONEncoder().encode(info)
|
||||
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
|
||||
#expect(decoded.nodeId == "node-1")
|
||||
#expect(decoded.isPaired == true)
|
||||
#expect(decoded.isConnected == false)
|
||||
token: "token",
|
||||
createdAtMs: 1,
|
||||
lastSeenAtMs: nil)
|
||||
try await store.upsert(node)
|
||||
#expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
|
||||
|
||||
try await store.touchSeen(nodeId: "node-1")
|
||||
let updated = await store.find(nodeId: "node-1")
|
||||
#expect(updated?.lastSeenAtMs != nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func presenceReporterHelpers() {
|
||||
|
||||
@@ -21,7 +21,6 @@ import Testing
|
||||
features: [:],
|
||||
snapshot: snapshot,
|
||||
canvashosturl: nil,
|
||||
auth: nil,
|
||||
policy: [:])
|
||||
|
||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
|
||||
|
||||
@@ -3,15 +3,13 @@ import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct OnboardingWizardStepViewTests {
|
||||
@Test func noteStepBuilds() {
|
||||
let step = WizardStep(
|
||||
id: "step-1",
|
||||
type: ProtoAnyCodable("note"),
|
||||
type: AnyCodable("note"),
|
||||
title: "Welcome",
|
||||
message: "Hello",
|
||||
options: nil,
|
||||
@@ -24,17 +22,17 @@ struct OnboardingWizardStepViewTests {
|
||||
}
|
||||
|
||||
@Test func selectStepBuilds() {
|
||||
let options: [[String: ProtoAnyCodable]] = [
|
||||
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
|
||||
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
|
||||
let options: [[String: AnyCodable]] = [
|
||||
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
|
||||
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
|
||||
]
|
||||
let step = WizardStep(
|
||||
id: "step-2",
|
||||
type: ProtoAnyCodable("select"),
|
||||
type: AnyCodable("select"),
|
||||
title: "Mode",
|
||||
message: "Choose a mode",
|
||||
options: options,
|
||||
initialvalue: ProtoAnyCodable("local"),
|
||||
initialvalue: AnyCodable("local"),
|
||||
placeholder: nil,
|
||||
sensitive: nil,
|
||||
executor: nil)
|
||||
|
||||
@@ -7,22 +7,20 @@ struct SessionMenuPreviewTests {
|
||||
@Test func loaderReturnsCachedItems() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||
let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready)
|
||||
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
||||
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
||||
|
||||
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(loaded.status == .ready)
|
||||
#expect(loaded.items.count == 1)
|
||||
#expect(loaded.items.first?.text == "Hi")
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .ready)
|
||||
#expect(snapshot.items.count == 1)
|
||||
#expect(snapshot.items.first?.text == "Hi")
|
||||
}
|
||||
|
||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty)
|
||||
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
||||
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
||||
|
||||
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(loaded.status == .empty)
|
||||
#expect(loaded.items.isEmpty)
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .empty)
|
||||
#expect(snapshot.items.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,6 @@ struct VoiceWakeHelpersTests {
|
||||
#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() {
|
||||
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct WideAreaGatewayDiscoveryTests {
|
||||
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
|
||||
if recordType == "PTR" {
|
||||
if nameserver == "@100.123.224.76" {
|
||||
return "steipetacstudio-gateway._clawdbot-gw._tcp.clawdbot.internal.\n"
|
||||
return "steipetacstudio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -235,27 +235,6 @@ public struct ClawdbotChatHistoryPayload: Codable, Sendable {
|
||||
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 let runId: String
|
||||
public let status: String
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user