Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c50b64e298 | ||
|
|
a471f2debe |
No files matched your search
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a problem or unexpected behavior in Clawdbot.
|
||||
title: "[Bug]: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
What went wrong?
|
||||
|
||||
## Steps to reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
What did you expect to happen?
|
||||
|
||||
## Actual behavior
|
||||
What actually happened?
|
||||
|
||||
## Environment
|
||||
- Clawdbot version:
|
||||
- OS:
|
||||
- Install method (pnpm/npx/docker/etc):
|
||||
|
||||
## Logs or screenshots
|
||||
Paste relevant logs or add screenshots (redact secrets).
|
||||
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Onboarding
|
||||
url: https://discord.gg/clawd
|
||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
|
||||
- name: Support
|
||||
url: https://discord.gg/clawd
|
||||
about: Get help from Krill and the community on Discord in #help.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or improvement for Clawdbot.
|
||||
title: "[Feature]: "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Summary
|
||||
Describe the problem you are trying to solve or the opportunity you see.
|
||||
|
||||
## Proposed solution
|
||||
What would you like Clawdbot to do?
|
||||
|
||||
## Alternatives considered
|
||||
Any other approaches you have considered?
|
||||
|
||||
## Additional context
|
||||
Links, screenshots, or related issues.
|
||||
@@ -52,21 +52,32 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.runtime == 'node'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
# bun.sh downloads currently fail with:
|
||||
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
|
||||
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
|
||||
|
||||
- name: Setup Node.js (tooling for bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -3,8 +3,6 @@ node_modules
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
coverage
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
|
||||
+1
-1
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
|
||||
@@ -5,19 +5,9 @@
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
- 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)`).
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
@@ -25,7 +15,6 @@
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- Add brief code comments for tricky or non-obvious logic.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
|
||||
@@ -41,50 +30,35 @@
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing).
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
|
||||
## Troubleshooting
|
||||
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdbot.debug.*`), 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 expecting `com.steipete.clawdbot`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdbot`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||
- 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.
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||
- Release signing/notary keys are managed outside the repo; follow internal release docs.
|
||||
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) instead of manual conflict resolution.
|
||||
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||
- **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.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
|
||||
+1
-226
@@ -4,232 +4,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking
|
||||
- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack.
|
||||
- Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots).
|
||||
- New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
|
||||
- To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
- Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>` (Telegram also supports `clawdbot telegram pairing ...`).
|
||||
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
|
||||
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
|
||||
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
|
||||
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
|
||||
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
|
||||
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
||||
|
||||
### Fixes
|
||||
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
|
||||
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
|
||||
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
|
||||
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
|
||||
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
|
||||
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
|
||||
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
|
||||
- CLI: add `clawdbot docs` live docs search with pretty output.
|
||||
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
|
||||
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
|
||||
- Agent: deliver final replies for non-streaming models when block chunking is enabled. Thank you @mneves75 for PR #369!
|
||||
- Agent: trim bootstrap context injections and keep group guidance concise (emoji reactions allowed). Thanks @tobiasbischoff for PR #370.
|
||||
- Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298.
|
||||
- Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent.
|
||||
- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.
|
||||
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
|
||||
- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
|
||||
- Docs: sanitize AGENTS guidance and add Clawdis migration troubleshooting note. Thanks @buddyh for PR #348.
|
||||
- Docs: add ClawdHub guide and hubs link for browsing, install, and sync workflows.
|
||||
- Docs: add FAQ for PNPM/Bun lockfile migration warning; link AgentSkills spec + ClawdHub guide (`/clawdhub`) from skills docs.
|
||||
- Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312.
|
||||
- Status: add provider usage snapshots to `/status`, `clawdbot status --usage`, and the macOS menu bar.
|
||||
- Build: fix macOS packaging QR smoke test for the bun-compiled relay. Thanks @dbhurley for PR #358.
|
||||
- Browser: fix `browser snapshot`/`browser act` timeouts under Bun by patching Playwright’s CDP WebSocket selection. Thanks @azade-c for PR #307.
|
||||
- Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324.
|
||||
- Gmail: include tailscale command exit codes/output when hook setup fails (easier debugging).
|
||||
- Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322.
|
||||
- Telegram: include sender identity in group envelope headers. (#336)
|
||||
- Telegram: support forum topics with topic-isolated sessions and message_thread_id routing. Thanks @HazAT, @nachoiacovino, @RandyVentures for PR #321/#333/#334.
|
||||
- Telegram: add draft streaming via `sendMessageDraft` with `telegram.streamMode`, plus `/reasoning stream` for draft-only reasoning.
|
||||
- Telegram: honor `/activation` session mode for group mention gating and clarify group activation docs. Thanks @julianengel for PR #377.
|
||||
- iMessage: ignore disconnect errors during shutdown (avoid unhandled promise rejections). Thanks @antons for PR #359.
|
||||
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
|
||||
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
|
||||
- Auto-reply: add `/reasoning on|off` to expose model reasoning blocks (italic).
|
||||
- Auto-reply: place reasoning blocks before the final reply text when appended.
|
||||
- Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331.
|
||||
- Auto-reply: add per-channel/topic skill filters + system prompts for Discord/Slack/Telegram. Thanks @kitze for PR #286.
|
||||
- Auto-reply: refresh `/status` output with build info, compact context, and queue depth.
|
||||
- Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295.
|
||||
- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
|
||||
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
|
||||
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
|
||||
- Auth/Doctor: migrate Anthropic OAuth configs from `anthropic:default` → `anthropic:<email>` and surface a doctor hint on refresh failures. Thanks @RandyVentures for PR #361. (#363)
|
||||
- Auth: delete legacy `auth.json` after migration to prevent stale OAuth token overwrites. Thanks @reeltimeapps for PR #368.
|
||||
- Auth: auto-sync OAuth creds from Claude CLI/Codex CLI into `anthropic:claude-cli`/`openai-codex:codex-cli` and offer them as onboarding/config choices (avoids `refresh_token_reused`). Thanks @pepicrft for PR #374.
|
||||
- Gateway/CLI: stop forcing localhost URL in remote mode so remote gateway config works. Thanks @oswalpalash for PR #293.
|
||||
- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins.
|
||||
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
|
||||
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
|
||||
- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
|
||||
- Doctor: add `--yes` and `--non-interactive` for headless/automation runs (`--non-interactive` only applies safe migrations).
|
||||
- Gateway/CLI: auto-migrate legacy sessions + agent state layouts on startup (safe; WhatsApp auth still requires `clawdbot doctor`).
|
||||
- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion).
|
||||
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
|
||||
- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284.
|
||||
- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp.
|
||||
- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270.
|
||||
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
|
||||
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
|
||||
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
|
||||
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
|
||||
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
|
||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
||||
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
||||
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running.
|
||||
- Cron/Heartbeat: enqueue cron system events in the resolved main session so heartbeats drain the right queue. Thanks @zats for PR #350.
|
||||
- Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229.
|
||||
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
|
||||
- Auth/CLI: normalize provider ids and Z.AI aliases across auth profile ordering and models list/status. Thanks @mneves75 for PR #303.
|
||||
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
|
||||
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
|
||||
- Docs: add group chat participation guidance to the AGENTS template.
|
||||
- Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use).
|
||||
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
|
||||
- Skills: add Linuxbrew paths to gateway PATH bootstrap so the Skills UI can run brew installers under systemd/minimal environments.
|
||||
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
||||
- TUI: add `/elev` alias for `/elevated`.
|
||||
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
|
||||
- macOS: keep app connection settings local in remote mode to avoid overwriting gateway config. Thanks @ngutman for PR #310.
|
||||
- macOS: honor discovered gateway ports (Bonjour TXT) so remote tunnels connect to the right ports. Thanks @kkarimi for PR #375.
|
||||
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
|
||||
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
||||
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
|
||||
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
|
||||
- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json.
|
||||
- Model: `/model` list shows auth source (masked key or OAuth email) per provider.
|
||||
- Model: `/model list` is an alias for `/model`.
|
||||
- Model: `/model` output now includes auth source location (env/auth.json/models.json).
|
||||
- Model: avoid duplicate `missing (missing)` auth labels in `/model` list output.
|
||||
- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`.
|
||||
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
||||
- Docs: clarify per-session sandbox isolation and `perSession` sharing risks.
|
||||
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
||||
- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`).
|
||||
- Control UI: show a reading indicator bubble while the assistant is responding.
|
||||
- Control UI: animate reading indicator dots (honors reduced-motion).
|
||||
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
|
||||
- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
|
||||
- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
|
||||
- Control UI: add Chat focus mode toggle to collapse header + sidebar.
|
||||
- Control UI: tighten focus mode spacing (reduce top padding, add comfortable compose inset).
|
||||
- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported).
|
||||
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
|
||||
- Status: show model auth source (api-key/oauth).
|
||||
- Status: fix zero token counters for Anthropic (Opus) sessions by normalizing usage fields and ignoring empty usage updates.
|
||||
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
||||
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
||||
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
||||
- Auto-reply: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225.
|
||||
- Routing: route replies back to the originating provider/chat when multiple providers share the same session. Thanks @jalehman for PR #328.
|
||||
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
|
||||
- Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271.
|
||||
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
|
||||
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
|
||||
- Slack: send typing status updates via assistant threads. Thanks @thewilloftheshadow for PR #320.
|
||||
- Slack: fix Slack provider startup under Bun by using a named import for Bolt `App`. Thanks @snopoke for PR #299.
|
||||
- Discord: surface missing-permission hints (muted/role overrides) when replies fail.
|
||||
- Discord: use channel IDs for DMs instead of user IDs. Thanks @VACInc for PR #261.
|
||||
- Discord: treat empty message content as media placeholder so voice messages are not dropped; enables `routing.transcribeAudio`. Thanks @VACInc for PR #339.
|
||||
- Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235.
|
||||
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
|
||||
- Auth: rotate across multiple OAuth profiles with cooldown tracking and email-based profile IDs. Thanks @mukhtharcm for PR #269.
|
||||
- Auth: fix multi-account OAuth rotation so round-robin alternates instead of pinning to lastGood. Thanks @mukhtharcm for PR #281.
|
||||
- Auth: lock auth profile usage updates and fail fast on 429s during rotation. Thanks @mukhtharcm for PR #342.
|
||||
- Configure: stop auto-writing `auth.order` for newly added auth profiles (round-robin default unless explicitly pinned).
|
||||
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242.
|
||||
- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241.
|
||||
- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220.
|
||||
- Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194.
|
||||
- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
|
||||
- Auto-reply: reset corrupted Gemini sessions when function-call ordering breaks. Thanks @VACInc for PR #297.
|
||||
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
|
||||
- Telegram: notify users when inbound media exceeds size limits. Thanks @jarvis-medmatic for PR #283.
|
||||
- Telegram: send GIF media as animations (auto-play) and improve filename sniffing.
|
||||
- Bash tool: inherit gateway PATH so Nix-provided tools resolve during commands. Thanks @joshp123 for PR #202.
|
||||
- Delivery chunking: keep Markdown fenced code blocks valid when splitting long replies (close + reopen fences).
|
||||
- Auth: prefer OAuth profiles over API keys during round-robin selection (prevents OAuth “lost after one message” when both are configured).
|
||||
- Models: extend `clawdbot models` status output with a masked auth overview (profiles, env sources, and OAuth counts).
|
||||
|
||||
### Maintenance
|
||||
- Skills: add Himalaya email CLI skill. Thanks @dantelex for PR #335.
|
||||
- Agent: add `skipBootstrap` config option. Thanks @onutc for PR #292.
|
||||
- UI: add favicon.ico derived from the macOS app icon. Thanks @jeffersonwarrior for PR #305.
|
||||
- Tooling: replace tsx with bun for TypeScript execution. Thanks @obviyus for PR #278.
|
||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
||||
- Skills: add CodexBar model usage helper with macOS requirement metadata.
|
||||
- Skills: add 1Password CLI skill with op examples.
|
||||
- Lint: organize imports and wrap long lines in reply commands.
|
||||
- Refactor: centralize group allowlist/mention policy across providers.
|
||||
- Deps: update to latest across the repo.
|
||||
|
||||
## 2026.1.7
|
||||
|
||||
### Fixes
|
||||
- Android: bump version to 2026.1.7, add version code, and name APK outputs. Thanks @fcatuhe for PR #402.
|
||||
|
||||
## 2026.1.5-3
|
||||
|
||||
### Fixes
|
||||
- NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid `ERR_MODULE_NOT_FOUND` in Node 25 npx installs.
|
||||
|
||||
## 2026.1.5-2
|
||||
|
||||
### Fixes
|
||||
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
|
||||
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/*.js` modules.
|
||||
|
||||
## 2026.1.5-1
|
||||
|
||||
### Fixes
|
||||
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
|
||||
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/index.js`.
|
||||
|
||||
## 2026.1.5
|
||||
|
||||
### Highlights
|
||||
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
|
||||
- Agent tools: new `image` tool routed to the image model (when configured).
|
||||
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
|
||||
- Docs: document built-in model shorthands + precedence (user config wins).
|
||||
- Bun: optional local install/build workflow without maintaining a Bun lockfile (see `docs/bun.md`).
|
||||
|
||||
### Fixes
|
||||
- Control UI: render Markdown in tool result cards.
|
||||
- Control UI: prevent overlapping action buttons in Discord guild rules on narrow layouts.
|
||||
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
|
||||
- Cron tool uses `id` for update/remove/run/runs (aligns with gateway params). (#180) — thanks @adamgall
|
||||
- Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame).
|
||||
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
|
||||
- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan
|
||||
- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files.
|
||||
- macOS: parse JSON5 config safely to avoid wiping user settings when comments are present.
|
||||
- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj
|
||||
- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj
|
||||
- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT).
|
||||
- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176).
|
||||
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
|
||||
- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env).
|
||||
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
|
||||
- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`).
|
||||
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
|
||||
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
|
||||
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
|
||||
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
|
||||
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
|
||||
|
||||
## 2026.1.5
|
||||
|
||||
### Fixes
|
||||
- Control UI: render Markdown in chat messages (sanitized).
|
||||
- Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
|
||||
|
||||
|
||||
## 2026.1.4
|
||||
@@ -240,7 +16,6 @@
|
||||
- Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
|
||||
- Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
|
||||
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
|
||||
- Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.
|
||||
|
||||
### Breaking
|
||||
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
# Install Bun (required for build scripts)
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: c1243a7978...5c8eedd642.
@@ -16,56 +16,117 @@
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · Getting Started: [https://docs.clawd.bot/getting-started](https://docs.clawd.bot/getting-started) · Updating: [https://docs.clawd.bot/updating](https://docs.clawd.bot/updating) · Showcase: [https://docs.clawd.bot/showcase](https://docs.clawd.bot/showcase) · FAQ: [https://docs.clawd.bot/faq](https://docs.clawd.bot/faq) · Wizard: [https://docs.clawd.bot/wizard](https://docs.clawd.bot/wizard) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://docs.clawd.bot/docker](https://docs.clawd.bot/docker) · Discord: [https://discord.gg/clawd](https://discord.gg/clawd)
|
||||
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
|
||||
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: https://docs.clawd.bot/getting-started
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
- **Anthropic** (Claude Pro/Max)
|
||||
- **OpenAI** (ChatGPT/Codex)
|
||||
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/onboarding).
|
||||
## Highlights
|
||||
|
||||
## Models (selection + auth)
|
||||
- **Local-first Gateway** — single control plane for sessions, providers, tools, and events.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **Voice Wake + Talk Mode** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **Live Canvas** — agent-driven visual workspace with A2UI.
|
||||
- **First-class tools** — browser, canvas, nodes, cron, sessions, and Discord actions.
|
||||
- **Companion apps** — macOS menu bar app + iOS/Android nodes.
|
||||
- **Onboarding + skills** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
- Models config + CLI: https://docs.clawd.bot/models
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: https://docs.clawd.bot/model-failover
|
||||
## Everything we built so far
|
||||
|
||||
## Recommended setup (from source)
|
||||
### Core platform
|
||||
- Gateway WS control plane with sessions, presence, config, cron, webhooks, control UI, and Canvas host.
|
||||
- CLI surface: gateway, agent, send, wizard, doctor/update, and TUI.
|
||||
- Pi agent runtime in RPC mode with tool streaming and block streaming.
|
||||
- Session model: `main` for direct chats, group isolation, activation modes, queue modes, reply-back.
|
||||
- Media pipeline: images/audio/video, transcription hooks, size caps, temp file lifecycle.
|
||||
|
||||
Do **not** download prebuilt binaries. Run from source.
|
||||
### Surfaces + providers
|
||||
- WhatsApp (Baileys), Telegram (grammY), Discord (discord.js), Signal (signal-cli), iMessage (imsg), WebChat.
|
||||
- Group mention gating, reply tags, per-surface chunking and routing.
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
### Apps + nodes
|
||||
- macOS app: menu bar control plane, Voice Wake/PTT, Talk Mode overlay, WebChat, Debug tools, SSH remote gateway control.
|
||||
- iOS node: Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing.
|
||||
- Android node: Canvas, Talk Mode, camera, screen recording, optional SMS.
|
||||
- macOS node mode: system.run/notify + canvas/camera exposure.
|
||||
|
||||
```bash
|
||||
# Clone this repo
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
### Tools + automation
|
||||
- Browser control: dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- Canvas: A2UI push/reset, eval, snapshot.
|
||||
- Nodes: camera snap/clip, screen record, location.get, notifications.
|
||||
- Cron + wakeups; webhooks; Gmail Pub/Sub triggers.
|
||||
- Skills platform: bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
pnpm install
|
||||
pnpm ui:install
|
||||
pnpm ui:build
|
||||
pnpm build
|
||||
pnpm clawdbot onboard
|
||||
### Ops + packaging
|
||||
- Control UI + WebChat served directly from the Gateway.
|
||||
- Tailscale Serve/Funnel or SSH tunnels with token/password auth.
|
||||
- Nix mode for declarative config; Docker-based installs.
|
||||
- Health, doctor migrations, structured logging, release tooling.
|
||||
|
||||
## Changes since 2.0.0-beta5 (2026-01-03)
|
||||
|
||||
### Highlights
|
||||
- Project rename completed: CLIs, paths, bundle IDs, env vars, and docs unified on Clawdbot.
|
||||
- Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
|
||||
- Gateway config hot reload, configurable port, and Control UI base-path support.
|
||||
- Sandbox options: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
|
||||
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
|
||||
|
||||
### Fixes
|
||||
- Presence beacons keep node lists fresh; Instances view stays accurate.
|
||||
- Block streaming + chunking reliability (Telegram/Discord ordering, fewer duplicates).
|
||||
- WhatsApp GIF playback for MP4-based GIFs.
|
||||
- Onboarding/Control UI basePath handling fixes + UI polish.
|
||||
- Cleaner logging + clearer tool summaries.
|
||||
|
||||
### Breaking
|
||||
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||
- Bash tool removed `stdinMode: "pty"` support (use tmux for real TTYs).
|
||||
- Primary session key is fixed to `main` (or `global` for global scope).
|
||||
|
||||
## Project rename + changelog format
|
||||
|
||||
Clawdis → Clawdbot. The rename touched every surface, path, and bundle ID. To make that transition explicit, releases now use **date-based versions** (`YYYY.M.D`), and the changelog is compressed into milestone summaries instead of long semver trains. Full detail still lives in git history and the docs.
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
Your surfaces
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat (browser)
|
||||
├─ macOS app (Clawdbot.app)
|
||||
└─ iOS node (Canvas + voice)
|
||||
```
|
||||
|
||||
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
|
||||
## Quick start (from source)
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, providers): https://docs.clawd.bot/getting-started
|
||||
Runtime: **Node ≥22** + **pnpm**.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
# Recommended: run the onboarding wizard
|
||||
pnpm clawdbot onboard
|
||||
|
||||
# Link WhatsApp (stores creds in ~/.clawdbot/credentials)
|
||||
pnpm clawdbot login
|
||||
|
||||
# Start the gateway
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
@@ -74,198 +135,66 @@ pnpm gateway:watch
|
||||
# Send a message
|
||||
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
|
||||
pnpm clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? https://docs.clawd.bot/updating (and run `clawdbot doctor`).
|
||||
|
||||
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
|
||||
Full security guide: https://docs.clawd.bot/security
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve --provider <provider> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
|
||||
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.clawd.bot/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/wizard), and [doctor](https://docs.clawd.bot/doctor).
|
||||
- [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.clawd.bot/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/groups).
|
||||
- [Media pipeline](https://docs.clawd.bot/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/audio).
|
||||
|
||||
### Providers
|
||||
- [Providers](https://docs.clawd.bot/surface): [WhatsApp](https://docs.clawd.bot/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/telegram) (grammY), [Slack](https://docs.clawd.bot/slack) (Bolt), [Discord](https://docs.clawd.bot/discord) (discord.js), [Signal](https://docs.clawd.bot/signal) (signal-cli), [iMessage](https://docs.clawd.bot/imessage) (imsg), [WebChat](https://docs.clawd.bot/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface).
|
||||
|
||||
### Apps + nodes
|
||||
- [macOS app](https://docs.clawd.bot/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/talk) overlay, [WebChat](https://docs.clawd.bot/webchat), debug tools, [remote gateway](https://docs.clawd.bot/remote) control.
|
||||
- [iOS node](https://docs.clawd.bot/ios): [Canvas](https://docs.clawd.bot/mac/canvas), [Voice Wake](https://docs.clawd.bot/voicewake), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.clawd.bot/android): [Canvas](https://docs.clawd.bot/mac/canvas), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- [Browser control](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.clawd.bot/mac/canvas): [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.clawd.bot/cron); [webhooks](https://docs.clawd.bot/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub).
|
||||
- [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Ops + packaging
|
||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale) or [SSH tunnels](https://docs.clawd.bot/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs.
|
||||
- [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ bridge: tcp://0.0.0.0:18790
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS/Android nodes
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.clawd.bot/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
|
||||
- **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)).
|
||||
- **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.clawd.bot/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
|
||||
Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
|
||||
Notes:
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.clawd.bot/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the bash tool and provider connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: bash runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.clawd.bot/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
||||
|
||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
||||
|
||||
Elevated bash (host permissions) is separate from macOS TCC:
|
||||
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/macos) · [Gateway protocol](https://docs.clawd.bot/architecture)
|
||||
|
||||
## Agent to Agent (sessions_* tools)
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.clawd.bot/session-tool)
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
https://ClawdHub.com
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — health + session info (group shows activation mode)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/verbose on|off`
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## macOS app (optional)
|
||||
## Architecture
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
### TypeScript Gateway (src/gateway/server.ts)
|
||||
- **Single HTTP+WS server** on `ws://127.0.0.1:18789` (bind policy: loopback/lan/tailnet/auto). The first frame must be `connect`; AJV validates frames against TypeBox schemas (`src/gateway/protocol`).
|
||||
- **Single source of truth** for sessions, providers, cron, voice wake, and presence. Methods cover `send`, `agent`, `chat.*`, `sessions.*`, `config.*`, `cron.*`, `voicewake.*`, `node.*`, `system-*`, `wake`.
|
||||
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
|
||||
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
|
||||
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
|
||||
- **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
||||
|
||||
If you plan to build/run companion apps, initialize submodules first:
|
||||
### iOS app (apps/ios)
|
||||
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
||||
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
|
||||
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
|
||||
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdbot://` deep‑link interception (`ScreenController`).
|
||||
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdbot://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
## Companion apps
|
||||
|
||||
### macOS (Clawdbot.app) (optional)
|
||||
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
|
||||
|
||||
### macOS (Clawdbot.app)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
||||
Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
||||
|
||||
### iOS node (optional)
|
||||
### iOS node (internal)
|
||||
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://docs.clawd.bot/ios).
|
||||
Runbook: `docs/ios/connect.md`.
|
||||
|
||||
### Android node (optional)
|
||||
### Android node (internal)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.clawd.bot/android).
|
||||
- Runbook: `docs/android/connect.md`.
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
@@ -275,36 +204,25 @@ Runbook: [iOS connect](https://docs.clawd.bot/ios).
|
||||
|
||||
## Configuration
|
||||
|
||||
Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
Minimal `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5"
|
||||
whatsapp: {
|
||||
allowFrom: ["+1234567890"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration)
|
||||
|
||||
## Security model (important)
|
||||
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.clawd.bot/whatsapp)
|
||||
### WhatsApp
|
||||
|
||||
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.clawd.bot/telegram)
|
||||
### Telegram
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -314,14 +232,10 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.clawd.bot/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.clawd.bot/discord)
|
||||
### Discord
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -331,19 +245,6 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.clawd.bot/signal)
|
||||
|
||||
- Requires `signal-cli` and a `signal` config section.
|
||||
|
||||
### [iMessage](https://docs.clawd.bot/imessage)
|
||||
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [WebChat](https://docs.clawd.bot/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
@@ -358,101 +259,39 @@ Browser control (optional):
|
||||
|
||||
## Docs
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/windows), [Linux](https://docs.clawd.bot/linux), [macOS](https://docs.clawd.bot/macos), [iOS](https://docs.clawd.bot/ios), [Android](https://docs.clawd.bot/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.clawd.bot/discovery)
|
||||
- [Bonjour/mDNS](https://docs.clawd.bot/bonjour)
|
||||
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme)
|
||||
- [Control UI](https://docs.clawd.bot/control-ui)
|
||||
- [Dashboard](https://docs.clawd.bot/dashboard)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.clawd.bot/health)
|
||||
- [Gateway lock](https://docs.clawd.bot/gateway-lock)
|
||||
- [Background process](https://docs.clawd.bot/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.clawd.bot/logging)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.clawd.bot/agent-loop)
|
||||
- [Presence](https://docs.clawd.bot/presence)
|
||||
- [TypeBox schemas](https://docs.clawd.bot/typebox)
|
||||
- [RPC adapters](https://docs.clawd.bot/rpc)
|
||||
- [Queue](https://docs.clawd.bot/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.clawd.bot/skills-config)
|
||||
- [Default AGENTS](https://docs.clawd.bot/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.clawd.bot/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.clawd.bot/mac/voicewake)
|
||||
- [iOS node](https://docs.clawd.bot/ios)
|
||||
- [Android node](https://docs.clawd.bot/android)
|
||||
- [Windows (WSL2)](https://docs.clawd.bot/windows)
|
||||
- [Linux app](https://docs.clawd.bot/linux)
|
||||
- [`docs/index.md`](docs/index.md) (overview)
|
||||
- [`docs/configuration.md`](docs/configuration.md)
|
||||
- [`docs/group-messages.md`](docs/group-messages.md)
|
||||
- [`docs/gateway.md`](docs/gateway.md)
|
||||
- [`docs/web.md`](docs/web.md)
|
||||
- [`docs/discovery.md`](docs/discovery.md)
|
||||
- [`docs/agent.md`](docs/agent.md)
|
||||
- [`docs/discord.md`](docs/discord.md)
|
||||
- [`docs/wizard.md`](docs/wizard.md)
|
||||
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
|
||||
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawd.bot/gmail-pubsub)
|
||||
|
||||
Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you don’t want auto-start.
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail setup --account you@gmail.com
|
||||
clawdbot hooks gmail run
|
||||
```
|
||||
- [`docs/security.md`](docs/security.md)
|
||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||
- [`docs/clawdbot-mac.md`](docs/clawdbot-mac.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant.
|
||||
|
||||
- https://clawd.me
|
||||
- https://soul.md
|
||||
- https://steipete.me
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
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/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/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/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/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/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/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/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/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/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/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="mbelinky" title="mbelinky"/></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/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="omniwired" title="omniwired"/></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/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/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/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/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/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="vsabavat" title="vsabavat"/></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/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/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/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/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/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/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/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/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/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></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/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/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></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/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/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
||||
</p>
|
||||
+198
-41
@@ -1,55 +1,212 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdis</title>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
|
||||
<title>2.0.0-beta5</title>
|
||||
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3095</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||
<sparkle:version>2765</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2.0.0-beta5</h2>
|
||||
<h3>Fixed</h3>
|
||||
<ul>
|
||||
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
|
||||
<li>Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).</li>
|
||||
<li>Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).</li>
|
||||
<li>Tests: cover read tool image metadata + text output.</li>
|
||||
<li>Tests: add queue mode coverage (collect/followup + directive parsing).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Skills config schema moved under <code>skills.*</code>:</li>
|
||||
</ul>
|
||||
- <code>skillsLoad.extraDirs</code> → <code>skills.load.extraDirs</code>
|
||||
- <code>skillsInstall.*</code> → <code>skills.install.*</code>
|
||||
- per-skill config map moved to <code>skills.entries</code> (e.g. <code>skills.peekaboo.enabled</code> → <code>skills.entries.peekaboo.enabled</code>)
|
||||
- new optional bundled allowlist: <code>skills.allowBundled</code> (only affects bundled skills)
|
||||
<ul>
|
||||
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
|
||||
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
|
||||
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
|
||||
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdbot doctor</code> to migrate).</li>
|
||||
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdbot doctor</code> to migrate).</li>
|
||||
</ul>
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
<li>Discord: expand <code>discord</code> tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/Telegram: add reply tags (<code>[[reply_to_current]]</code>, <code>[[reply_to:<id>]]</code>) with per-provider <code>replyToMode</code> (off|first|all) for native threaded replies.</li>
|
||||
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
|
||||
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
|
||||
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
|
||||
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDBOT_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
|
||||
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
|
||||
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
|
||||
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
|
||||
<li>Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.</li>
|
||||
<li>Signal: add <code>signal-cli</code> JSON-RPC support for send/receive via the Signal provider.</li>
|
||||
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
|
||||
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
|
||||
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: allow agent-triggered reactions via <code>clawdbot_discord</code> when enabled, and surface message ids in context.</li>
|
||||
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
|
||||
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
|
||||
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
|
||||
<li>Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).</li>
|
||||
<li>Tests: add a Z.AI live test gate for smoke validation when keys are present.</li>
|
||||
<li>macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.</li>
|
||||
<li>CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.</li>
|
||||
<li>CLI: add ASCII banner header to wizard entry points.</li>
|
||||
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
|
||||
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
|
||||
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
|
||||
<li>CLI: enhance <code>clawdbot tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
|
||||
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
|
||||
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
|
||||
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
|
||||
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
|
||||
<li>Tests: add a Docker-based onboarding E2E harness.</li>
|
||||
<li>Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.</li>
|
||||
<li>Browser tools: add remote CDP URL support, Linux launcher options (<code>executablePath</code>, <code>noSandbox</code>), and surface <code>cdpUrl</code> in status.</li>
|
||||
<li>Skills: add tmux-first coding-agent skill + <code>requires.anyBins</code> gate for multi-CLI setup (thanks @sreekaransrinath).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||
<li>Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).</li>
|
||||
<li>macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.</li>
|
||||
<li>Chat UI: keep the chat scrolled to the latest message after switching sessions.</li>
|
||||
<li>Chat UI: show rich session display names in Web Chat + SwiftUI + Android.</li>
|
||||
<li>Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.</li>
|
||||
<li>Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).</li>
|
||||
<li>Providers: make outbound text chunk limits configurable via <code>*.textChunkLimit</code> (defaults remain 4000/Discord 2000).</li>
|
||||
<li>CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.</li>
|
||||
<li>Control UI: accept a <code>?token=</code> URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.</li>
|
||||
<li>Agent prompt: remove hardcoded user name in system prompt example.</li>
|
||||
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
|
||||
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
|
||||
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
|
||||
<li>Gateway CLI: read <code>CLAWDBOT_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
|
||||
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
|
||||
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
|
||||
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
|
||||
<li>Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.</li>
|
||||
<li>Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.</li>
|
||||
<li>Discord: include recent guild context when replying to mentions and add <code>discord.historyLimit</code> to tune how many messages are captured.</li>
|
||||
<li>Discord: include author tag + id in group context <code>[from:]</code> lines for ping-ready replies (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: preserve newlines when stripping reply tags from agent output.</li>
|
||||
<li>Gateway: fix TypeScript build by aligning hook mapping <code>channel</code> types and removing a dead Group DM branch in Discord monitor.</li>
|
||||
<li>Skills: switch imsg installer to brew tap formula.</li>
|
||||
<li>Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.</li>
|
||||
<li>Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.</li>
|
||||
<li>Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.</li>
|
||||
<li>CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.</li>
|
||||
<li>CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).</li>
|
||||
<li>CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.</li>
|
||||
<li>CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.</li>
|
||||
<li>CLI onboarding: always prompt for WhatsApp <code>whatsapp.allowFrom</code> and print (optionally open) the Control UI URL when done.</li>
|
||||
<li>CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).</li>
|
||||
<li>macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.</li>
|
||||
<li>macOS: keep config writes on the main actor to satisfy Swift concurrency rules.</li>
|
||||
<li>macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient <code>cancelled</code> device refresh errors, and auto-recover the control channel on disconnect.</li>
|
||||
<li>macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.</li>
|
||||
<li>macOS menu: tighten session row padding and time out session preview loading with cached fallback.</li>
|
||||
<li>macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.</li>
|
||||
<li>macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b</li>
|
||||
<li>macOS codesign: include camera entitlement so permission prompts work in the menu bar app.</li>
|
||||
<li>Agent tools: bash tool supports real TTY via <code>stdinMode: "pty"</code> with node-pty, warning + fallback on load/start failure.</li>
|
||||
<li>Agent tools: map <code>camera.snap</code> JPEG payloads to <code>image/jpeg</code> to avoid MIME mismatch errors.</li>
|
||||
<li>Tests: cover <code>camera.snap</code> MIME mapping to prevent image/png vs image/jpeg mismatches.</li>
|
||||
<li>macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.</li>
|
||||
<li>Camera snap: add <code>delayMs</code> parameter (default 2000ms on macOS) to improve exposure reliability.</li>
|
||||
<li>Camera: add <code>camera.list</code> and optional <code>deviceId</code> selection for snaps/clips.</li>
|
||||
<li>Tests: cover camera device selection params in CLI + agent tools.</li>
|
||||
<li>macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b</li>
|
||||
<li>macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b</li>
|
||||
<li>macOS remote: route settings through gateway config and avoid local config reads in remote mode.</li>
|
||||
<li>Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).</li>
|
||||
<li>Telegram: honor per-group mention gating defaults/overrides via <code>telegram.groups</code> and <code>"*"</code> defaults (thanks @joshp123).</li>
|
||||
<li>Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl</li>
|
||||
<li>Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.</li>
|
||||
<li>Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.</li>
|
||||
<li>Cron: prevent <code>every</code> schedules without an anchor from firing in a tight loop (thanks @jamesgroat).</li>
|
||||
<li>Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock</li>
|
||||
<li>Docs/agent tools: clarify that browser <code>wait</code> should be avoided by default and used only in exceptional cases.</li>
|
||||
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
|
||||
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
|
||||
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
|
||||
<li>Browser CLI: add <code>clawdbot browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
|
||||
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
|
||||
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
|
||||
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
|
||||
<li>CLI: tighten onboarding prompt typing to keep bun builds green.</li>
|
||||
<li>macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.</li>
|
||||
<li>macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.</li>
|
||||
<li>macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).</li>
|
||||
<li>macOS Debug: add an icon for the App Logging submenu.</li>
|
||||
<li>macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.</li>
|
||||
<li>macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.</li>
|
||||
<li>macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).</li>
|
||||
<li>macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.</li>
|
||||
<li>macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.</li>
|
||||
<li>macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.</li>
|
||||
<li>WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.</li>
|
||||
<li>Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).</li>
|
||||
<li>Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.</li>
|
||||
<li>Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.</li>
|
||||
<li>iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.</li>
|
||||
<li>ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.</li>
|
||||
<li>Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).</li>
|
||||
<li>Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).</li>
|
||||
<li>Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.</li>
|
||||
<li>Chat UI: user bubbles use <code>ui.seamColor</code> (fallback to a calmer default blue).</li>
|
||||
<li>Android Chat UI: use <code>onPrimary</code> for user bubble text to preserve contrast (thanks @Syhids).</li>
|
||||
<li>Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.</li>
|
||||
<li>Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.</li>
|
||||
<li>Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.</li>
|
||||
<li>Control UI: soften chat bubble text opacity for calmer readability.</li>
|
||||
<li>macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.</li>
|
||||
<li>macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).</li>
|
||||
<li>Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).</li>
|
||||
<li>iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).</li>
|
||||
<li>iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.</li>
|
||||
<li>iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.</li>
|
||||
<li>iOS/Android Talk Mode: explicitly <code>chat.subscribe</code> when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.</li>
|
||||
<li>Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.</li>
|
||||
<li>Gateway: <code>voice.transcript</code> now also maps agent bus output to <code>chat</code> events, ensuring chat UIs refresh for voice-triggered runs.</li>
|
||||
<li>Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.</li>
|
||||
<li>iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.</li>
|
||||
<li>Gateway config: inject <code>talk.apiKey</code> from <code>ELEVENLABS_API_KEY</code>/shell profile so nodes can fetch it on demand.</li>
|
||||
<li>Canvas A2UI: tag requests with <code>platform=android|ios|macos</code> and boost Android canvas background contrast.</li>
|
||||
<li>iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).</li>
|
||||
<li>macOS menu: device list now uses <code>node.list</code> (devices only; no agent/tool presence entries).</li>
|
||||
<li>macOS menu: device list now shows connected nodes only.</li>
|
||||
<li>macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.</li>
|
||||
<li>macOS menu: split device platform/version across first and second rows for better fit.</li>
|
||||
<li>macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.</li>
|
||||
<li>Canvas A2UI: polish the debug status HUD styling.</li>
|
||||
<li>iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.</li>
|
||||
<li>iOS Talk Mode: avoid audio tap queue assertions when starting recognition.</li>
|
||||
<li>macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).</li>
|
||||
<li>macOS remote: harden SSH tunnel recovery/logging, honor <code>gateway.remote.url</code> port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.</li>
|
||||
<li>iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.</li>
|
||||
<li>macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.</li>
|
||||
<li>iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.</li>
|
||||
<li>iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.</li>
|
||||
<li>iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.</li>
|
||||
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
|
||||
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
|
||||
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
|
||||
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdbot sessions dir.</li>
|
||||
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</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.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3091</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx 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.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-2</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3089</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
|
||||
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</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.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2.0.0-beta5/Clawdbot-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -19,8 +19,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 20260107
|
||||
versionName = "2026.1.7"
|
||||
versionCode = 1
|
||||
versionName = "2.0.0-beta3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -48,20 +48,6 @@ android {
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.outputs.forEach { output ->
|
||||
val apkOutput = output as? com.android.build.api.variant.ApkVariantOutput ?: return@forEach
|
||||
val versionName = variant.versionName.orNull ?: "0"
|
||||
apkOutput.outputFileName.set("clawdbot-${versionName}-${variant.name}.apk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -78,7 +64,7 @@ dependencies {
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.2")
|
||||
implementation("androidx.webkit:webkit:1.15.0")
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
@@ -110,8 +96,7 @@ dependencies {
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||
testImplementation("org.robolectric:robolectric:4.16")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fallback": {
|
||||
"emoji": "🧩",
|
||||
"detailKeys": [
|
||||
"command",
|
||||
"path",
|
||||
"url",
|
||||
"targetUrl",
|
||||
"targetId",
|
||||
"ref",
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"jobId",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
"guildId",
|
||||
"userId",
|
||||
"name",
|
||||
"query",
|
||||
"pattern",
|
||||
"messageId"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"bash": {
|
||||
"emoji": "🛠️",
|
||||
"title": "Bash",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"process": {
|
||||
"emoji": "🧰",
|
||||
"title": "Process",
|
||||
"detailKeys": ["sessionId"]
|
||||
},
|
||||
"read": {
|
||||
"emoji": "📖",
|
||||
"title": "Read",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"write": {
|
||||
"emoji": "✍️",
|
||||
"title": "Write",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"edit": {
|
||||
"emoji": "📝",
|
||||
"title": "Edit",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"attach": {
|
||||
"emoji": "📎",
|
||||
"title": "Attach",
|
||||
"detailKeys": ["path", "url", "fileName"]
|
||||
},
|
||||
"browser": {
|
||||
"emoji": "🌐",
|
||||
"title": "Browser",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"start": { "label": "start" },
|
||||
"stop": { "label": "stop" },
|
||||
"tabs": { "label": "tabs" },
|
||||
"open": { "label": "open", "detailKeys": ["targetUrl"] },
|
||||
"focus": { "label": "focus", "detailKeys": ["targetId"] },
|
||||
"close": { "label": "close", "detailKeys": ["targetId"] },
|
||||
"snapshot": {
|
||||
"label": "snapshot",
|
||||
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
|
||||
},
|
||||
"screenshot": {
|
||||
"label": "screenshot",
|
||||
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
|
||||
},
|
||||
"navigate": {
|
||||
"label": "navigate",
|
||||
"detailKeys": ["targetUrl", "targetId"]
|
||||
},
|
||||
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
|
||||
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
|
||||
"upload": {
|
||||
"label": "upload",
|
||||
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
|
||||
},
|
||||
"dialog": {
|
||||
"label": "dialog",
|
||||
"detailKeys": ["accept", "promptText", "targetId"]
|
||||
},
|
||||
"act": {
|
||||
"label": "act",
|
||||
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"canvas": {
|
||||
"emoji": "🖼️",
|
||||
"title": "Canvas",
|
||||
"actions": {
|
||||
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
|
||||
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
|
||||
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
|
||||
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
|
||||
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
|
||||
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
|
||||
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"emoji": "📱",
|
||||
"title": "Nodes",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
|
||||
"pending": { "label": "pending" },
|
||||
"approve": { "label": "approve", "detailKeys": ["requestId"] },
|
||||
"reject": { "label": "reject", "detailKeys": ["requestId"] },
|
||||
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
|
||||
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
|
||||
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
|
||||
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
|
||||
"screen_record": {
|
||||
"label": "screen record",
|
||||
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"emoji": "⏰",
|
||||
"title": "Cron",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"list": { "label": "list" },
|
||||
"add": {
|
||||
"label": "add",
|
||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||
},
|
||||
"update": { "label": "update", "detailKeys": ["jobId"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["jobId"] },
|
||||
"run": { "label": "run", "detailKeys": ["jobId"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["jobId"] },
|
||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"emoji": "🔌",
|
||||
"title": "Gateway",
|
||||
"actions": {
|
||||
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
|
||||
}
|
||||
},
|
||||
"whatsapp_login": {
|
||||
"emoji": "🟢",
|
||||
"title": "WhatsApp Login",
|
||||
"actions": {
|
||||
"start": { "label": "start" },
|
||||
"wait": { "label": "wait" }
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"emoji": "💬",
|
||||
"title": "Discord",
|
||||
"actions": {
|
||||
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
|
||||
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
|
||||
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
|
||||
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
|
||||
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
|
||||
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
|
||||
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
|
||||
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
|
||||
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
|
||||
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
|
||||
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
|
||||
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
|
||||
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
|
||||
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
|
||||
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
|
||||
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
|
||||
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
|
||||
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
|
||||
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
|
||||
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
|
||||
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
|
||||
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
|
||||
"eventList": { "label": "events", "detailKeys": ["guildId"] },
|
||||
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
|
||||
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
|
||||
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
|
||||
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,31 +98,14 @@ class NodeForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun buildNotification(title: String, text: String): Notification {
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val launchPending =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
1,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
val stopPending =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
2,
|
||||
stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(launchPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
|
||||
@@ -515,6 +515,7 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
private fun hasBackgroundLocationPermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package com.clawdbot.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.CancellationSignal
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -66,15 +64,6 @@ class LocationCaptureManager(private val context: Context) {
|
||||
providers: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
): Location? {
|
||||
val fineOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val coarseOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!fineOk && !coarseOk) {
|
||||
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val candidates =
|
||||
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
|
||||
@@ -83,20 +72,12 @@ class LocationCaptureManager(private val context: Context) {
|
||||
return freshest
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun requestCurrent(
|
||||
manager: LocationManager,
|
||||
providers: List<String>,
|
||||
timeoutMs: Long,
|
||||
): Location {
|
||||
val fineOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val coarseOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!fineOk && !coarseOk) {
|
||||
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
|
||||
}
|
||||
val resolved =
|
||||
providers.firstOrNull { manager.isProviderEnabled(it) }
|
||||
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
|
||||
|
||||
@@ -133,7 +133,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
if (requestedMode != null) {
|
||||
viewModel.setLocationMode(requestedMode)
|
||||
if (requestedMode == LocationMode.Always) {
|
||||
if (requestedMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val backgroundOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -191,7 +191,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (fineOk || coarseOk) {
|
||||
viewModel.setLocationMode(targetMode)
|
||||
if (targetMode == LocationMode.Always) {
|
||||
if (targetMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val backgroundOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@@ -28,9 +28,9 @@ import androidx.compose.ui.unit.dp
|
||||
fun StatusPill(
|
||||
bridge: BridgeState,
|
||||
voiceEnabled: Boolean,
|
||||
activity: StatusActivity? = null,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
activity: StatusActivity? = null,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Intent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class NodeForegroundServiceTest {
|
||||
@Test
|
||||
fun buildNotificationSetsLaunchIntent() {
|
||||
val service = Robolectric.buildService(NodeForegroundService::class.java).get()
|
||||
val notification = buildNotification(service)
|
||||
|
||||
val pendingIntent = notification.contentIntent
|
||||
assertNotNull(pendingIntent)
|
||||
|
||||
val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent
|
||||
assertNotNull(savedIntent)
|
||||
assertEquals(MainActivity::class.java.name, savedIntent.component?.className)
|
||||
|
||||
val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
assertEquals(expectedFlags, savedIntent.flags and expectedFlags)
|
||||
}
|
||||
|
||||
private fun buildNotification(service: NodeForegroundService): Notification {
|
||||
val method =
|
||||
NodeForegroundService::class.java.getDeclaredMethod(
|
||||
"buildNotification",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
)
|
||||
method.isAccessible = true
|
||||
return method.invoke(service, "Title", "Text") as Notification
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
outPath: outPath)
|
||||
|
||||
let state = CaptureState()
|
||||
let recordQueue = DispatchQueue(label: "com.clawdbot.screenrecord")
|
||||
let recordQueue = DispatchQueue(label: "com.clawdis.screenrecord")
|
||||
|
||||
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
|
||||
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
|
||||
|
||||
@@ -9,7 +9,7 @@ import SwiftUI
|
||||
final class AppState {
|
||||
private let isPreview: Bool
|
||||
private var isInitializing = true
|
||||
private var configWatcher: ConfigFileWatcher?
|
||||
private nonisolated var configWatcher: ConfigFileWatcher?
|
||||
private var suppressVoiceWakeGlobalSync = false
|
||||
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
|
||||
|
||||
@@ -321,7 +321,6 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
deinit {
|
||||
self.configWatcher?.stop()
|
||||
}
|
||||
@@ -401,9 +400,11 @@ final class AppState {
|
||||
private func syncGatewayConfigIfNeeded() {
|
||||
guard !self.isPreview, !self.isInitializing else { return }
|
||||
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let desiredMode: String? = switch connectionMode {
|
||||
var root = ClawdbotConfigFile.loadDict()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var changed = false
|
||||
|
||||
let desiredMode: String? = switch self.connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
case .remote:
|
||||
@@ -411,49 +412,37 @@ final class AppState {
|
||||
case .unconfigured:
|
||||
nil
|
||||
}
|
||||
let remoteHost = connectionMode == .remote
|
||||
? CommandResolver.parseSSHTarget(remoteTarget)?.host
|
||||
: nil
|
||||
|
||||
Task { @MainActor in
|
||||
// Keep app-only connection settings local to avoid overwriting remote gateway config.
|
||||
var root = ClawdbotConfigFile.loadDict()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var changed = false
|
||||
|
||||
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let desiredMode {
|
||||
if currentMode != desiredMode {
|
||||
gateway["mode"] = desiredMode
|
||||
changed = true
|
||||
}
|
||||
} else if currentMode != nil {
|
||||
gateway.removeValue(forKey: "mode")
|
||||
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let desiredMode {
|
||||
if currentMode != desiredMode {
|
||||
gateway["mode"] = desiredMode
|
||||
changed = true
|
||||
}
|
||||
|
||||
if connectionMode == .remote, let host = remoteHost {
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
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
|
||||
gateway["remote"] = remote
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
guard changed else { return }
|
||||
if gateway.isEmpty {
|
||||
root.removeValue(forKey: "gateway")
|
||||
} else {
|
||||
root["gateway"] = gateway
|
||||
}
|
||||
ClawdbotConfigFile.saveDict(root)
|
||||
} else if currentMode != nil {
|
||||
gateway.removeValue(forKey: "mode")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if self.connectionMode == .remote,
|
||||
let host = CommandResolver.parseSSHTarget(self.remoteTarget)?.host
|
||||
{
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
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
|
||||
gateway["remote"] = remote
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
guard changed else { return }
|
||||
root["gateway"] = gateway
|
||||
ClawdbotConfigFile.saveDict(root)
|
||||
}
|
||||
|
||||
func triggerVoiceEars(ttl: TimeInterval? = 5) {
|
||||
|
||||
@@ -187,7 +187,7 @@ actor BridgeServer {
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
provider: .last))
|
||||
channel: .last))
|
||||
|
||||
case "agent.request":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
@@ -205,7 +205,7 @@ actor BridgeServer {
|
||||
?? "node-\(nodeId)"
|
||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let provider = GatewayAgentProvider(raw: link.channel)
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
@@ -213,7 +213,7 @@ actor BridgeServer {
|
||||
thinking: thinking,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
provider: provider))
|
||||
channel: channel))
|
||||
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -79,15 +79,14 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
|
||||
let result = await GatewayConnection.shared.sendAgent(
|
||||
GatewayAgentInvocation(
|
||||
message: text,
|
||||
sessionKey: self.sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
provider: .last,
|
||||
idempotencyKey: actionId))
|
||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: text,
|
||||
sessionKey: self.sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last,
|
||||
idempotencyKey: actionId))
|
||||
|
||||
await MainActor.run {
|
||||
guard let webView else { return }
|
||||
|
||||
@@ -20,7 +20,7 @@ enum ClawdbotConfigFile {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let root = self.parseConfigData(data) else {
|
||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
self.logger.warning("config JSON root invalid")
|
||||
return [:]
|
||||
}
|
||||
@@ -122,83 +122,4 @@ enum ClawdbotConfigFile {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func remoteGatewayPort() -> Int? {
|
||||
guard let url = self.remoteGatewayUrl(),
|
||||
let port = url.port,
|
||||
port > 0
|
||||
else { return nil }
|
||||
return port
|
||||
}
|
||||
|
||||
static func remoteGatewayPort(matchingHost sshHost: String) -> Int? {
|
||||
let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedSshHost.isEmpty,
|
||||
let url = self.remoteGatewayUrl(),
|
||||
let port = url.port,
|
||||
port > 0,
|
||||
let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!urlHost.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sshKey = Self.hostKey(trimmedSshHost)
|
||||
let urlKey = Self.hostKey(urlHost)
|
||||
guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil }
|
||||
return port
|
||||
}
|
||||
|
||||
static func setRemoteGatewayUrl(host: String, port: Int?) {
|
||||
guard let port, port > 0 else { return }
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedHost.isEmpty else { return }
|
||||
self.updateGatewayDict { gateway in
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let scheme = URL(string: existingUrl)?.scheme ?? "ws"
|
||||
remote["url"] = "\(scheme)://\(trimmedHost):\(port)"
|
||||
gateway["remote"] = remote
|
||||
}
|
||||
}
|
||||
|
||||
private static func remoteGatewayUrl() -> URL? {
|
||||
let root = self.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let raw = remote["url"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
private static func hostKey(_ host: String) -> String {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if trimmed.contains(":") { return trimmed }
|
||||
let digits = CharacterSet(charactersIn: "0123456789.")
|
||||
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
|
||||
}
|
||||
|
||||
private static func parseConfigData(_ data: Data) -> [String: Any]? {
|
||||
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
return root
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
if #available(macOS 12.0, *) {
|
||||
decoder.allowsJSON5 = true
|
||||
}
|
||||
if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) {
|
||||
self.logger.notice("config parsed with JSON5 decoder")
|
||||
return decoded.mapValues { $0.foundationValue }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,11 @@ enum ConfigStore {
|
||||
if let override = overrides.loadRemote {
|
||||
return await override()
|
||||
}
|
||||
return await self.loadFromGateway() ?? [:]
|
||||
return await self.loadFromGateway()
|
||||
}
|
||||
if let override = overrides.loadLocal {
|
||||
return override()
|
||||
}
|
||||
if let gateway = await self.loadFromGateway() {
|
||||
return gateway
|
||||
}
|
||||
return ClawdbotConfigFile.loadDict()
|
||||
}
|
||||
|
||||
@@ -58,17 +55,13 @@ enum ConfigStore {
|
||||
if let override = overrides.saveLocal {
|
||||
override(root)
|
||||
} else {
|
||||
do {
|
||||
try await self.saveToGateway(root)
|
||||
} catch {
|
||||
ClawdbotConfigFile.saveDict(root)
|
||||
}
|
||||
ClawdbotConfigFile.saveDict(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func loadFromGateway() async -> [String: Any]? {
|
||||
private static func loadFromGateway() async -> [String: Any] {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
@@ -76,7 +69,7 @@ enum ConfigStore {
|
||||
timeoutMs: 8000)
|
||||
return snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
} catch {
|
||||
return nil
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
NavigationSplitView {
|
||||
self.sidebar
|
||||
} detail: {
|
||||
self.detail
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear {
|
||||
self.store.start()
|
||||
self.ensureSelection()
|
||||
@@ -18,30 +18,27 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if !self.enabledProviders.isEmpty {
|
||||
self.sidebarSectionHeader("Configured")
|
||||
List(selection: self.$selectedProvider) {
|
||||
if !self.enabledProviders.isEmpty {
|
||||
Section("Configured") {
|
||||
ForEach(self.enabledProviders) { provider in
|
||||
self.sidebarRow(provider)
|
||||
.tag(provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.availableProviders.isEmpty {
|
||||
self.sidebarSectionHeader("Available")
|
||||
if !self.availableProviders.isEmpty {
|
||||
Section("Available") {
|
||||
ForEach(self.availableProviders) { provider in
|
||||
self.sidebarRow(provider)
|
||||
.tag(provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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))
|
||||
.listStyle(.sidebar)
|
||||
.frame(minWidth: 210, idealWidth: 230, maxWidth: 260)
|
||||
}
|
||||
|
||||
private var detail: some View {
|
||||
@@ -52,7 +49,7 @@ extension ConnectionsSettings {
|
||||
self.emptyDetail
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var emptyDetail: some View {
|
||||
@@ -82,41 +79,18 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
private func sidebarRow(_ provider: ConnectionProvider) -> some View {
|
||||
let isSelected = self.selectedProvider == provider
|
||||
return Button {
|
||||
self.selectedProvider = provider
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(self.providerTint(provider))
|
||||
.frame(width: 8, height: 8)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(provider.title)
|
||||
Text(self.providerSummary(provider))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(self.providerTint(provider))
|
||||
.frame(width: 8, height: 8)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(provider.title)
|
||||
Text(self.providerSummary(provider))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.background(Color.clear) // ensure full-width hit test area
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func sidebarSectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 2)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func detailHeader(for provider: ConnectionProvider) -> some View {
|
||||
|
||||
@@ -33,13 +33,13 @@ extension CronJobEditor {
|
||||
case let .systemEvent(text):
|
||||
self.payloadKind = .systemEvent
|
||||
self.systemEventText = text
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = message
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
self.provider = GatewayAgentProvider(raw: provider)
|
||||
self.channel = GatewayAgentChannel(raw: channel)
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
@@ -166,7 +166,7 @@ extension CronJobEditor {
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
payload["provider"] = self.provider.rawValue
|
||||
payload["channel"] = self.channel.rawValue
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
|
||||
@@ -13,7 +13,7 @@ extension CronJobEditor {
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = "Run diagnostic"
|
||||
self.deliver = true
|
||||
self.provider = .last
|
||||
self.channel = .last
|
||||
self.to = "+15551230000"
|
||||
self.thinking = "low"
|
||||
self.timeoutSeconds = "90"
|
||||
|
||||
@@ -17,7 +17,7 @@ struct CronJobEditor: View {
|
||||
static let scheduleKindNote =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
static let isolatedPayloadNote =
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a provider, "
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a surface, "
|
||||
+ "and a short summary is posted back to your main chat."
|
||||
static let mainPayloadNote =
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
@@ -42,7 +42,7 @@ struct CronJobEditor: View {
|
||||
@State var systemEventText: String = ""
|
||||
@State var agentMessage: String = ""
|
||||
@State var deliver: Bool = false
|
||||
@State var provider: GatewayAgentProvider = .last
|
||||
@State var channel: GatewayAgentChannel = .last
|
||||
@State var to: String = ""
|
||||
@State var thinking: String = ""
|
||||
@State var timeoutSeconds: String = ""
|
||||
@@ -309,7 +309,7 @@ struct CronJobEditor: View {
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Deliver")
|
||||
Toggle("Deliver result to a provider", isOn: self.$deliver)
|
||||
Toggle("Deliver result to a surface", isOn: self.$deliver)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
@@ -317,15 +317,12 @@ struct CronJobEditor: View {
|
||||
if self.deliver {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Provider")
|
||||
Picker("", selection: self.$provider) {
|
||||
Text("last").tag(GatewayAgentProvider.last)
|
||||
Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentProvider.telegram)
|
||||
Text("discord").tag(GatewayAgentProvider.discord)
|
||||
Text("slack").tag(GatewayAgentProvider.slack)
|
||||
Text("signal").tag(GatewayAgentProvider.signal)
|
||||
Text("imessage").tag(GatewayAgentProvider.imessage)
|
||||
self.gridLabel("Channel")
|
||||
Picker("", selection: self.$channel) {
|
||||
Text("last").tag(GatewayAgentChannel.last)
|
||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||
Text("discord").tag(GatewayAgentChannel.discord)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
@@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
|
||||
thinking: String?,
|
||||
timeoutSeconds: Int?,
|
||||
deliver: Bool?,
|
||||
provider: String?,
|
||||
channel: String?,
|
||||
to: String?,
|
||||
bestEffortDeliver: Bool?)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver
|
||||
}
|
||||
|
||||
var kind: String {
|
||||
@@ -101,7 +101,7 @@ enum CronPayload: Codable, Equatable {
|
||||
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
|
||||
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
||||
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
|
||||
provider: container.decodeIfPresent(String.self, forKey: .provider),
|
||||
channel: container.decodeIfPresent(String.self, forKey: .channel),
|
||||
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||
default:
|
||||
@@ -118,12 +118,12 @@ enum CronPayload: Codable, Equatable {
|
||||
switch self {
|
||||
case let .systemEvent(text):
|
||||
try container.encode(text, forKey: .text)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
try container.encode(message, forKey: .message)
|
||||
try container.encodeIfPresent(thinking, forKey: .thinking)
|
||||
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||
try container.encodeIfPresent(deliver, forKey: .deliver)
|
||||
try container.encodeIfPresent(provider, forKey: .provider)
|
||||
try container.encodeIfPresent(channel, forKey: .channel)
|
||||
try container.encodeIfPresent(to, forKey: .to)
|
||||
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ extension CronSettings {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
@@ -216,7 +216,7 @@ extension CronSettings {
|
||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||
if deliver ?? false {
|
||||
StatusPill(text: "deliver", tint: .secondary)
|
||||
if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
|
||||
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
|
||||
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 600,
|
||||
deliver: true,
|
||||
provider: "last",
|
||||
channel: "last",
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
@@ -72,7 +72,7 @@ extension CronSettings {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
|
||||
@@ -59,7 +59,7 @@ final class DeepLinkHandler {
|
||||
}
|
||||
|
||||
do {
|
||||
let provider = GatewayAgentProvider(raw: link.channel)
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
let explicitSessionKey = link.sessionKey?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.nonEmpty
|
||||
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
|
||||
message: messagePreview,
|
||||
sessionKey: resolvedSessionKey,
|
||||
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
deliver: provider.shouldDeliver(link.deliver),
|
||||
deliver: channel.shouldDeliver(link.deliver),
|
||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
provider: provider,
|
||||
channel: channel,
|
||||
timeoutSeconds: link.timeoutSeconds,
|
||||
idempotencyKey: UUID().uuidString)
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayAgentChannel: String, CaseIterable, Sendable {
|
||||
case last
|
||||
case webchat
|
||||
case whatsapp
|
||||
case telegram
|
||||
|
||||
init(raw: String?) {
|
||||
let trimmed = raw?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased() ?? ""
|
||||
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
|
||||
}
|
||||
|
||||
func shouldDeliver(_ isLast: Bool) -> Bool {
|
||||
switch self {
|
||||
case .webchat:
|
||||
return false
|
||||
case .last:
|
||||
return isLast
|
||||
case .whatsapp, .telegram:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,16 @@ import OSLog
|
||||
|
||||
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
|
||||
|
||||
enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
|
||||
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case last
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
case slack
|
||||
case signal
|
||||
case imessage
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self = GatewayAgentProvider(rawValue: normalized) ?? .last
|
||||
self = GatewayAgentChannel(rawValue: normalized) ?? .last
|
||||
}
|
||||
|
||||
var isDeliverable: Bool { self != .webchat }
|
||||
@@ -31,7 +28,7 @@ struct GatewayAgentInvocation: Sendable {
|
||||
var thinking: String?
|
||||
var deliver: Bool = false
|
||||
var to: String?
|
||||
var provider: GatewayAgentProvider = .last
|
||||
var channel: GatewayAgentChannel = .last
|
||||
var timeoutSeconds: Int?
|
||||
var idempotencyKey: String = UUID().uuidString
|
||||
}
|
||||
@@ -368,7 +365,7 @@ extension GatewayConnection {
|
||||
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||
"deliver": AnyCodable(invocation.deliver),
|
||||
"to": AnyCodable(invocation.to ?? ""),
|
||||
"provider": AnyCodable(invocation.provider.rawValue),
|
||||
"channel": AnyCodable(invocation.channel.rawValue),
|
||||
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
||||
]
|
||||
if let timeout = invocation.timeoutSeconds {
|
||||
@@ -389,7 +386,7 @@ extension GatewayConnection {
|
||||
sessionKey: String,
|
||||
deliver: Bool,
|
||||
to: String?,
|
||||
provider: GatewayAgentProvider = .last,
|
||||
channel: GatewayAgentChannel = .last,
|
||||
timeoutSeconds: Int? = nil,
|
||||
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
||||
{
|
||||
@@ -399,7 +396,7 @@ extension GatewayConnection {
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
provider: provider,
|
||||
channel: channel,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
idempotencyKey: idempotencyKey))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ final class GatewayDiscoveryModel {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
@@ -139,7 +138,6 @@ final class GatewayDiscoveryModel {
|
||||
lanHost: parsedTXT.lanHost,
|
||||
tailnetDns: parsedTXT.tailnetDns,
|
||||
sshPort: parsedTXT.sshPort,
|
||||
gatewayPort: parsedTXT.gatewayPort,
|
||||
cliPath: parsedTXT.cliPath,
|
||||
stableID: stableID,
|
||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
||||
@@ -209,12 +207,11 @@ final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
static func parseGatewayTXT(_ txt: [String: String])
|
||||
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, gatewayPort: Int?, cliPath: String?)
|
||||
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, cliPath: String?)
|
||||
{
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort = 22
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
|
||||
if let value = txt["lanHost"] {
|
||||
@@ -231,18 +228,12 @@ final class GatewayDiscoveryModel {
|
||||
{
|
||||
sshPort = parsed
|
||||
}
|
||||
if let value = txt["gatewayPort"],
|
||||
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
parsed > 0
|
||||
{
|
||||
gatewayPort = parsed
|
||||
}
|
||||
if let value = txt["cliPath"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
cliPath = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
return (lanHost, tailnetDns, sshPort, gatewayPort, cliPath)
|
||||
return (lanHost, tailnetDns, sshPort, cliPath)
|
||||
}
|
||||
|
||||
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||
|
||||
@@ -13,14 +13,12 @@ enum GatewayEndpointState: Sendable, Equatable {
|
||||
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
|
||||
actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
let token: @Sendable () -> String?
|
||||
let password: @Sendable () -> String?
|
||||
let localPort: @Sendable () -> Int
|
||||
let localHost: @Sendable () async -> String
|
||||
let remotePortIfRunning: @Sendable () async -> UInt16?
|
||||
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
|
||||
|
||||
@@ -35,16 +33,6 @@ actor GatewayEndpointStore {
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
},
|
||||
localPort: { GatewayEnvironment.gatewayPort() },
|
||||
localHost: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
tailscaleIP: tailscaleIP)
|
||||
},
|
||||
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
||||
}
|
||||
@@ -101,17 +89,13 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
|
||||
let port = deps.localPort()
|
||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||
root: ClawdbotConfigFile.loadDict(),
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil)
|
||||
let token = deps.token()
|
||||
let password = deps.password()
|
||||
switch initialMode {
|
||||
case .local:
|
||||
self.state = .ready(
|
||||
mode: .local,
|
||||
url: URL(string: "ws://\(host):\(port)")!,
|
||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
||||
token: token,
|
||||
password: password)
|
||||
case .remote:
|
||||
@@ -145,10 +129,9 @@ actor GatewayEndpointStore {
|
||||
switch mode {
|
||||
case .local:
|
||||
let port = self.deps.localPort()
|
||||
let host = await self.deps.localHost()
|
||||
self.setState(.ready(
|
||||
mode: .local,
|
||||
url: URL(string: "ws://\(host):\(port)")!,
|
||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
||||
token: token,
|
||||
password: password))
|
||||
case .remote:
|
||||
@@ -235,39 +218,6 @@ actor GatewayEndpointStore {
|
||||
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveGatewayBindMode(
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
{
|
||||
if let envBind = env["CLAWDBOT_GATEWAY_BIND"] {
|
||||
let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let bind = gateway["bind"] as? String
|
||||
{
|
||||
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveLocalGatewayHost(
|
||||
bindMode: String?,
|
||||
tailscaleIP: String?) -> String
|
||||
{
|
||||
switch bindMode {
|
||||
case "tailnet", "auto":
|
||||
tailscaleIP ?? "127.0.0.1"
|
||||
default:
|
||||
"127.0.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@@ -279,19 +229,5 @@ extension GatewayEndpointStore {
|
||||
{
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||
}
|
||||
|
||||
static func _testResolveGatewayBindMode(
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
{
|
||||
self.resolveGatewayBindMode(root: root, env: env)
|
||||
}
|
||||
|
||||
static func _testResolveLocalGatewayHost(
|
||||
bindMode: String?,
|
||||
tailscaleIP: String?) -> String
|
||||
{
|
||||
self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
import ClawdbotProtocol
|
||||
|
||||
// The generated gateway protocol models are value types, but they don't currently declare Sendable.
|
||||
// We use them across actors via GatewayConnection's event stream, so mark them as unchecked.
|
||||
extension HelloOk: @unchecked Sendable {}
|
||||
extension EventFrame: @unchecked Sendable {}
|
||||
@@ -151,7 +151,6 @@ struct GeneralSettings: View {
|
||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
|
||||
if status == .authorizedAlways {
|
||||
return true
|
||||
}
|
||||
@@ -694,7 +693,6 @@ extension GeneralSettings {
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private var cachedErrorText: String?
|
||||
private var cacheUpdatedAt: Date?
|
||||
private let refreshIntervalSeconds: TimeInterval = 12
|
||||
private var cachedUsageSummary: GatewayUsageSummary?
|
||||
private var cachedUsageErrorText: String?
|
||||
private var usageCacheUpdatedAt: Date?
|
||||
private let usageRefreshIntervalSeconds: TimeInterval = 30
|
||||
private let nodesStore = NodesStore.shared
|
||||
#if DEBUG
|
||||
private var testControlChannelConnected: Bool?
|
||||
@@ -62,7 +58,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.loadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await self.refreshUsageCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
@@ -113,69 +108,65 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
var cursor = insertIndex
|
||||
var headerView: NSView?
|
||||
|
||||
if let snapshot = self.cachedSnapshot {
|
||||
let now = Date()
|
||||
let rows = snapshot.rows.filter { row in
|
||||
if row.key == "main" { return true }
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}.sorted { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
|
||||
guard let snapshot = self.cachedSnapshot else {
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
headerView = hosted
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
} else {
|
||||
for row in rows {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
highlighted: true)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let hosted = self.makeHostedView(
|
||||
headerItem.view = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: 0,
|
||||
statusText: self.cachedErrorText ?? "Loading sessions…")),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
headerView = hosted
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
cursor += 1
|
||||
menu.insertItem(headerItem, at: insertIndex)
|
||||
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
|
||||
guard let self, let view else { return }
|
||||
self.captureMenuWidthIfAvailable(from: view)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||
let now = Date()
|
||||
let rows = snapshot.rows.filter { row in
|
||||
if row.key == "main" { return true }
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}.sorted { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let headerView = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = headerView
|
||||
menu.insertItem(headerItem, at: insertIndex)
|
||||
|
||||
var cursor = insertIndex + 1
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
|
||||
at: cursor)
|
||||
return
|
||||
}
|
||||
|
||||
for row in rows {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
highlighted: true)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
||||
guard let self, let headerView else { return }
|
||||
@@ -249,55 +240,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
_ = cursor
|
||||
}
|
||||
|
||||
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||
let rows = self.usageRows
|
||||
let errorText = self.cachedUsageErrorText
|
||||
|
||||
if rows.isEmpty && errorText == nil {
|
||||
return cursor
|
||||
}
|
||||
|
||||
var cursor = cursor
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
headerItem.view = self.makeHostedView(
|
||||
rootView: AnyView(MenuUsageHeaderView(
|
||||
count: rows.count,
|
||||
statusText: errorText)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
return cursor
|
||||
}
|
||||
|
||||
for row in rows {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(UsageMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
return cursor
|
||||
}
|
||||
|
||||
private var usageRows: [UsageRow] {
|
||||
guard let summary = self.cachedUsageSummary else { return [] }
|
||||
return summary.primaryRows()
|
||||
}
|
||||
|
||||
private var isControlChannelConnected: Bool {
|
||||
#if DEBUG
|
||||
if let override = self.testControlChannelConnected { return override }
|
||||
@@ -422,40 +364,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshUsageCache(force: Bool) async {
|
||||
if !force,
|
||||
let updated = self.usageCacheUpdatedAt,
|
||||
Date().timeIntervalSince(updated) < self.usageRefreshIntervalSeconds
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedUsageSummary = nil
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
} catch {
|
||||
if self.cachedUsageSummary == nil {
|
||||
self.cachedUsageErrorText = self.compactUsageError(error)
|
||||
}
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
private func compactUsageError(_ error: Error) -> String {
|
||||
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if message.isEmpty { return "Usage unavailable" }
|
||||
if message.count > 90 { return "\(message.prefix(87))…" }
|
||||
return message
|
||||
}
|
||||
|
||||
private func compactError(_ error: Error) -> String {
|
||||
if let loadError = error as? SessionLoadError {
|
||||
switch loadError {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuUsageHeaderView: View {
|
||||
let count: Int
|
||||
let statusText: String?
|
||||
|
||||
private let paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 6
|
||||
private let paddingTrailing: CGFloat = 10
|
||||
private let paddingLeading: CGFloat = 20
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Usage")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if self.count == 1 { return "1 provider" }
|
||||
return "\(self.count) providers"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
private struct UncheckedSendable<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -60,7 +63,7 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func withTimeout<T: Sendable>(
|
||||
private func withTimeout<T>(
|
||||
timeoutMs: Int,
|
||||
operation: @escaping () async throws -> T) async throws -> T
|
||||
{
|
||||
@@ -68,38 +71,15 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
var didFinish = false
|
||||
|
||||
func finish(returning value: T) {
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
continuation.resume(returning: value)
|
||||
}
|
||||
|
||||
func finish(throwing error: Swift.Error) {
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
|
||||
let timeoutItem = DispatchWorkItem {
|
||||
finish(throwing: Error.timeout)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + .milliseconds(timeoutMs),
|
||||
execute: timeoutItem)
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let value = try await operation()
|
||||
timeoutItem.cancel()
|
||||
finish(returning: value)
|
||||
} catch {
|
||||
timeoutItem.cancel()
|
||||
finish(throwing: error)
|
||||
}
|
||||
return try await withThrowingTaskGroup(of: UncheckedSendable<T>.self) { group in
|
||||
group.addTask { try await UncheckedSendable(value: operation()) }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
|
||||
throw Error.timeout
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -202,23 +202,6 @@ final class MacNodeModeCoordinator {
|
||||
return 18790
|
||||
}
|
||||
|
||||
static func remoteBridgePort() -> Int {
|
||||
let fallback = Int(Self.loopbackBridgePort() ?? 18790)
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
let sshHost = CommandResolver.parseSSHTarget(settings.target)?.host ?? ""
|
||||
let base =
|
||||
ClawdbotConfigFile.remoteGatewayPort(matchingHost: sshHost) ??
|
||||
GatewayEnvironment.gatewayPort()
|
||||
guard base > 0 else { return fallback }
|
||||
return Self.derivePort(base: base, offset: 1, fallback: fallback)
|
||||
}
|
||||
|
||||
private static func derivePort(base: Int, offset: Int, fallback: Int) -> Int {
|
||||
let derived = base + offset
|
||||
guard derived > 0, derived <= Int(UInt16.max) else { return fallback }
|
||||
return derived
|
||||
}
|
||||
|
||||
static func probeEndpoint(_ endpoint: NWEndpoint, timeoutSeconds: Double) async -> Bool {
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let stream = Self.makeStateStream(for: connection)
|
||||
@@ -286,10 +269,7 @@ final class MacNodeModeCoordinator {
|
||||
if mode == .remote {
|
||||
do {
|
||||
if self.tunnel == nil || self.tunnel?.process.isRunning == false {
|
||||
let remotePort = Self.remoteBridgePort()
|
||||
self.tunnel = try await RemotePortTunnel.create(
|
||||
remotePort: remotePort,
|
||||
allowRemoteUrlOverride: false)
|
||||
self.tunnel = try await RemotePortTunnel.create(remotePort: 18790)
|
||||
}
|
||||
if let localPort = self.tunnel?.localPort,
|
||||
let port = NWEndpoint.Port(rawValue: localPort)
|
||||
|
||||
@@ -29,7 +29,6 @@ extension OnboardingView {
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ extension OnboardingView {
|
||||
|
||||
var navigationBar: some View {
|
||||
let wizardLockIndex = self.wizardPageOrderIndex
|
||||
return HStack(spacing: 20) {
|
||||
HStack(spacing: 20) {
|
||||
ZStack(alignment: .leading) {
|
||||
Button(action: {}, label: {
|
||||
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
||||
|
||||
@@ -12,7 +12,6 @@ extension OnboardingView {
|
||||
lanHost: "bridge.local",
|
||||
tailnetDns: "bridge.ts.net",
|
||||
sshPort: 2222,
|
||||
gatewayPort: 18789,
|
||||
cliPath: "/usr/local/bin/clawdbot",
|
||||
stableID: "bridge-1",
|
||||
debugID: "bridge-1",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotProtocol
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -232,52 +232,44 @@ struct OnboardingWizardStepView: View {
|
||||
|
||||
private var selectOptions: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.optionItems, id: \.index) { item in
|
||||
self.selectOptionRow(item)
|
||||
ForEach(self.optionItems) { item in
|
||||
Button {
|
||||
self.selectedIndex = item.index
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
.foregroundStyle(.primary)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var multiselectOptions: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.optionItems, id: \.index) { item in
|
||||
self.multiselectOptionRow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectOptionRow(_ item: WizardOptionItem) -> some View {
|
||||
Button {
|
||||
self.selectedIndex = item.index
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
.foregroundStyle(.primary)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(self.optionItems) { item in
|
||||
Toggle(isOn: self.bindingForOption(item)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func multiselectOptionRow(_ item: WizardOptionItem) -> some View {
|
||||
Toggle(isOn: self.bindingForOption(item)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
|
||||
|
||||
@@ -140,7 +140,6 @@ enum PermissionManager {
|
||||
private static func ensureLocation(interactive: Bool) async -> Bool {
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
switch status {
|
||||
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
|
||||
case .authorizedAlways:
|
||||
return true
|
||||
case .notDetermined:
|
||||
@@ -202,7 +201,6 @@ enum PermissionManager {
|
||||
|
||||
case .location:
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
// Note: macOS only supports authorizedAlways
|
||||
results[cap] = status == .authorizedAlways
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +38,7 @@ final class RemotePortTunnel {
|
||||
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
||||
}
|
||||
|
||||
static func create(
|
||||
remotePort: Int,
|
||||
preferredLocalPort: UInt16? = nil,
|
||||
allowRemoteUrlOverride: Bool = true
|
||||
) async throws -> RemotePortTunnel {
|
||||
static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> RemotePortTunnel {
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
|
||||
throw NSError(
|
||||
@@ -53,7 +49,7 @@ final class RemotePortTunnel {
|
||||
|
||||
let localPort = try await Self.findPort(preferred: preferredLocalPort)
|
||||
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remotePortOverride = allowRemoteUrlOverride ? Self.resolveRemotePortOverride(for: sshHost) : nil
|
||||
let remotePortOverride = Self.resolveRemotePortOverride(for: sshHost)
|
||||
let resolvedRemotePort = remotePortOverride ?? remotePort
|
||||
if let override = remotePortOverride {
|
||||
Self.logger.info(
|
||||
@@ -131,9 +127,42 @@ final class RemotePortTunnel {
|
||||
}
|
||||
|
||||
private static func resolveRemotePortOverride(for sshHost: String) -> Int? {
|
||||
let trimmed = sshHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return ClawdbotConfigFile.remoteGatewayPort(matchingHost: trimmed)
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
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)
|
||||
guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else {
|
||||
return nil
|
||||
}
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!host.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let sshKey = Self.hostKey(sshHost)
|
||||
let urlKey = Self.hostKey(host)
|
||||
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
|
||||
guard sshKey == urlKey else {
|
||||
Self.logger.debug(
|
||||
"remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
private static func hostKey(_ host: String) -> String {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if trimmed.contains(":") { return trimmed }
|
||||
let digits = CharacterSet(charactersIn: "0123456789.")
|
||||
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
|
||||
}
|
||||
|
||||
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
|
||||
|
||||
@@ -9,7 +9,7 @@ struct GatewaySessionDefaultsRecord: Codable {
|
||||
struct GatewaySessionEntryRecord: Codable {
|
||||
let key: String
|
||||
let displayName: String?
|
||||
let provider: String?
|
||||
let surface: String?
|
||||
let subject: String?
|
||||
let room: String?
|
||||
let space: String?
|
||||
@@ -71,7 +71,7 @@ struct SessionRow: Identifiable {
|
||||
let key: String
|
||||
let kind: SessionKind
|
||||
let displayName: String?
|
||||
let provider: String?
|
||||
let surface: String?
|
||||
let subject: String?
|
||||
let room: String?
|
||||
let space: String?
|
||||
@@ -141,7 +141,7 @@ extension SessionRow {
|
||||
key: "user@example.com",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
@@ -158,7 +158,7 @@ extension SessionRow {
|
||||
key: "discord:channel:release-squad",
|
||||
kind: .group,
|
||||
displayName: "discord:#release-squad",
|
||||
provider: "discord",
|
||||
surface: "discord",
|
||||
subject: nil,
|
||||
room: "#release-squad",
|
||||
space: nil,
|
||||
@@ -175,7 +175,7 @@ extension SessionRow {
|
||||
key: "global",
|
||||
kind: .global,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
@@ -298,7 +298,7 @@ enum SessionLoader {
|
||||
key: entry.key,
|
||||
kind: SessionKind.from(key: entry.key),
|
||||
displayName: entry.displayName,
|
||||
provider: entry.provider,
|
||||
surface: entry.surface,
|
||||
subject: entry.subject,
|
||||
room: entry.room,
|
||||
space: entry.space,
|
||||
|
||||
@@ -194,22 +194,6 @@ enum SettingsTab: CaseIterable {
|
||||
case .about: "About"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .general: "gearshape"
|
||||
case .connections: "link"
|
||||
case .skills: "sparkles"
|
||||
case .sessions: "clock.arrow.circlepath"
|
||||
case .cron: "calendar"
|
||||
case .config: "slider.horizontal.3"
|
||||
case .instances: "network"
|
||||
case .voiceWake: "waveform.circle"
|
||||
case .permissions: "lock.shield"
|
||||
case .debug: "ant"
|
||||
case .about: "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -588,9 +588,7 @@ actor TalkModeRuntime {
|
||||
let stream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
guard self.isCurrent(input.generation) else { return }
|
||||
|
||||
if self.interruptOnSpeech {
|
||||
guard await self.prepareForPlayback(generation: input.generation) else { return }
|
||||
}
|
||||
if self.interruptOnSpeech, ! await self.prepareForPlayback(generation: input.generation) { return }
|
||||
|
||||
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||
self.phase = .speaking
|
||||
@@ -645,9 +643,7 @@ actor TalkModeRuntime {
|
||||
|
||||
private func playSystemVoice(input: TalkPlaybackInput) async throws {
|
||||
self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)")
|
||||
if self.interruptOnSpeech {
|
||||
guard await self.prepareForPlayback(generation: input.generation) else { return }
|
||||
}
|
||||
if self.interruptOnSpeech, ! await self.prepareForPlayback(generation: input.generation) { return }
|
||||
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||
self.phase = .speaking
|
||||
await TalkSystemSpeechSynthesizer.shared.stop()
|
||||
@@ -731,6 +727,7 @@ actor TalkModeRuntime {
|
||||
}
|
||||
|
||||
extension TalkModeRuntime {
|
||||
|
||||
// MARK: - Audio playback (MainActor helpers)
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct GatewayUsageWindow: Codable {
|
||||
let label: String
|
||||
let usedPercent: Double
|
||||
let resetAt: Double?
|
||||
}
|
||||
|
||||
struct GatewayUsageProvider: Codable {
|
||||
let provider: String
|
||||
let displayName: String
|
||||
let windows: [GatewayUsageWindow]
|
||||
let plan: String?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
struct GatewayUsageSummary: Codable {
|
||||
let updatedAt: Double
|
||||
let providers: [GatewayUsageProvider]
|
||||
}
|
||||
|
||||
struct UsageRow: Identifiable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let plan: String?
|
||||
let windowLabel: String?
|
||||
let usedPercent: Double?
|
||||
let resetAt: Date?
|
||||
let error: String?
|
||||
|
||||
var titleText: String {
|
||||
if let plan, !plan.isEmpty { return "\(displayName) (\(plan))" }
|
||||
return displayName
|
||||
}
|
||||
|
||||
var remainingPercent: Int? {
|
||||
guard let usedPercent, usedPercent.isFinite else { return nil }
|
||||
let remaining = max(0, min(100, Int(round(100 - usedPercent))))
|
||||
return remaining
|
||||
}
|
||||
|
||||
func detailText(now: Date = .init()) -> String {
|
||||
if let error, !error.isEmpty { return error }
|
||||
guard let remaining = self.remainingPercent else { return "No data" }
|
||||
var parts = ["\(remaining)% left"]
|
||||
if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) }
|
||||
if let resetAt {
|
||||
let reset = UsageRow.formatResetRemaining(target: resetAt, now: now)
|
||||
if let reset { parts.append("⏱\(reset)") }
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private static func formatResetRemaining(target: Date, now: Date) -> String? {
|
||||
let diff = target.timeIntervalSince(now)
|
||||
if diff <= 0 { return "now" }
|
||||
let minutes = Int(floor(diff / 60))
|
||||
if minutes < 60 { return "\(minutes)m" }
|
||||
let hours = minutes / 60
|
||||
let mins = minutes % 60
|
||||
if hours < 24 { return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" }
|
||||
let days = hours / 24
|
||||
if days < 7 { return "\(days)d \(hours % 24)h" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
return formatter.string(from: target)
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayUsageSummary {
|
||||
func primaryRows() -> [UsageRow] {
|
||||
self.providers.compactMap { provider in
|
||||
if let error = provider.error, provider.windows.isEmpty {
|
||||
return UsageRow(
|
||||
id: provider.provider,
|
||||
displayName: provider.displayName,
|
||||
plan: provider.plan,
|
||||
windowLabel: nil,
|
||||
usedPercent: nil,
|
||||
resetAt: nil,
|
||||
error: error)
|
||||
}
|
||||
|
||||
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UsageRow(
|
||||
id: "\(provider.provider)-\(window.label)",
|
||||
displayName: provider.displayName,
|
||||
plan: provider.plan,
|
||||
windowLabel: window.label,
|
||||
usedPercent: window.usedPercent,
|
||||
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
|
||||
error: provider.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum UsageLoader {
|
||||
static func loadSummary() async throws -> GatewayUsageSummary {
|
||||
let data = try await ControlChannel.shared.request(
|
||||
method: "usage.status",
|
||||
params: nil,
|
||||
timeoutMs: 5000)
|
||||
return try JSONDecoder().decode(GatewayUsageSummary.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UsageMenuLabelView: View {
|
||||
let row: UsageRow
|
||||
let width: CGFloat
|
||||
private let paddingLeading: CGFloat = 22
|
||||
private let paddingTrailing: CGFloat = 14
|
||||
private let barHeight: CGFloat = 6
|
||||
|
||||
private var primaryTextColor: Color { .primary }
|
||||
private var secondaryTextColor: Color { .secondary }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let used = row.usedPercent {
|
||||
ContextUsageBar(
|
||||
usedTokens: Int(round(used)),
|
||||
contextTokens: 100,
|
||||
width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
|
||||
height: self.barHeight)
|
||||
}
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(row.titleText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.primaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Text(row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
|
||||
var thinking: String = "low"
|
||||
var deliver: Bool = true
|
||||
var to: String?
|
||||
var provider: GatewayAgentProvider = .last
|
||||
var channel: GatewayAgentChannel = .last
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
|
||||
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
|
||||
{
|
||||
let payload = Self.prefixedTranscript(transcript)
|
||||
let deliver = options.provider.shouldDeliver(options.deliver)
|
||||
let deliver = options.channel.shouldDeliver(options.deliver)
|
||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: payload,
|
||||
sessionKey: options.sessionKey,
|
||||
thinking: options.thinking,
|
||||
deliver: deliver,
|
||||
to: options.to,
|
||||
provider: options.provider))
|
||||
channel: options.channel))
|
||||
|
||||
if result.ok {
|
||||
self.logger.info("voice wake forward ok")
|
||||
|
||||
@@ -3,14 +3,14 @@ import Foundation
|
||||
|
||||
public let GATEWAY_PROTOCOL_VERSION = 2
|
||||
|
||||
public enum ErrorCode: String, Codable, Sendable {
|
||||
public enum ErrorCode: String, Codable {
|
||||
case notLinked = "NOT_LINKED"
|
||||
case agentTimeout = "AGENT_TIMEOUT"
|
||||
case invalidRequest = "INVALID_REQUEST"
|
||||
case unavailable = "UNAVAILABLE"
|
||||
}
|
||||
|
||||
public struct ConnectParams: Codable, Sendable {
|
||||
public struct ConnectParams: Codable {
|
||||
public let minprotocol: Int
|
||||
public let maxprotocol: Int
|
||||
public let client: [String: AnyCodable]
|
||||
@@ -47,7 +47,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct HelloOk: Codable, Sendable {
|
||||
public struct HelloOk: Codable {
|
||||
public let type: String
|
||||
public let _protocol: Int
|
||||
public let server: [String: AnyCodable]
|
||||
@@ -84,7 +84,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct RequestFrame: Codable, Sendable {
|
||||
public struct RequestFrame: Codable {
|
||||
public let type: String
|
||||
public let id: String
|
||||
public let method: String
|
||||
@@ -109,7 +109,7 @@ public struct RequestFrame: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ResponseFrame: Codable, Sendable {
|
||||
public struct ResponseFrame: Codable {
|
||||
public let type: String
|
||||
public let id: String
|
||||
public let ok: Bool
|
||||
@@ -138,7 +138,7 @@ public struct ResponseFrame: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct EventFrame: Codable, Sendable {
|
||||
public struct EventFrame: Codable {
|
||||
public let type: String
|
||||
public let event: String
|
||||
public let payload: AnyCodable?
|
||||
@@ -167,7 +167,7 @@ public struct EventFrame: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct PresenceEntry: Codable, Sendable {
|
||||
public struct PresenceEntry: Codable {
|
||||
public let host: String?
|
||||
public let ip: String?
|
||||
public let version: String?
|
||||
@@ -228,7 +228,7 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct StateVersion: Codable, Sendable {
|
||||
public struct StateVersion: Codable {
|
||||
public let presence: Int
|
||||
public let health: Int
|
||||
|
||||
@@ -245,7 +245,7 @@ public struct StateVersion: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct Snapshot: Codable, Sendable {
|
||||
public struct Snapshot: Codable {
|
||||
public let presence: [PresenceEntry]
|
||||
public let health: AnyCodable
|
||||
public let stateversion: StateVersion
|
||||
@@ -278,7 +278,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ErrorShape: Codable, Sendable {
|
||||
public struct ErrorShape: Codable {
|
||||
public let code: String
|
||||
public let message: String
|
||||
public let details: AnyCodable?
|
||||
@@ -307,7 +307,7 @@ public struct ErrorShape: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentEvent: Codable, Sendable {
|
||||
public struct AgentEvent: Codable {
|
||||
public let runid: String
|
||||
public let seq: Int
|
||||
public let stream: String
|
||||
@@ -336,13 +336,12 @@ public struct AgentEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SendParams: Codable, Sendable {
|
||||
public struct SendParams: Codable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let gifplayback: Bool?
|
||||
public let provider: String?
|
||||
public let accountid: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
@@ -351,7 +350,6 @@ public struct SendParams: Codable, Sendable {
|
||||
mediaurl: String?,
|
||||
gifplayback: Bool?,
|
||||
provider: String?,
|
||||
accountid: String?,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.to = to
|
||||
@@ -359,7 +357,6 @@ public struct SendParams: Codable, Sendable {
|
||||
self.mediaurl = mediaurl
|
||||
self.gifplayback = gifplayback
|
||||
self.provider = provider
|
||||
self.accountid = accountid
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -368,60 +365,18 @@ public struct SendParams: Codable, Sendable {
|
||||
case mediaurl = "mediaUrl"
|
||||
case gifplayback = "gifPlayback"
|
||||
case provider
|
||||
case accountid = "accountId"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct PollParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let question: String
|
||||
public let options: [String]
|
||||
public let maxselections: Int?
|
||||
public let durationhours: Int?
|
||||
public let provider: String?
|
||||
public let accountid: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
to: String,
|
||||
question: String,
|
||||
options: [String],
|
||||
maxselections: Int?,
|
||||
durationhours: Int?,
|
||||
provider: String?,
|
||||
accountid: String?,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.to = to
|
||||
self.question = question
|
||||
self.options = options
|
||||
self.maxselections = maxselections
|
||||
self.durationhours = durationhours
|
||||
self.provider = provider
|
||||
self.accountid = accountid
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case to
|
||||
case question
|
||||
case options
|
||||
case maxselections = "maxSelections"
|
||||
case durationhours = "durationHours"
|
||||
case provider
|
||||
case accountid = "accountId"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentParams: Codable, Sendable {
|
||||
public struct AgentParams: Codable {
|
||||
public let message: String
|
||||
public let to: String?
|
||||
public let sessionid: String?
|
||||
public let sessionkey: String?
|
||||
public let thinking: String?
|
||||
public let deliver: Bool?
|
||||
public let provider: String?
|
||||
public let channel: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -434,7 +389,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
sessionkey: String?,
|
||||
thinking: String?,
|
||||
deliver: Bool?,
|
||||
provider: String?,
|
||||
channel: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -446,7 +401,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.sessionkey = sessionkey
|
||||
self.thinking = thinking
|
||||
self.deliver = deliver
|
||||
self.provider = provider
|
||||
self.channel = channel
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -459,7 +414,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case thinking
|
||||
case deliver
|
||||
case provider
|
||||
case channel
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
@@ -467,24 +422,28 @@ public struct AgentParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentWaitParams: Codable, Sendable {
|
||||
public struct AgentWaitParams: Codable {
|
||||
public let runid: String
|
||||
public let afterms: Int?
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
runid: String,
|
||||
afterms: Int?,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.runid = runid
|
||||
self.afterms = afterms
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case afterms = "afterMs"
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeParams: Codable, Sendable {
|
||||
public struct WakeParams: Codable {
|
||||
public let mode: AnyCodable
|
||||
public let text: String
|
||||
|
||||
@@ -501,7 +460,7 @@ public struct WakeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRequestParams: Codable, Sendable {
|
||||
public struct NodePairRequestParams: Codable {
|
||||
public let nodeid: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
@@ -550,10 +509,10 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairListParams: Codable, Sendable {
|
||||
public struct NodePairListParams: Codable {
|
||||
}
|
||||
|
||||
public struct NodePairApproveParams: Codable, Sendable {
|
||||
public struct NodePairApproveParams: Codable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
@@ -566,7 +525,7 @@ public struct NodePairApproveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRejectParams: Codable, Sendable {
|
||||
public struct NodePairRejectParams: Codable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
@@ -579,7 +538,7 @@ public struct NodePairRejectParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairVerifyParams: Codable, Sendable {
|
||||
public struct NodePairVerifyParams: Codable {
|
||||
public let nodeid: String
|
||||
public let token: String
|
||||
|
||||
@@ -596,7 +555,7 @@ public struct NodePairVerifyParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeRenameParams: Codable, Sendable {
|
||||
public struct NodeRenameParams: Codable {
|
||||
public let nodeid: String
|
||||
public let displayname: String
|
||||
|
||||
@@ -613,10 +572,10 @@ public struct NodeRenameParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeListParams: Codable, Sendable {
|
||||
public struct NodeListParams: Codable {
|
||||
}
|
||||
|
||||
public struct NodeDescribeParams: Codable, Sendable {
|
||||
public struct NodeDescribeParams: Codable {
|
||||
public let nodeid: String
|
||||
|
||||
public init(
|
||||
@@ -629,7 +588,7 @@ public struct NodeDescribeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeParams: Codable, Sendable {
|
||||
public struct NodeInvokeParams: Codable {
|
||||
public let nodeid: String
|
||||
public let command: String
|
||||
public let params: AnyCodable?
|
||||
@@ -658,43 +617,37 @@ public struct NodeInvokeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsListParams: Codable, Sendable {
|
||||
public struct SessionsListParams: Codable {
|
||||
public let limit: Int?
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let spawnedby: String?
|
||||
|
||||
public init(
|
||||
limit: Int?,
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
spawnedby: String?
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.limit = limit
|
||||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.spawnedby = spawnedby
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case limit
|
||||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case spawnedby = "spawnedBy"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public struct SessionsPatchParams: Codable {
|
||||
public let key: String
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let verboselevel: AnyCodable?
|
||||
public let reasoninglevel: AnyCodable?
|
||||
public let elevatedlevel: AnyCodable?
|
||||
public let model: AnyCodable?
|
||||
public let spawnedby: AnyCodable?
|
||||
public let sendpolicy: AnyCodable?
|
||||
public let groupactivation: AnyCodable?
|
||||
|
||||
@@ -702,20 +655,16 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
key: String,
|
||||
thinkinglevel: AnyCodable?,
|
||||
verboselevel: AnyCodable?,
|
||||
reasoninglevel: AnyCodable?,
|
||||
elevatedlevel: AnyCodable?,
|
||||
model: AnyCodable?,
|
||||
spawnedby: AnyCodable?,
|
||||
sendpolicy: AnyCodable?,
|
||||
groupactivation: AnyCodable?
|
||||
) {
|
||||
self.key = key
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.verboselevel = verboselevel
|
||||
self.reasoninglevel = reasoninglevel
|
||||
self.elevatedlevel = elevatedlevel
|
||||
self.model = model
|
||||
self.spawnedby = spawnedby
|
||||
self.sendpolicy = sendpolicy
|
||||
self.groupactivation = groupactivation
|
||||
}
|
||||
@@ -723,16 +672,14 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
case key
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case verboselevel = "verboseLevel"
|
||||
case reasoninglevel = "reasoningLevel"
|
||||
case elevatedlevel = "elevatedLevel"
|
||||
case model
|
||||
case spawnedby = "spawnedBy"
|
||||
case sendpolicy = "sendPolicy"
|
||||
case groupactivation = "groupActivation"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsResetParams: Codable, Sendable {
|
||||
public struct SessionsResetParams: Codable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
@@ -745,7 +692,7 @@ public struct SessionsResetParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsDeleteParams: Codable, Sendable {
|
||||
public struct SessionsDeleteParams: Codable {
|
||||
public let key: String
|
||||
public let deletetranscript: Bool?
|
||||
|
||||
@@ -762,7 +709,7 @@ public struct SessionsDeleteParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactParams: Codable, Sendable {
|
||||
public struct SessionsCompactParams: Codable {
|
||||
public let key: String
|
||||
public let maxlines: Int?
|
||||
|
||||
@@ -779,10 +726,10 @@ public struct SessionsCompactParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigGetParams: Codable, Sendable {
|
||||
public struct ConfigGetParams: Codable {
|
||||
}
|
||||
|
||||
public struct ConfigSetParams: Codable, Sendable {
|
||||
public struct ConfigSetParams: Codable {
|
||||
public let raw: String
|
||||
|
||||
public init(
|
||||
@@ -795,10 +742,10 @@ public struct ConfigSetParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigSchemaParams: Codable, Sendable {
|
||||
public struct ConfigSchemaParams: Codable {
|
||||
}
|
||||
|
||||
public struct ConfigSchemaResponse: Codable, Sendable {
|
||||
public struct ConfigSchemaResponse: Codable {
|
||||
public let schema: AnyCodable
|
||||
public let uihints: [String: AnyCodable]
|
||||
public let version: String
|
||||
@@ -823,7 +770,7 @@ public struct ConfigSchemaResponse: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStartParams: Codable, Sendable {
|
||||
public struct WizardStartParams: Codable {
|
||||
public let mode: AnyCodable?
|
||||
public let workspace: String?
|
||||
|
||||
@@ -840,7 +787,7 @@ public struct WizardStartParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardNextParams: Codable, Sendable {
|
||||
public struct WizardNextParams: Codable {
|
||||
public let sessionid: String
|
||||
public let answer: [String: AnyCodable]?
|
||||
|
||||
@@ -857,7 +804,7 @@ public struct WizardNextParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardCancelParams: Codable, Sendable {
|
||||
public struct WizardCancelParams: Codable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
@@ -870,7 +817,7 @@ public struct WizardCancelParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStatusParams: Codable, Sendable {
|
||||
public struct WizardStatusParams: Codable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
@@ -883,7 +830,7 @@ public struct WizardStatusParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStep: Codable, Sendable {
|
||||
public struct WizardStep: Codable {
|
||||
public let id: String
|
||||
public let type: AnyCodable
|
||||
public let title: String?
|
||||
@@ -928,7 +875,7 @@ public struct WizardStep: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardNextResult: Codable, Sendable {
|
||||
public struct WizardNextResult: Codable {
|
||||
public let done: Bool
|
||||
public let step: [String: AnyCodable]?
|
||||
public let status: AnyCodable?
|
||||
@@ -953,7 +900,7 @@ public struct WizardNextResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStartResult: Codable, Sendable {
|
||||
public struct WizardStartResult: Codable {
|
||||
public let sessionid: String
|
||||
public let done: Bool
|
||||
public let step: [String: AnyCodable]?
|
||||
@@ -982,7 +929,7 @@ public struct WizardStartResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStatusResult: Codable, Sendable {
|
||||
public struct WizardStatusResult: Codable {
|
||||
public let status: AnyCodable
|
||||
public let error: String?
|
||||
|
||||
@@ -999,7 +946,7 @@ public struct WizardStatusResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkModeParams: Codable, Sendable {
|
||||
public struct TalkModeParams: Codable {
|
||||
public let enabled: Bool
|
||||
public let phase: String?
|
||||
|
||||
@@ -1016,7 +963,7 @@ public struct TalkModeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProvidersStatusParams: Codable, Sendable {
|
||||
public struct ProvidersStatusParams: Codable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
|
||||
@@ -1033,49 +980,41 @@ public struct ProvidersStatusParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginStartParams: Codable, Sendable {
|
||||
public struct WebLoginStartParams: Codable {
|
||||
public let force: Bool?
|
||||
public let timeoutms: Int?
|
||||
public let verbose: Bool?
|
||||
public let accountid: String?
|
||||
|
||||
public init(
|
||||
force: Bool?,
|
||||
timeoutms: Int?,
|
||||
verbose: Bool?,
|
||||
accountid: String?
|
||||
verbose: Bool?
|
||||
) {
|
||||
self.force = force
|
||||
self.timeoutms = timeoutms
|
||||
self.verbose = verbose
|
||||
self.accountid = accountid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case force
|
||||
case timeoutms = "timeoutMs"
|
||||
case verbose
|
||||
case accountid = "accountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginWaitParams: Codable, Sendable {
|
||||
public struct WebLoginWaitParams: Codable {
|
||||
public let timeoutms: Int?
|
||||
public let accountid: String?
|
||||
|
||||
public init(
|
||||
timeoutms: Int?,
|
||||
accountid: String?
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.timeoutms = timeoutms
|
||||
self.accountid = accountid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case timeoutms = "timeoutMs"
|
||||
case accountid = "accountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelChoice: Codable, Sendable {
|
||||
public struct ModelChoice: Codable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let provider: String
|
||||
@@ -1104,10 +1043,10 @@ public struct ModelChoice: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelsListParams: Codable, Sendable {
|
||||
public struct ModelsListParams: Codable {
|
||||
}
|
||||
|
||||
public struct ModelsListResult: Codable, Sendable {
|
||||
public struct ModelsListResult: Codable {
|
||||
public let models: [ModelChoice]
|
||||
|
||||
public init(
|
||||
@@ -1120,10 +1059,10 @@ public struct ModelsListResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsStatusParams: Codable, Sendable {
|
||||
public struct SkillsStatusParams: Codable {
|
||||
}
|
||||
|
||||
public struct SkillsInstallParams: Codable, Sendable {
|
||||
public struct SkillsInstallParams: Codable {
|
||||
public let name: String
|
||||
public let installid: String
|
||||
public let timeoutms: Int?
|
||||
@@ -1144,7 +1083,7 @@ public struct SkillsInstallParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsUpdateParams: Codable, Sendable {
|
||||
public struct SkillsUpdateParams: Codable {
|
||||
public let skillkey: String
|
||||
public let enabled: Bool?
|
||||
public let apikey: String?
|
||||
@@ -1169,7 +1108,7 @@ public struct SkillsUpdateParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronJob: Codable, Sendable {
|
||||
public struct CronJob: Codable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let description: String?
|
||||
@@ -1226,7 +1165,7 @@ public struct CronJob: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronListParams: Codable, Sendable {
|
||||
public struct CronListParams: Codable {
|
||||
public let includedisabled: Bool?
|
||||
|
||||
public init(
|
||||
@@ -1239,10 +1178,10 @@ public struct CronListParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronStatusParams: Codable, Sendable {
|
||||
public struct CronStatusParams: Codable {
|
||||
}
|
||||
|
||||
public struct CronAddParams: Codable, Sendable {
|
||||
public struct CronAddParams: Codable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
@@ -1283,7 +1222,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronUpdateParams: Codable, Sendable {
|
||||
public struct CronUpdateParams: Codable {
|
||||
public let id: String
|
||||
public let patch: [String: AnyCodable]
|
||||
|
||||
@@ -1300,7 +1239,7 @@ public struct CronUpdateParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRemoveParams: Codable, Sendable {
|
||||
public struct CronRemoveParams: Codable {
|
||||
public let id: String
|
||||
|
||||
public init(
|
||||
@@ -1313,7 +1252,7 @@ public struct CronRemoveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunParams: Codable, Sendable {
|
||||
public struct CronRunParams: Codable {
|
||||
public let id: String
|
||||
public let mode: AnyCodable?
|
||||
|
||||
@@ -1330,7 +1269,7 @@ public struct CronRunParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunsParams: Codable, Sendable {
|
||||
public struct CronRunsParams: Codable {
|
||||
public let id: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1347,7 +1286,7 @@ public struct CronRunsParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunLogEntry: Codable, Sendable {
|
||||
public struct CronRunLogEntry: Codable {
|
||||
public let ts: Int
|
||||
public let jobid: String
|
||||
public let action: String
|
||||
@@ -1392,7 +1331,7 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatHistoryParams: Codable, Sendable {
|
||||
public struct ChatHistoryParams: Codable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1409,7 +1348,7 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatSendParams: Codable, Sendable {
|
||||
public struct ChatSendParams: Codable {
|
||||
public let sessionkey: String
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
@@ -1446,7 +1385,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatAbortParams: Codable, Sendable {
|
||||
public struct ChatAbortParams: Codable {
|
||||
public let sessionkey: String
|
||||
public let runid: String
|
||||
|
||||
@@ -1463,7 +1402,7 @@ public struct ChatAbortParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatEvent: Codable, Sendable {
|
||||
public struct ChatEvent: Codable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let seq: Int
|
||||
@@ -1504,7 +1443,7 @@ public struct ChatEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TickEvent: Codable, Sendable {
|
||||
public struct TickEvent: Codable {
|
||||
public let ts: Int
|
||||
|
||||
public init(
|
||||
@@ -1517,7 +1456,7 @@ public struct TickEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ShutdownEvent: Codable, Sendable {
|
||||
public struct ShutdownEvent: Codable {
|
||||
public let reason: String
|
||||
public let restartexpectedms: Int?
|
||||
|
||||
@@ -1534,7 +1473,7 @@ public struct ShutdownEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayFrame: Codable, Sendable {
|
||||
public enum GatewayFrame: Codable {
|
||||
case req(RequestFrame)
|
||||
case res(ResponseFrame)
|
||||
case event(EventFrame)
|
||||
|
||||
@@ -16,52 +16,6 @@ struct ClawdbotConfigFileTests {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func remoteGatewayPortParsesAndMatchesHost() {
|
||||
let override = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://bridge.ts.net:19999",
|
||||
],
|
||||
],
|
||||
])
|
||||
#expect(ClawdbotConfigFile.remoteGatewayPort() == 19999)
|
||||
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge.ts.net") == 19999)
|
||||
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge") == 19999)
|
||||
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func setRemoteGatewayUrlPreservesScheme() {
|
||||
let override = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "wss://old-host:111",
|
||||
],
|
||||
],
|
||||
])
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: "new-host", port: 2222)
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String
|
||||
#expect(url == "wss://new-host:2222")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func stateDirOverrideSetsConfigPath() {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
|
||||
@@ -69,13 +69,11 @@ struct GatewayDiscoveryModelTests {
|
||||
"lanHost": " studio.local ",
|
||||
"tailnetDns": " peters-mac-studio-1.ts.net ",
|
||||
"sshPort": " 2222 ",
|
||||
"gatewayPort": " 18799 ",
|
||||
"cliPath": " /opt/clawdbot "
|
||||
])
|
||||
#expect(parsed.lanHost == "studio.local")
|
||||
#expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net")
|
||||
#expect(parsed.sshPort == 2222)
|
||||
#expect(parsed.gatewayPort == 18799)
|
||||
#expect(parsed.cliPath == "/opt/clawdbot")
|
||||
}
|
||||
|
||||
@@ -83,13 +81,11 @@ struct GatewayDiscoveryModelTests {
|
||||
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
|
||||
"lanHost": " ",
|
||||
"tailnetDns": "\n",
|
||||
"gatewayPort": "nope",
|
||||
"sshPort": "nope"
|
||||
])
|
||||
#expect(parsed.lanHost == nil)
|
||||
#expect(parsed.tailnetDns == nil)
|
||||
#expect(parsed.sshPort == 22)
|
||||
#expect(parsed.gatewayPort == nil)
|
||||
#expect(parsed.cliPath == nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,156 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayEndpointStoreTests {
|
||||
@Test func resolvesLocalHostFromBindModes() {
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "loopback",
|
||||
tailscaleIP: "100.64.0.10") == "127.0.0.1")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "lan",
|
||||
tailscaleIP: "100.64.0.10") == "127.0.0.1")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "tailnet",
|
||||
tailscaleIP: "100.64.0.10") == "100.64.0.10")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "tailnet",
|
||||
tailscaleIP: nil) == "127.0.0.1")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "auto",
|
||||
tailscaleIP: "100.64.0.10") == "100.64.0.10")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "auto",
|
||||
tailscaleIP: nil) == "127.0.0.1")
|
||||
@Suite struct GatewayEndpointStoreTests {
|
||||
private final class ModeBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value: AppState.ConnectionMode
|
||||
|
||||
init(_ initial: AppState.ConnectionMode) {
|
||||
self.value = initial
|
||||
}
|
||||
|
||||
func get() -> AppState.ConnectionMode {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.value
|
||||
}
|
||||
|
||||
func set(_ next: AppState.ConnectionMode) {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
self.value = next
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolvesBindModeFromEnvOrConfig() {
|
||||
let root: [String: Any] = ["gateway": ["bind": "tailnet"]]
|
||||
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
|
||||
@Test func localRefreshResolvesToLocalhostPort() async throws {
|
||||
let mode = ModeBox(.local)
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { "t" },
|
||||
password: { nil },
|
||||
localPort: { 1234 },
|
||||
remotePortIfRunning: { nil },
|
||||
ensureRemoteTunnel: { 18789 }))
|
||||
|
||||
await store.refresh()
|
||||
let cfg = try await store.requireConfig()
|
||||
#expect(cfg.url.absoluteString == "ws://127.0.0.1:1234")
|
||||
#expect(cfg.token == "t")
|
||||
}
|
||||
|
||||
@Test func remoteWithoutTunnelRecoversByEnsuringTunnel() async throws {
|
||||
let mode = ModeBox(.remote)
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { nil },
|
||||
password: { nil },
|
||||
localPort: { 18789 },
|
||||
remotePortIfRunning: { nil },
|
||||
ensureRemoteTunnel: { 18789 }))
|
||||
|
||||
let cfg = try await store.requireConfig()
|
||||
#expect(cfg.url.absoluteString == "ws://127.0.0.1:18789")
|
||||
#expect(cfg.token == nil)
|
||||
}
|
||||
|
||||
@Test func ensureRemoteTunnelPublishesReadyState() async throws {
|
||||
let mode = ModeBox(.remote)
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { "tok" },
|
||||
password: { "pw" },
|
||||
localPort: { 1 },
|
||||
remotePortIfRunning: { 5555 },
|
||||
ensureRemoteTunnel: { 5555 }))
|
||||
|
||||
let stream = await store.subscribe(bufferingNewest: 10)
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
|
||||
_ = await iterator.next() // initial
|
||||
_ = try await store.ensureRemoteControlTunnel()
|
||||
|
||||
let next = await iterator.next()
|
||||
guard case let .ready(mode, url, token, password) = next else {
|
||||
Issue.record("expected .ready after ensure, got \(String(describing: next))")
|
||||
return
|
||||
}
|
||||
#expect(mode == .remote)
|
||||
#expect(url.absoluteString == "ws://127.0.0.1:5555")
|
||||
#expect(token == "tok")
|
||||
#expect(password == "pw")
|
||||
}
|
||||
|
||||
@Test func resolvesGatewayPasswordByMode() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": ["password": " local "],
|
||||
"remote": ["password": " remote "],
|
||||
],
|
||||
]
|
||||
let env: [String: String] = [:]
|
||||
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: [:]) == "tailnet")
|
||||
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
|
||||
env: env) == "local")
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: true,
|
||||
root: root,
|
||||
env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan")
|
||||
env: env) == "remote")
|
||||
}
|
||||
|
||||
@Test func gatewayPasswordEnvOverridesConfig() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": ["password": "local"],
|
||||
"remote": ["password": "remote"],
|
||||
],
|
||||
]
|
||||
let env = ["CLAWDBOT_GATEWAY_PASSWORD": " env "]
|
||||
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env) == "env")
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: true,
|
||||
root: root,
|
||||
env: env) == "env")
|
||||
}
|
||||
|
||||
@Test func gatewayPasswordIgnoresWhitespaceValues() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": ["password": " "],
|
||||
"remote": ["password": "\n\t"],
|
||||
],
|
||||
]
|
||||
let env = ["CLAWDBOT_GATEWAY_PASSWORD": " "]
|
||||
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env) == nil)
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: true,
|
||||
root: root,
|
||||
env: env) == nil)
|
||||
}
|
||||
|
||||
@Test func unconfiguredModeRejectsConfig() async {
|
||||
let mode = ModeBox(.unconfigured)
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { nil },
|
||||
password: { nil },
|
||||
localPort: { 18789 },
|
||||
remotePortIfRunning: { nil },
|
||||
ensureRemoteTunnel: { 18789 }))
|
||||
|
||||
await #expect(throws: Error.self) {
|
||||
_ = try await store.requireConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,70 +45,6 @@ import Testing
|
||||
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4)
|
||||
#expect(ok == false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func remoteBridgePortUsesMatchingRemoteUrlPort() {
|
||||
let configPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let prevTarget = defaults.string(forKey: remoteTargetKey)
|
||||
defer {
|
||||
if let prevTarget {
|
||||
defaults.set(prevTarget, forKey: remoteTargetKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: remoteTargetKey)
|
||||
}
|
||||
}
|
||||
|
||||
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
|
||||
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
|
||||
defaults.set("user@bridge.ts.net", forKey: remoteTargetKey)
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://bridge.ts.net:25000",
|
||||
],
|
||||
],
|
||||
])
|
||||
#expect(MacNodeModeCoordinator.remoteBridgePort() == 25001)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func remoteBridgePortFallsBackWhenRemoteUrlHostMismatch() {
|
||||
let configPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let prevTarget = defaults.string(forKey: remoteTargetKey)
|
||||
defer {
|
||||
if let prevTarget {
|
||||
defaults.set(prevTarget, forKey: remoteTargetKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: remoteTargetKey)
|
||||
}
|
||||
}
|
||||
|
||||
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
|
||||
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
|
||||
defaults.set("user@other.ts.net", forKey: remoteTargetKey)
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://bridge.ts.net:25000",
|
||||
],
|
||||
],
|
||||
])
|
||||
#expect(MacNodeModeCoordinator.remoteBridgePort() == 20001)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestError: Error {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Testing
|
||||
import ClawdbotIPC
|
||||
import CoreLocation
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
@@ -23,16 +22,4 @@ struct PermissionManagerTests {
|
||||
let ensured = await PermissionManager.ensure(caps, interactive: false)
|
||||
#expect(ensured.keys.count == caps.count)
|
||||
}
|
||||
|
||||
@Test func locationStatusMatchesAuthorizationAlways() async {
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let results = await PermissionManager.status([.location])
|
||||
#expect(results[.location] == (status == .authorizedAlways))
|
||||
}
|
||||
|
||||
@Test func ensureLocationNonInteractiveMatchesAuthorizationAlways() async {
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let ensured = await PermissionManager.ensure([.location], interactive: false)
|
||||
#expect(ensured[.location] == (status == .authorizedAlways))
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"id",
|
||||
"jobId",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
@@ -136,10 +136,10 @@
|
||||
"label": "add",
|
||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||
},
|
||||
"update": { "label": "update", "detailKeys": ["id"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["id"] },
|
||||
"run": { "label": "run", "detailKeys": ["id"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["id"] },
|
||||
"update": { "label": "update", "detailKeys": ["jobId"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["jobId"] },
|
||||
"run": { "label": "run", "detailKeys": ["jobId"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["jobId"] },
|
||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||
}
|
||||
},
|
||||
|
||||
+18
-15
@@ -1,17 +1,20 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/biome.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentWidth": 2,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"files": {
|
||||
"includes": ["src/**/*.ts", "test/**/*.ts"]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
"$schema": "https://biomejs.dev/schemas/biome.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentWidth": 2,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,26 +8,26 @@ read_when:
|
||||
|
||||
## First run (recommended)
|
||||
|
||||
Clawdbot uses a dedicated workspace directory for the agent. Default: `~/clawd` (configurable via `agent.workspace`).
|
||||
Clawdbot uses a dedicated workspace directory for the agent. Default: `~/.clawdbot/workspace`.
|
||||
|
||||
1) Create the workspace (if it doesn’t already exist):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/clawd
|
||||
mkdir -p ~/.clawdbot/workspace
|
||||
```
|
||||
|
||||
2) Copy the default workspace templates into the workspace:
|
||||
|
||||
```bash
|
||||
cp docs/reference/templates/AGENTS.md ~/clawd/AGENTS.md
|
||||
cp docs/reference/templates/SOUL.md ~/clawd/SOUL.md
|
||||
cp docs/reference/templates/TOOLS.md ~/clawd/TOOLS.md
|
||||
cp docs/templates/AGENTS.md ~/.clawdbot/workspace/AGENTS.md
|
||||
cp docs/templates/SOUL.md ~/.clawdbot/workspace/SOUL.md
|
||||
cp docs/templates/TOOLS.md ~/.clawdbot/workspace/TOOLS.md
|
||||
```
|
||||
|
||||
3) Optional: if you want the personal assistant skill roster, replace AGENTS.md with this file:
|
||||
|
||||
```bash
|
||||
cp docs/reference/AGENTS.default.md ~/clawd/AGENTS.md
|
||||
cp docs/AGENTS.default.md ~/.clawdbot/workspace/AGENTS.md
|
||||
```
|
||||
|
||||
4) Optional: choose a different workspace by setting `agent.workspace` (supports `~`):
|
||||
@@ -73,7 +73,7 @@ cp docs/reference/AGENTS.default.md ~/clawd/AGENTS.md
|
||||
If you treat this workspace as Clawd’s “memory”, make it a git repo (ideally private) so `AGENTS.md` and your memory files are backed up.
|
||||
|
||||
```bash
|
||||
cd ~/clawd
|
||||
cd ~/.clawdbot/workspace
|
||||
git init
|
||||
git add AGENTS.md
|
||||
git commit -m "Add Clawd workspace"
|
||||
@@ -83,7 +83,7 @@ git commit -m "Add Clawd workspace"
|
||||
## What Clawdbot Does
|
||||
- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac.
|
||||
- macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdbot` CLI via its bundled binary.
|
||||
- Direct chats collapse into the agent's `main` session by default; groups stay isolated as `agent:<agentId>:<provider>:group:<id>` (rooms/channels: `agent:<agentId>:<provider>:channel:<id>`); heartbeats keep background tasks alive.
|
||||
- Direct chats collapse into the shared `main` session by default; groups stay isolated as `surface:group:<id>` (rooms: `surface:channel:<id>`); heartbeats keep background tasks alive.
|
||||
|
||||
## Core Skills (enable in Settings → Skills)
|
||||
- **mcporter** — Tool server runtime/CLI for managing external skill backends.
|
||||
@@ -12,12 +12,12 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
|
||||
1) **Version & metadata**
|
||||
- [ ] Bump `package.json` version (e.g., `1.1.0`).
|
||||
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
|
||||
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
|
||||
- [ ] Update CLI/version strings: `src/cli/program.ts` and the Baileys user agent in `src/provider-web.ts`.
|
||||
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to `dist/index.js` for `clawdbot`.
|
||||
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
|
||||
|
||||
2) **Build & artifacts**
|
||||
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
||||
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated `src/canvas-host/a2ui/a2ui.bundle.js`.
|
||||
- [ ] `pnpm run build` (regenerates `dist/`).
|
||||
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
||||
|
||||
@@ -34,11 +34,11 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
|
||||
5) **macOS app (Sparkle)**
|
||||
- [ ] Build + sign the macOS app, then zip it for distribution.
|
||||
- [ ] Generate the Sparkle appcast (HTML notes via [`scripts/make_appcast.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/make_appcast.sh)) and update `appcast.xml`.
|
||||
- [ ] Generate the Sparkle appcast (HTML notes via `scripts/make_appcast.sh`) and update `appcast.xml`.
|
||||
- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release.
|
||||
- [ ] Follow [`docs/mac/release.md`](/platforms/mac/release) for the exact commands and required env vars.
|
||||
- [ ] Follow `docs/mac/release.md` for the exact commands and required env vars.
|
||||
- `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly.
|
||||
- If notarizing, use the `clawdbot-notary` keychain profile created from App Store Connect API env vars (see [`docs/mac/release.md`](/platforms/mac/release)).
|
||||
- If notarizing, use the `clawdbot-notary` keychain profile created from App Store Connect API env vars (see `docs/mac/release.md`).
|
||||
|
||||
6) **Publish (npm)**
|
||||
- [ ] Confirm git status is clean; commit and push as needed.
|
||||
+13
-15
@@ -1,5 +1,5 @@
|
||||
title: "CLAWDBOT Docs"
|
||||
description: "A TypeScript/Node gateway + macOS/iOS/Android companions for WhatsApp (web) and Telegram (bot)."
|
||||
description: "A TypeScript/Node gateway + macOS/iOS companions for WhatsApp (web) and Telegram (bot)."
|
||||
markdown: kramdown
|
||||
highlighter: rouge
|
||||
|
||||
@@ -23,29 +23,27 @@ nav:
|
||||
- title: "Home"
|
||||
url: "/"
|
||||
- title: "Clawd Setup"
|
||||
url: "/start/clawd/"
|
||||
url: "/clawd.html"
|
||||
- title: "Gateway"
|
||||
url: "/gateway/"
|
||||
url: "/gateway.html"
|
||||
- title: "Remote"
|
||||
url: "/gateway/remote/"
|
||||
url: "/remote.html"
|
||||
- title: "Discovery"
|
||||
url: "/gateway/discovery/"
|
||||
url: "/discovery.html"
|
||||
- title: "Configuration"
|
||||
url: "/gateway/configuration/"
|
||||
url: "/configuration.html"
|
||||
- title: "WebChat"
|
||||
url: "/web/webchat/"
|
||||
url: "/webchat.html"
|
||||
- title: "macOS App"
|
||||
url: "/platforms/macos/"
|
||||
- title: "iOS App"
|
||||
url: "/platforms/ios/"
|
||||
- title: "Android App"
|
||||
url: "/platforms/android/"
|
||||
url: "/clawdbot-mac.html"
|
||||
- title: "iOS Node"
|
||||
url: "/ios/connect.html"
|
||||
- title: "Telegram"
|
||||
url: "/providers/telegram/"
|
||||
url: "/telegram.html"
|
||||
- title: "Security"
|
||||
url: "/gateway/security/"
|
||||
url: "/security.html"
|
||||
- title: "Troubleshooting"
|
||||
url: "/gateway/troubleshooting/"
|
||||
url: "/troubleshooting.html"
|
||||
|
||||
kramdown:
|
||||
input: GFM
|
||||
|
||||
File renamed without changes.
@@ -3,21 +3,20 @@ summary: "Agent runtime (embedded p-mono), workspace contract, and session boots
|
||||
read_when:
|
||||
- Changing agent runtime, workspace bootstrap, or session behavior
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Agent Runtime 🤖
|
||||
|
||||
CLAWDBOT runs a single embedded agent runtime derived from **p-mono** (internal name: **p**).
|
||||
|
||||
## Workspace (required)
|
||||
|
||||
CLAWDBOT uses a single agent workspace directory (`agent.workspace`) as the agent’s **only** working directory (`cwd`) for tools and context.
|
||||
You must set an agent home directory via `agent.workspace`. CLAWDBOT uses this as the agent’s **only** working directory (`cwd`) for tools and context.
|
||||
|
||||
Recommended: use `clawdbot setup` to create `~/.clawdbot/clawdbot.json` if missing and initialize the workspace files.
|
||||
|
||||
Full workspace layout + backup guide: [`docs/agent-workspace.md`](/concepts/agent-workspace)
|
||||
|
||||
If `agent.sandbox` is enabled, non-main sessions can override this with
|
||||
per-session workspaces under `agent.sandbox.workspaceRoot` (see
|
||||
[`docs/configuration.md`](/gateway/configuration)).
|
||||
`docs/configuration.md`).
|
||||
|
||||
## Bootstrap files (injected)
|
||||
|
||||
@@ -31,18 +30,8 @@ Inside `agent.workspace`, CLAWDBOT expects these user-editable files:
|
||||
|
||||
On the first turn of a new session, CLAWDBOT injects the contents of these files directly into the agent context.
|
||||
|
||||
Blank files are skipped. Large files are trimmed and truncated with a marker so prompts stay lean (read the file for full content).
|
||||
|
||||
If a file is missing, CLAWDBOT injects a single “missing file” marker line (and `clawdbot setup` will create a safe default template).
|
||||
|
||||
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). If you delete it after completing the ritual, it should not be recreated on later restarts.
|
||||
|
||||
To disable bootstrap file creation entirely (for pre-seeded workspaces), set:
|
||||
|
||||
```json5
|
||||
{ agent: { skipBootstrap: true } }
|
||||
```
|
||||
|
||||
## Built-in tools (internal)
|
||||
|
||||
p’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used.
|
||||
@@ -54,7 +43,7 @@ Clawdbot loads skills from three locations (workspace wins on name conflict):
|
||||
- Managed/local: `~/.clawdbot/skills`
|
||||
- Workspace: `<workspace>/skills`
|
||||
|
||||
Skills can be gated by config/env (see `skills` in [`docs/configuration.md`](/gateway/configuration)).
|
||||
Skills can be gated by config/env (see `skills` in `docs/configuration.md`).
|
||||
|
||||
## p-mono integration
|
||||
|
||||
@@ -78,7 +67,7 @@ Apply these notes **only** when the user is Peter Steinberger at steipete.
|
||||
## Sessions
|
||||
|
||||
Session transcripts are stored as JSONL at:
|
||||
- `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl`
|
||||
- `~/.clawdbot/sessions/<SessionId>.jsonl`
|
||||
|
||||
The session ID is stable and chosen by CLAWDBOT.
|
||||
Legacy Pi/Tau session folders are **not** read.
|
||||
@@ -93,7 +82,7 @@ message is injected before the next assistant response.
|
||||
|
||||
When queue mode is `followup` or `collect`, inbound messages are held until the
|
||||
current turn ends, then a new agent turn starts with the queued payloads. See
|
||||
[`docs/queue.md`](/concepts/queue) for mode + debounce/cap behavior.
|
||||
`docs/queue.md` for mode + debounce/cap behavior.
|
||||
|
||||
Block streaming sends completed assistant blocks as soon as they finish; disable
|
||||
via `agent.blockStreamingDefault: "off"` if you only want the final response.
|
||||
@@ -111,4 +100,5 @@ At minimum, set:
|
||||
|
||||
---
|
||||
|
||||
*Next: [Group Chats](/concepts/group-messages)* 🦞
|
||||
*Next: [Group Chats](./group-messages.md)* 🦞
|
||||
<!-- {% endraw %} -->
|
||||
@@ -1,20 +1,18 @@
|
||||
---
|
||||
summary: "Android app (node): connection runbook + Canvas/Chat/Camera"
|
||||
summary: "Runbook: connect/pair the Android node to a Clawdbot Gateway and use Canvas/Chat/Camera"
|
||||
read_when:
|
||||
- Pairing or reconnecting the Android node
|
||||
- Debugging Android bridge discovery or auth
|
||||
- Verifying chat history parity across clients
|
||||
---
|
||||
|
||||
# Android App (Node)
|
||||
|
||||
## Connection Runbook
|
||||
# Android Node Connection Runbook
|
||||
|
||||
Android node app ⇄ (mDNS/NSD + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
|
||||
|
||||
The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Android talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing.
|
||||
|
||||
### Prerequisites
|
||||
## Prerequisites
|
||||
|
||||
- You can run the Gateway on the “master” machine.
|
||||
- Android device/emulator can reach the gateway bridge:
|
||||
@@ -23,12 +21,12 @@ The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Android talk
|
||||
- Manual bridge host/port (fallback)
|
||||
- You can run the CLI (`clawdbot`) on the gateway machine (or via SSH).
|
||||
|
||||
### 1) Start the Gateway (with bridge enabled)
|
||||
## 1) Start the Gateway (with bridge enabled)
|
||||
|
||||
Bridge is enabled by default (disable via `CLAWDBOT_BRIDGE_ENABLED=0`).
|
||||
|
||||
```bash
|
||||
clawdbot gateway --port 18789 --verbose
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
Confirm in logs you see something like:
|
||||
@@ -39,7 +37,7 @@ For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to
|
||||
- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json` on the gateway host.
|
||||
- Restart the Gateway / macOS menubar app.
|
||||
|
||||
### 2) Verify discovery (optional)
|
||||
## 2) Verify discovery (optional)
|
||||
|
||||
From the gateway machine:
|
||||
|
||||
@@ -47,18 +45,18 @@ From the gateway machine:
|
||||
dns-sd -B _clawdbot-bridge._tcp local.
|
||||
```
|
||||
|
||||
More debugging notes: [`docs/bonjour.md`](/gateway/bonjour).
|
||||
More debugging notes: `docs/bonjour.md`.
|
||||
|
||||
#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
|
||||
### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
|
||||
|
||||
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
|
||||
|
||||
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-bridge._tcp` records.
|
||||
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
|
||||
|
||||
Details and example CoreDNS config: [`docs/bonjour.md`](/gateway/bonjour).
|
||||
Details and example CoreDNS config: `docs/bonjour.md`.
|
||||
|
||||
### 3) Connect from Android
|
||||
## 3) Connect from Android
|
||||
|
||||
In the Android app:
|
||||
|
||||
@@ -71,7 +69,7 @@ After the first successful pairing, Android auto-reconnects on launch:
|
||||
- Manual endpoint (if enabled), otherwise
|
||||
- The last discovered bridge (best-effort).
|
||||
|
||||
### 4) Approve pairing (CLI)
|
||||
## 4) Approve pairing (CLI)
|
||||
|
||||
On the gateway machine:
|
||||
|
||||
@@ -80,9 +78,9 @@ clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
Pairing details: [`docs/gateway/pairing.md`](/gateway/pairing).
|
||||
Pairing details: `docs/gateway/pairing.md`.
|
||||
|
||||
### 5) Verify the node is connected
|
||||
## 5) Verify the node is connected
|
||||
|
||||
- Via nodes status:
|
||||
```bash
|
||||
@@ -93,7 +91,7 @@ Pairing details: [`docs/gateway/pairing.md`](/gateway/pairing).
|
||||
clawdbot gateway call node.list --params "{}"
|
||||
```
|
||||
|
||||
### 6) Chat + history
|
||||
## 6) Chat + history
|
||||
|
||||
The Android node’s Chat sheet uses the gateway’s **primary session key** (`main`), so history and replies are shared with WebChat and other clients:
|
||||
|
||||
@@ -101,9 +99,9 @@ The Android node’s Chat sheet uses the gateway’s **primary session key** (`m
|
||||
- Send: `chat.send`
|
||||
- Push updates (best-effort): `chat.subscribe` → `event:"chat"`
|
||||
|
||||
### 7) Canvas + camera
|
||||
## 7) Canvas + camera
|
||||
|
||||
#### Gateway Canvas Host (recommended for web content)
|
||||
### Gateway Canvas Host (recommended for web content)
|
||||
|
||||
If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point the node at the Gateway canvas host.
|
||||
|
||||
@@ -130,4 +128,4 @@ Camera commands (foreground only; permission-gated):
|
||||
- `camera.snap` (jpg)
|
||||
- `camera.clip` (mp4)
|
||||
|
||||
See [`docs/camera.md`](/nodes/camera) for parameters and CLI helpers.
|
||||
See `docs/camera.md` for parameters and CLI helpers.
|
||||
@@ -1,52 +1,27 @@
|
||||
---
|
||||
summary: "WebSocket gateway architecture, components, and client flows"
|
||||
summary: "Target WebSocket gateway architecture, components, and client flows"
|
||||
read_when:
|
||||
- Working on gateway protocol, clients, or transports
|
||||
---
|
||||
# Gateway Architecture
|
||||
# Gateway Architecture (target state)
|
||||
|
||||
Last updated: 2026-01-05
|
||||
Last updated: 2025-12-09
|
||||
|
||||
## Overview
|
||||
- A single long-lived **Gateway** process owns all messaging surfaces (WhatsApp via Baileys, Telegram via grammY, Slack via Bolt, Discord via discord.js, Signal via signal-cli, iMessage via imsg, WebChat) and the control/event plane.
|
||||
- All clients (macOS app, CLI, web UI, automations) connect to the Gateway over one transport: **WebSocket on the configured bind host** (default `127.0.0.1:18789`; tunnel or VPN for remote).
|
||||
- A single long-lived **Gateway** process owns all messaging surfaces (WhatsApp via Baileys, Telegram via grammY, Discord via discord.js) and the control/event plane.
|
||||
- All clients (macOS app, CLI, web UI, automations) connect to the Gateway over one transport: **WebSocket on 127.0.0.1:18789** (tunnel or VPN for remote).
|
||||
- One Gateway per host; it is the only place that is allowed to open a WhatsApp session. All sends/agent runs go through it.
|
||||
- By default: the Gateway exposes a Canvas host on `canvasHost.port` (default `18793`), serving `~/clawd/canvas` at `/__clawdbot__/canvas/` with live-reload; disable via `canvasHost.enabled=false` or `CLAWDBOT_SKIP_CANVAS_HOST=1`.
|
||||
|
||||
## Implementation snapshot (current code)
|
||||
|
||||
### TypeScript Gateway ([`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts))
|
||||
- Single HTTP + WebSocket server (default `18789`); bind policy `loopback|lan|tailnet|auto`. Refuses non-loopback binds without auth; Tailscale serve/funnel requires loopback.
|
||||
- Handshake: first frame must be a `connect` request; AJV validates request + params against TypeBox schemas; protocol negotiated via `minProtocol`/`maxProtocol`.
|
||||
- `hello-ok` includes snapshot (presence/health/stateVersion/uptime/configPath/stateDir), features (methods/events), policy (max payload/buffer/tick), and `canvasHostUrl` when available.
|
||||
- Events emitted: `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `talk.mode`, `node.pair.requested`, `node.pair.resolved`, `voicewake.changed`, `shutdown`.
|
||||
- Idempotency keys are required for `send`, `agent`, `chat.send`, and node invokes; the dedupe cache avoids double-sends on reconnects. Payload sizes are capped per connection.
|
||||
- Optional node bridge ([`src/infra/bridge/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/bridge/server.ts)): TCP JSONL frames (`hello`, `pair-request`, `req/res`, `event`, `invoke`, `ping`). Node connect/disconnect updates presence and flows into the session bus.
|
||||
- Control UI + Canvas host: HTTP serves Control UI (base path configurable) and can host the A2UI canvas via [`src/canvas-host/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/server.ts) (live reload). Canvas host URL is advertised to nodes + clients.
|
||||
|
||||
### iOS node (`apps/ios`)
|
||||
- Discovery + pairing: `BridgeDiscoveryModel` uses `NWBrowser` Bonjour discovery and reads TXT fields for LAN/tailnet host hints plus gateway/bridge/canvas ports.
|
||||
- Auto-connect: `BridgeConnectionController` uses stored `node.instanceId` + Keychain token; supports manual host/port; performs `pair-and-hello`.
|
||||
- Bridge runtime: `BridgeSession` actor owns an `NWConnection`, JSONL frames, `hello`/`hello-ok`, ping/pong, `req/res`, server `event`s, and `invoke` callbacks; stores `canvasHostUrl`.
|
||||
- Commands: `NodeAppModel` executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record`, `location.get`. Canvas/camera/screen are blocked when backgrounded.
|
||||
- Canvas + actions: `WKWebView` with A2UI action bridge; accepts actions from local-network or trusted file URLs; intercepts `clawdbot://` deep links and forwards `agent.request` to the bridge.
|
||||
- Voice/talk: voice wake sends `voice.transcript` events and syncs triggers via `voicewake.get` + `voicewake.changed`; Talk Mode attaches to the bridge.
|
||||
|
||||
### Android node (`apps/android`)
|
||||
- Discovery + pairing: `BridgeDiscovery` uses mDNS/NSD to find `_clawdbot-bridge._tcp`, with manual host/port fallback.
|
||||
- Auto-connect: `NodeRuntime` restores a stored token, performs `pair-and-hello`, and reconnects to the last discovered or manual bridge.
|
||||
- Bridge runtime: `BridgeSession` owns the TCP JSONL session (`hello`/`hello-ok`, ping/pong, `req/res`, `event`, `invoke`); stores `canvasHostUrl`.
|
||||
- Commands: `NodeRuntime` executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, and chat/session events; foreground-only for canvas/camera.
|
||||
|
||||
## Components and flows
|
||||
- **Gateway (daemon)**
|
||||
- Maintains WhatsApp (Baileys), Telegram (grammY), Slack (Bolt), Discord (discord.js), Signal (signal-cli), and iMessage (imsg) connections.
|
||||
- Maintains Baileys/Telegram/Discord connections.
|
||||
- Exposes a typed WS API (req/resp + server push events).
|
||||
- Validates every inbound frame against JSON Schema; rejects anything before a mandatory `connect`.
|
||||
- **Clients (mac app / CLI / web admin)**
|
||||
- One WS connection per client.
|
||||
- Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`).
|
||||
- On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see [`docs/macos.md`](/platforms/macos)).
|
||||
- On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see `docs/clawdbot-mac.md`).
|
||||
- **Agent process (Pi)**
|
||||
- Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection.
|
||||
- **WebChat**
|
||||
@@ -3,10 +3,6 @@
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.mdx-content > h1:first-of-type {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
@@ -131,3 +127,4 @@
|
||||
font-family: var(--font-pixel);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
+62
-35
@@ -29,40 +29,40 @@ html[data-theme="auto"] {
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--bg0: #0b1a22;
|
||||
--bg1: #0a1720;
|
||||
--panel: #0e231f;
|
||||
--panel2: #102a24;
|
||||
--text: #c9eadc;
|
||||
--muted: #8ab8aa;
|
||||
--faint: #699b8d;
|
||||
--link: #6fe8c7;
|
||||
--link2: #ff7b63;
|
||||
--bg0: #06141f;
|
||||
--bg1: #031019;
|
||||
--panel: #061a16;
|
||||
--panel2: #071f19;
|
||||
--text: #d6f6ea;
|
||||
--muted: #95c9b9;
|
||||
--faint: #66a391;
|
||||
--link: #79ffd0;
|
||||
--link2: #ff775f;
|
||||
--accent: #ff4f40;
|
||||
--accent2: #5fdfa2;
|
||||
--frame-border: #6fbfa8;
|
||||
--code-bg: #091814;
|
||||
--code-fg: #d7f5e8;
|
||||
--code-accent: #5fdfa2;
|
||||
--accent2: #67ff9b;
|
||||
--frame-border: #b7ffe6;
|
||||
--code-bg: #04110d;
|
||||
--code-fg: #dcfff1;
|
||||
--code-accent: #67ff9b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-theme="auto"] {
|
||||
--bg0: #0b1a22;
|
||||
--bg1: #0a1720;
|
||||
--panel: #0e231f;
|
||||
--panel2: #102a24;
|
||||
--text: #c9eadc;
|
||||
--muted: #8ab8aa;
|
||||
--faint: #699b8d;
|
||||
--link: #6fe8c7;
|
||||
--link2: #ff7b63;
|
||||
--bg0: #06141f;
|
||||
--bg1: #031019;
|
||||
--panel: #061a16;
|
||||
--panel2: #071f19;
|
||||
--text: #d6f6ea;
|
||||
--muted: #95c9b9;
|
||||
--faint: #66a391;
|
||||
--link: #79ffd0;
|
||||
--link2: #ff775f;
|
||||
--accent: #ff4f40;
|
||||
--accent2: #5fdfa2;
|
||||
--frame-border: #6fbfa8;
|
||||
--code-bg: #091814;
|
||||
--code-fg: #d7f5e8;
|
||||
--code-accent: #5fdfa2;
|
||||
--accent2: #67ff9b;
|
||||
--frame-border: #b7ffe6;
|
||||
--code-bg: #04110d;
|
||||
--code-fg: #dcfff1;
|
||||
--code-accent: #67ff9b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +70,9 @@ html[data-theme="dark"] {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -90,9 +87,39 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklab, var(--text) 10%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklab, var(--text) 10%, transparent) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
body::after {
|
||||
display: none;
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, var(--scanline-opacity)),
|
||||
rgba(0, 0, 0, var(--scanline-opacity)) 1px,
|
||||
transparent 1px,
|
||||
transparent var(--scanline-size)
|
||||
);
|
||||
opacity: 0.8;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
|
||||
@@ -3,6 +3,7 @@ summary: "How inbound audio/voice notes are downloaded, transcribed, and injecte
|
||||
read_when:
|
||||
- Changing audio transcription or media handling
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Audio / Voice Notes — 2025-12-05
|
||||
|
||||
## What works
|
||||
@@ -46,3 +47,4 @@ Requires `OPENAI_API_KEY` in env and `openai` CLI installed:
|
||||
## Gotchas
|
||||
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
|
||||
- Keep timeouts reasonable (`timeoutSeconds`, default 45s) to avoid blocking the reply queue.
|
||||
<!-- {% endraw %} -->
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
summary: "Cron jobs + wakeups for the Gateway scheduler"
|
||||
read_when:
|
||||
- Scheduling background jobs or wakeups
|
||||
- Wiring automation that should run with or alongside heartbeats
|
||||
---
|
||||
# Cron jobs (Gateway scheduler)
|
||||
|
||||
Cron runs inside the Gateway and schedules background work so Clawdbot can
|
||||
wake itself up, run isolated agent jobs, and deliver reminders on time.
|
||||
|
||||
## Update checklist (internal)
|
||||
- [x] Audit cron + heartbeat behavior in code
|
||||
- [x] Rewrite cron doc as user-facing feature
|
||||
- [x] Update heartbeat docs + templates
|
||||
- [x] Update cron links in docs
|
||||
- [x] Update changelog
|
||||
- [x] Run full gate (lint/build/test/docs)
|
||||
|
||||
## What cron is
|
||||
- **Gateway-owned scheduler** that persists jobs under `~/.clawdbot/cron/`.
|
||||
- **Two execution modes**:
|
||||
- **Main session jobs** enqueue `System:` events and rely on the heartbeat runner.
|
||||
- **Isolated jobs** run a dedicated agent turn in `cron:<jobId>` sessions.
|
||||
- **Wakeups** are first-class: a job can trigger the next heartbeat or run it now.
|
||||
|
||||
## When to use it
|
||||
- Recurring reminders: “every weekday at 7:30” or “every 2h.”
|
||||
- Background chores: summarize inboxes, check dashboards, watch logs.
|
||||
- Automation that should not pollute the main chat history.
|
||||
- Scheduled wakeups that drive the heartbeat pipeline.
|
||||
|
||||
## Schedules
|
||||
Cron supports three schedule kinds:
|
||||
- `at`: one-shot timestamp in ms.
|
||||
- `every`: fixed interval (ms).
|
||||
- `cron`: 5-field cron expression, optional IANA timezone.
|
||||
|
||||
Cron expressions use `croner` under the hood. If a timezone is omitted, the
|
||||
server’s local timezone is used.
|
||||
|
||||
## Job types
|
||||
|
||||
### Main session jobs
|
||||
Main jobs enqueue a system event and optionally wake the heartbeat runner.
|
||||
They **must** use `payload.kind = "systemEvent"`.
|
||||
|
||||
- **`wakeMode: "next-heartbeat"`** (default): the event waits for the next
|
||||
scheduled heartbeat.
|
||||
- **`wakeMode: "now"`**: the event triggers an immediate heartbeat run.
|
||||
|
||||
### Isolated jobs
|
||||
Isolated jobs run a dedicated agent turn in session `cron:<jobId>` and can
|
||||
optionally deliver a message.
|
||||
|
||||
Key behaviors:
|
||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||
- A summary is posted to the main session with prefix `Cron` (or
|
||||
`isolation.postToMainPrefix`).
|
||||
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
|
||||
- `payload.deliver: true` sends output to a provider; otherwise it stays internal.
|
||||
|
||||
## Storage & history
|
||||
- Job store: `~/.clawdbot/cron/jobs.json` (JSON, Gateway-managed).
|
||||
- Run history: `~/.clawdbot/cron/runs/<jobId>.jsonl` (JSONL, auto-pruned).
|
||||
- Override store path: `cron.store` in config.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json5
|
||||
{
|
||||
cron: {
|
||||
enabled: true, // default true
|
||||
store: "~/.clawdbot/cron/jobs.json",
|
||||
maxConcurrentRuns: 1 // default 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Disable cron entirely:
|
||||
- `cron.enabled: false` (config)
|
||||
- or `CLAWDBOT_SKIP_CRON=1` (env)
|
||||
|
||||
## CLI quickstart
|
||||
|
||||
One-shot reminder (main session, wake immediately):
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Calendar check" \
|
||||
--at "20m" \
|
||||
--session main \
|
||||
--system-event "Next heartbeat: check calendar." \
|
||||
--wake now
|
||||
```
|
||||
|
||||
Recurring isolated job (deliver to WhatsApp):
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Morning status" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize inbox + calendar for today." \
|
||||
--deliver \
|
||||
--provider whatsapp \
|
||||
--to "+15551234567"
|
||||
```
|
||||
|
||||
Manual run (debug):
|
||||
```bash
|
||||
clawdbot cron run <jobId> --force
|
||||
```
|
||||
|
||||
Run history:
|
||||
```bash
|
||||
clawdbot cron runs --id <jobId> --limit 50
|
||||
```
|
||||
|
||||
Immediate wake without creating a job:
|
||||
```bash
|
||||
clawdbot wake --mode now --text "Next heartbeat: check battery."
|
||||
```
|
||||
|
||||
## API surface (Gateway)
|
||||
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
||||
- `cron.run` (force or due), `cron.runs`
|
||||
- `wake` (enqueue system event + optional heartbeat)
|
||||
|
||||
## Tips
|
||||
- Use **main session jobs** when you want the heartbeat prompt + existing context.
|
||||
- Use **isolated jobs** for noisy, frequent, or long-running work.
|
||||
- Keep messages short; cron turns are full agent runs and can burn tokens.
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
summary: "Poll sending via gateway + CLI"
|
||||
read_when:
|
||||
- Adding or modifying poll support
|
||||
- Debugging poll sends from the CLI or gateway
|
||||
---
|
||||
# Polls
|
||||
|
||||
Updated: 2026-01-06
|
||||
|
||||
## Supported providers
|
||||
- WhatsApp (web provider)
|
||||
- Discord
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# WhatsApp
|
||||
clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
|
||||
clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
|
||||
|
||||
# Discord
|
||||
clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
|
||||
clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--provider`: `whatsapp` (default) or `discord`
|
||||
- `--max-selections`: how many choices a voter can select (default: 1)
|
||||
- `--duration-hours`: Discord-only (defaults to 24 when omitted)
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
Method: `poll`
|
||||
|
||||
Params:
|
||||
- `to` (string, required)
|
||||
- `question` (string, required)
|
||||
- `options` (string[], required)
|
||||
- `maxSelections` (number, optional)
|
||||
- `durationHours` (number, optional)
|
||||
- `provider` (string, optional, default: `whatsapp`)
|
||||
- `idempotencyKey` (string, required)
|
||||
|
||||
## Provider differences
|
||||
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
|
||||
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
|
||||
|
||||
## Agent tool (Discord)
|
||||
The Discord tool action `poll` still uses `question`, `answers`, optional `allowMultiselect`, `durationHours`, and `content`. The gateway/CLI poll model maps `allowMultiselect` to `maxSelections > 1`.
|
||||
|
||||
Note: Discord has no “pick exactly N” mode; `maxSelections` is treated as a boolean (`> 1` = multiselect).
|
||||
File renamed without changes.
File renamed without changes.
@@ -69,7 +69,7 @@ The bridge port (default `18790`) is a plain TCP service. By default it binds to
|
||||
For a tailnet-only setup, bind it to the Tailscale IP instead:
|
||||
|
||||
- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json`.
|
||||
- Restart the Gateway (or restart the macOS menubar app via [`./scripts/restart-mac.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/restart-mac.sh) on that machine).
|
||||
- Restart the Gateway (or restart the macOS menubar app via `./scripts/restart-mac.sh` on that machine).
|
||||
|
||||
This keeps the bridge reachable only from devices on your tailnet (while still listening on loopback for local/SSH port-forwards).
|
||||
|
||||
@@ -77,8 +77,8 @@ This keeps the bridge reachable only from devices on your tailnet (while still l
|
||||
|
||||
Only the **Node Gateway** (`clawd` / `clawdbot gateway`) advertises Bonjour beacons.
|
||||
|
||||
- Implementation: [`src/infra/bonjour.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/bonjour.ts)
|
||||
- Gateway wiring: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts)
|
||||
- Implementation: `src/infra/bonjour.ts`
|
||||
- Gateway wiring: `src/gateway/server.ts`
|
||||
|
||||
## Service types
|
||||
|
||||
@@ -136,7 +136,7 @@ The log includes browser state transitions (`ready`, `waiting`, `failed`, `cance
|
||||
- **Sleep / interface churn**: macOS may temporarily drop mDNS results when switching networks; retry.
|
||||
- **Browse works but resolve fails (iOS “NoSuchRecord”)**: make sure the advertiser publishes a valid SRV target hostname.
|
||||
- Implementation detail: `@homebridge/ciao` defaults `hostname` to the *service instance name* when `hostname` is omitted. If your instance name contains spaces/parentheses, some resolvers can fail to resolve the implied A/AAAA record.
|
||||
- Fix: set an explicit DNS-safe `hostname` (single label; no `.local`) in [`src/infra/bonjour.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/bonjour.ts).
|
||||
- Fix: set an explicit DNS-safe `hostname` (single label; no `.local`) in `src/infra/bonjour.ts`.
|
||||
|
||||
## Escaped instance names (`\\032`)
|
||||
Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\\DDD` sequences (e.g. spaces become `\\032`).
|
||||
@@ -155,5 +155,5 @@ Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\\DDD`
|
||||
|
||||
## Related docs
|
||||
|
||||
- Discovery policy and transport selection: [`docs/discovery.md`](/gateway/discovery)
|
||||
- Node pairing + approvals: [`docs/gateway/pairing.md`](/gateway/pairing)
|
||||
- Discovery policy and transport selection: `docs/discovery.md`
|
||||
- Node pairing + approvals: `docs/gateway/pairing.md`
|
||||
@@ -189,26 +189,10 @@ All existing endpoints accept optional `?profile=<name>` query parameter:
|
||||
- `GET /?profile=work` — status for work profile
|
||||
- `POST /start?profile=work` — start work profile browser
|
||||
- `GET /tabs?profile=work` — list tabs for work profile
|
||||
- `POST /tabs/open?profile=work` — open tab in work profile
|
||||
- etc.
|
||||
|
||||
When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
|
||||
|
||||
### Agent browser tool
|
||||
|
||||
The `browser` tool accepts an optional `profile` parameter for all actions:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "open",
|
||||
"targetUrl": "https://example.com",
|
||||
"profile": "work"
|
||||
}
|
||||
```
|
||||
|
||||
This routes the operation to the specified profile's browser instance. Omitting
|
||||
`profile` uses the default profile.
|
||||
|
||||
### Profile naming rules
|
||||
|
||||
- Lowercase alphanumeric characters and hyphens only
|
||||
@@ -237,7 +221,7 @@ The agent should not assume tabs are ephemeral. It should:
|
||||
|
||||
## CLI quick reference (one example each)
|
||||
|
||||
All commands accept `--browser-profile <name>` to target a specific profile (default: `clawd`).
|
||||
All commands accept `--profile <name>` to target a specific profile (default: `clawd`).
|
||||
|
||||
Profile management:
|
||||
- `clawdbot browser profiles`
|
||||
@@ -303,7 +287,3 @@ Notes:
|
||||
- Sharing the user's logged-in Chrome sessions automatically.
|
||||
- General-purpose web scraping; this is primarily for "close-the-loop" verification
|
||||
and interaction.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
For Linux-specific issues (especially Ubuntu with snap Chromium), see [browser-linux-troubleshooting](/tools/browser-linux-troubleshooting).
|
||||
File renamed without changes.
@@ -4,6 +4,7 @@ read_when:
|
||||
- Onboarding a new assistant instance
|
||||
- Reviewing safety/permission implications
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Building a personal assistant with CLAWDBOT (Clawd-style)
|
||||
|
||||
CLAWDBOT is a WhatsApp + Telegram + Discord gateway for **Pi** agents. This guide is the “personal assistant” setup: one dedicated WhatsApp number that behaves like your always-on agent.
|
||||
@@ -18,7 +19,7 @@ You’re putting an agent in a position to:
|
||||
Start conservative:
|
||||
- Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||
- Use a dedicated WhatsApp number for the assistant.
|
||||
- Heartbeats now default to every 30 minutes. Disable until you trust the setup by setting `agent.heartbeat.every: "0m"`.
|
||||
- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -81,11 +82,11 @@ clawdbot gateway --port 18789
|
||||
|
||||
Now message the assistant number from your allowlisted phone.
|
||||
|
||||
## Give the agent a workspace (AGENTS)
|
||||
## Give the agent a workspace (AGENTS.md)
|
||||
|
||||
Clawd reads operating instructions and “memory” from its workspace directory.
|
||||
|
||||
By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
|
||||
By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`) automatically on setup/first agent run.
|
||||
|
||||
Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up.
|
||||
|
||||
@@ -93,8 +94,6 @@ Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideal
|
||||
clawdbot setup
|
||||
```
|
||||
|
||||
Full workspace layout + backup guide: [`docs/agent-workspace.md`](/concepts/agent-workspace)
|
||||
|
||||
Optional: choose a different workspace with `agent.workspace` (supports `~`).
|
||||
|
||||
```json5
|
||||
@@ -105,16 +104,6 @@ Optional: choose a different workspace with `agent.workspace` (supports `~`).
|
||||
}
|
||||
```
|
||||
|
||||
If you already ship your own workspace files from a repo, you can disable bootstrap file creation entirely:
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
skipBootstrap: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## The config that turns it into “an assistant”
|
||||
|
||||
CLAWDBOT defaults to a good assistant setup, but you’ll usually want to tune:
|
||||
@@ -156,19 +145,15 @@ Example:
|
||||
|
||||
## Sessions and memory
|
||||
|
||||
- Session files: `~/.clawdbot/agents/<agentId>/sessions/{{SessionId}}.jsonl`
|
||||
- Session metadata (token usage, last route, etc): `~/.clawdbot/agents/<agentId>/sessions/sessions.json` (legacy: `~/.clawdbot/sessions/sessions.json`)
|
||||
- Session files: `~/.clawdbot/sessions/{{SessionId}}.jsonl`
|
||||
- Session metadata (token usage, last route, etc): `~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`)
|
||||
- `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, the agent replies with a short hello to confirm the reset.
|
||||
- `/compact [instructions]` compacts the session context and reports the remaining context budget.
|
||||
|
||||
## Heartbeats (proactive mode)
|
||||
|
||||
By default, CLAWDBOT runs a heartbeat every 30 minutes with the prompt:
|
||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||
Set `agent.heartbeat.every: "0m"` to disable.
|
||||
When `agent.heartbeat.every` is set to a positive interval, CLAWDBOT periodically runs a heartbeat prompt (default: `HEARTBEAT`).
|
||||
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agent.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat.
|
||||
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||
- If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDBOT suppresses outbound delivery for that heartbeat.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -206,12 +191,9 @@ Logs live under `/tmp/clawdbot/` (default: `clawdbot-YYYY-MM-DD.log`).
|
||||
|
||||
## Next steps
|
||||
|
||||
- WebChat: [WebChat](/web/webchat)
|
||||
- Gateway ops: [Gateway runbook](/gateway)
|
||||
- Cron + wakeups: [Cron jobs](/automation/cron-jobs)
|
||||
- macOS menu bar companion: [Clawdbot macOS app](/platforms/macos)
|
||||
- iOS node app: [iOS app](/platforms/ios)
|
||||
- Android node app: [Android app](/platforms/android)
|
||||
- Windows status: [Windows (WSL2)](/platforms/windows)
|
||||
- Linux status: [Linux app](/platforms/linux)
|
||||
- Security: [Security](/gateway/security)
|
||||
- WebChat: [WebChat](./webchat.md)
|
||||
- Gateway ops: [Gateway runbook](./gateway.md)
|
||||
- Cron + wakeups: [Cron + wakeups](./cron.md)
|
||||
- macOS menu bar companion: [Clawdbot macOS app](./clawdbot-mac.md)
|
||||
- Security: [Security](./security.md)
|
||||
<!-- {% endraw %} -->
|
||||
@@ -13,7 +13,7 @@ Author: steipete · Status: draft spec · Date: 2025-12-20
|
||||
- Shows native notifications for Clawdbot/clawdbot events.
|
||||
- Owns TCC prompts (Notifications, Accessibility, Screen Recording, Automation/AppleScript, Microphone, Speech Recognition).
|
||||
- Runs (or connects to) the **Gateway** and exposes itself as a **node** so agents can reach macOS‑only features.
|
||||
- Hosts **PeekabooBridge** for UI automation (consumed by `peekaboo`; see [`docs/mac/peekaboo.md`](/platforms/mac/peekaboo)).
|
||||
- Hosts **PeekabooBridge** for UI automation (consumed by `peekaboo`; see `docs/mac/peekaboo.md`).
|
||||
- Installs a single CLI (`clawdbot`) by symlinking the bundled binary.
|
||||
|
||||
## High-level design
|
||||
@@ -79,7 +79,7 @@ Query parameters:
|
||||
- `sessionKey` (optional): explicit session key to use.
|
||||
- `thinking` (optional): thinking hint (e.g. `low`; omit for default).
|
||||
- `deliver` (optional): `true|false` (default: false).
|
||||
- `to` / `provider` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`).
|
||||
- `to` / `channel` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`).
|
||||
- `timeoutSeconds` (optional): timeout hint forwarded to the Gateway.
|
||||
- `key` (optional): unattended mode key (see below).
|
||||
|
||||
@@ -94,9 +94,9 @@ Notes:
|
||||
- In remote mode, Clawdbot will use the configured remote tunnel/endpoint.
|
||||
|
||||
## Build & dev workflow (native)
|
||||
- `cd apps/macos && swift build` (debug) / `swift build -c release`.
|
||||
- `cd native && swift build` (debug) / `swift build -c release`.
|
||||
- Run app for dev: `swift run Clawdbot` (or Xcode scheme).
|
||||
- Package app + CLI: [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) (builds bun CLI + gateway).
|
||||
- Package app + CLI: `scripts/package-mac-app.sh` (builds bun CLI + gateway).
|
||||
- Tests: add Swift Testing suites under `apps/macos/Tests`.
|
||||
|
||||
## Open questions / decisions
|
||||
@@ -1,575 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for clawdbot commands, subcommands, and options"
|
||||
read_when:
|
||||
- Adding or modifying CLI commands or options
|
||||
- Documenting new command surfaces
|
||||
---
|
||||
|
||||
# CLI reference
|
||||
|
||||
This page mirrors `src/cli/*` and is the source of truth for CLI behavior.
|
||||
If you change the CLI code, update this doc.
|
||||
|
||||
## Global flags
|
||||
|
||||
- `--dev`: isolate state under `~/.clawdbot-dev` and shift default ports.
|
||||
- `--profile <name>`: isolate state under `~/.clawdbot-<name>`.
|
||||
- `-V`, `--version`, `-v`: print version and exit.
|
||||
|
||||
## Command tree
|
||||
|
||||
```
|
||||
clawdbot [--dev] [--profile <name>] <command>
|
||||
setup
|
||||
onboard
|
||||
configure (alias: config)
|
||||
update
|
||||
doctor
|
||||
login
|
||||
logout
|
||||
send
|
||||
poll
|
||||
agent
|
||||
agents
|
||||
list
|
||||
add
|
||||
delete
|
||||
status
|
||||
health
|
||||
sessions
|
||||
gateway
|
||||
call
|
||||
health
|
||||
status
|
||||
wake
|
||||
send
|
||||
agent
|
||||
stop
|
||||
restart
|
||||
gateway-daemon
|
||||
models
|
||||
list
|
||||
status
|
||||
set
|
||||
set-image
|
||||
aliases list|add|remove
|
||||
fallbacks list|add|remove|clear
|
||||
image-fallbacks list|add|remove|clear
|
||||
scan
|
||||
wake
|
||||
cron
|
||||
status
|
||||
list
|
||||
add
|
||||
edit
|
||||
rm
|
||||
enable
|
||||
disable
|
||||
runs
|
||||
run
|
||||
nodes
|
||||
status
|
||||
describe
|
||||
list
|
||||
pending
|
||||
approve
|
||||
reject
|
||||
rename
|
||||
invoke
|
||||
run
|
||||
notify
|
||||
camera list|snap|clip
|
||||
canvas snapshot
|
||||
screen record
|
||||
location get
|
||||
canvas
|
||||
snapshot
|
||||
present
|
||||
hide
|
||||
navigate
|
||||
eval
|
||||
a2ui push|reset
|
||||
browser
|
||||
status
|
||||
start
|
||||
stop
|
||||
reset-profile
|
||||
tabs
|
||||
open
|
||||
focus
|
||||
close
|
||||
profiles
|
||||
create-profile
|
||||
delete-profile
|
||||
screenshot
|
||||
snapshot
|
||||
navigate
|
||||
resize
|
||||
click
|
||||
type
|
||||
press
|
||||
hover
|
||||
drag
|
||||
select
|
||||
upload
|
||||
fill
|
||||
dialog
|
||||
wait
|
||||
evaluate
|
||||
console
|
||||
pdf
|
||||
hooks
|
||||
gmail setup|run
|
||||
pairing
|
||||
list
|
||||
approve
|
||||
telegram
|
||||
pairing list|approve
|
||||
docs
|
||||
dns
|
||||
setup
|
||||
tui
|
||||
```
|
||||
|
||||
## Setup + onboarding
|
||||
|
||||
### `setup`
|
||||
Initialize config + workspace.
|
||||
|
||||
Options:
|
||||
- `--workspace <dir>`: agent workspace path (default `~/clawd`).
|
||||
- `--wizard`: run the onboarding wizard.
|
||||
- `--non-interactive`: run wizard without prompts.
|
||||
- `--mode <local|remote>`: wizard mode.
|
||||
- `--remote-url <url>`: remote Gateway URL.
|
||||
- `--remote-token <token>`: remote Gateway token.
|
||||
|
||||
### `onboard`
|
||||
Interactive wizard to set up gateway, workspace, and skills.
|
||||
|
||||
Options:
|
||||
- `--workspace <dir>`
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--auth-choice <oauth|openai-codex|antigravity|apiKey|minimax|skip>`
|
||||
- `--anthropic-api-key <key>`
|
||||
- `--gateway-port <port>`
|
||||
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
||||
- `--gateway-auth <off|token|password>`
|
||||
- `--gateway-token <token>`
|
||||
- `--gateway-password <password>`
|
||||
- `--remote-url <url>`
|
||||
- `--remote-token <token>`
|
||||
- `--tailscale <off|serve|funnel>`
|
||||
- `--tailscale-reset-on-exit`
|
||||
- `--install-daemon`
|
||||
- `--daemon-runtime <node|bun>`
|
||||
- `--skip-skills`
|
||||
- `--skip-health`
|
||||
- `--node-manager <npm|pnpm|bun>`
|
||||
- `--json`
|
||||
|
||||
### `configure` / `config`
|
||||
Interactive configuration wizard (models, providers, skills, gateway).
|
||||
|
||||
### `update`
|
||||
Audit and modernize the local configuration.
|
||||
|
||||
### `doctor`
|
||||
Health checks + quick fixes.
|
||||
|
||||
Options:
|
||||
- `--no-workspace-suggestions`: disable workspace memory hints.
|
||||
|
||||
## Auth + provider helpers
|
||||
|
||||
### `login`
|
||||
Link a WhatsApp Web account via QR.
|
||||
|
||||
Options:
|
||||
- `--verbose`
|
||||
- `--provider <provider>` (default `whatsapp`)
|
||||
- `--account <id>`
|
||||
|
||||
### `logout`
|
||||
Clear cached WhatsApp Web credentials.
|
||||
|
||||
Options:
|
||||
- `--provider <provider>`
|
||||
- `--account <id>`
|
||||
|
||||
### `pairing`
|
||||
Approve DM pairing requests across providers.
|
||||
|
||||
Subcommands:
|
||||
- `pairing list --provider <telegram|signal|imessage|discord|slack|whatsapp> [--json]`
|
||||
- `pairing approve --provider <...> <code> [--notify]`
|
||||
|
||||
### `telegram pairing`
|
||||
Telegram-only pairing helper.
|
||||
|
||||
Subcommands:
|
||||
- `telegram pairing list [--json]`
|
||||
- `telegram pairing approve <code> [--no-notify]`
|
||||
|
||||
### `hooks gmail`
|
||||
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
|
||||
|
||||
Subcommands:
|
||||
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--push-endpoint`, `--json`)
|
||||
- `hooks gmail run` (runtime overrides for the same flags)
|
||||
|
||||
### `dns setup`
|
||||
Wide-area discovery DNS helper (CoreDNS + Tailscale). See [/gateway/discovery](/gateway/discovery).
|
||||
|
||||
Options:
|
||||
- `--apply`: install/update CoreDNS config (requires sudo; macOS only).
|
||||
|
||||
## Messaging + agent
|
||||
|
||||
### `send`
|
||||
Send a message through a provider.
|
||||
|
||||
Required:
|
||||
- `--to <dest>`
|
||||
- `--message <text>`
|
||||
|
||||
Options:
|
||||
- `--media <path-or-url>`
|
||||
- `--gif-playback`
|
||||
- `--provider <whatsapp|telegram|discord|slack|signal|imessage>`
|
||||
- `--account <id>` (WhatsApp)
|
||||
- `--dry-run`
|
||||
- `--json`
|
||||
- `--verbose`
|
||||
|
||||
### `poll`
|
||||
Create a poll (WhatsApp or Discord).
|
||||
|
||||
Required:
|
||||
- `--to <id>`
|
||||
- `--question <text>`
|
||||
- `--option <choice>` (repeat 2-12 times)
|
||||
|
||||
Options:
|
||||
- `--max-selections <n>`
|
||||
- `--duration-hours <n>` (Discord)
|
||||
- `--provider <whatsapp|discord>`
|
||||
- `--dry-run`
|
||||
- `--json`
|
||||
- `--verbose`
|
||||
|
||||
### `agent`
|
||||
Run one agent turn via the Gateway (or `--local` embedded).
|
||||
|
||||
Required:
|
||||
- `--message <text>`
|
||||
|
||||
Options:
|
||||
- `--to <dest>` (for session key and optional delivery)
|
||||
- `--session-id <id>`
|
||||
- `--thinking <off|minimal|low|medium|high>`
|
||||
- `--verbose <on|off>`
|
||||
- `--provider <whatsapp|telegram|discord|slack|signal|imessage>`
|
||||
- `--local`
|
||||
- `--deliver`
|
||||
- `--json`
|
||||
- `--timeout <seconds>`
|
||||
|
||||
### `agents`
|
||||
Manage isolated agents (workspaces + auth + routing).
|
||||
|
||||
#### `agents list`
|
||||
List configured agents.
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
|
||||
#### `agents add [name]`
|
||||
Add a new isolated agent. If `--workspace` is omitted, runs the guided wizard.
|
||||
|
||||
Options:
|
||||
- `--workspace <dir>`
|
||||
- `--json`
|
||||
|
||||
#### `agents delete <id>`
|
||||
Delete an agent and prune its workspace + state.
|
||||
|
||||
Options:
|
||||
- `--force`
|
||||
- `--json`
|
||||
|
||||
### `status`
|
||||
Show linked session health and recent recipients.
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--deep` (probe providers)
|
||||
- `--usage` (show provider usage/quota)
|
||||
- `--timeout <ms>`
|
||||
- `--verbose`
|
||||
|
||||
### Usage tracking
|
||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||
|
||||
Surfaces:
|
||||
- `/status` (adds a short usage line when available)
|
||||
- `clawdbot status --usage` (prints full provider breakdown)
|
||||
- macOS menu bar (Usage section under Context)
|
||||
|
||||
Notes:
|
||||
- Data comes directly from provider usage endpoints (no estimates).
|
||||
- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
|
||||
- If no matching credentials exist, usage is hidden.
|
||||
- Details: see [Usage tracking](/concepts/usage-tracking).
|
||||
|
||||
### `health`
|
||||
Fetch health from the running Gateway.
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--timeout <ms>`
|
||||
- `--verbose`
|
||||
|
||||
### `sessions`
|
||||
List stored conversation sessions.
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--verbose`
|
||||
- `--store <path>`
|
||||
- `--active <minutes>`
|
||||
|
||||
## Gateway
|
||||
|
||||
### `gateway`
|
||||
Run the WebSocket Gateway.
|
||||
|
||||
Options:
|
||||
- `--port <port>`
|
||||
- `--bind <loopback|tailnet|lan|auto>`
|
||||
- `--token <token>`
|
||||
- `--auth <token|password>`
|
||||
- `--password <password>`
|
||||
- `--tailscale <off|serve|funnel>`
|
||||
- `--tailscale-reset-on-exit`
|
||||
- `--allow-unconfigured`
|
||||
- `--force` (kill existing listener on port)
|
||||
- `--verbose`
|
||||
- `--ws-log <auto|full|compact>`
|
||||
- `--compact` (alias for `--ws-log compact`)
|
||||
|
||||
### `gateway-daemon`
|
||||
Run the Gateway as a long-lived daemon (same options as `gateway`, minus `--allow-unconfigured` and `--force`).
|
||||
|
||||
### `gateway <subcommand>`
|
||||
Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
|
||||
|
||||
Subcommands:
|
||||
- `gateway call <method> [--params <json>]`
|
||||
- `gateway health`
|
||||
- `gateway status`
|
||||
- `gateway wake --text <text> [--mode now|next-heartbeat]`
|
||||
- `gateway send --to <jidOrPhone> --message <text> [--media-url <url>] [--gif-playback] [--idempotency-key <key>]`
|
||||
- `gateway agent --message <text> [--to <jidOrPhone>] [--session-id <id>] [--thinking <level>] [--deliver] [--timeout-seconds <n>] [--idempotency-key <key>]`
|
||||
- `gateway stop`
|
||||
- `gateway restart`
|
||||
|
||||
## Models
|
||||
|
||||
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
|
||||
|
||||
### `models` (root)
|
||||
`clawdbot models` is an alias for `models status`.
|
||||
|
||||
### `models list`
|
||||
Options:
|
||||
- `--all`
|
||||
- `--local`
|
||||
- `--provider <name>`
|
||||
- `--json`
|
||||
- `--plain`
|
||||
|
||||
### `models status`
|
||||
Options:
|
||||
- `--json`
|
||||
- `--plain`
|
||||
|
||||
### `models set <model>`
|
||||
Set `agent.model.primary`.
|
||||
|
||||
### `models set-image <model>`
|
||||
Set `agent.imageModel.primary`.
|
||||
|
||||
### `models aliases list|add|remove`
|
||||
Options:
|
||||
- `list`: `--json`, `--plain`
|
||||
- `add <alias> <model>`
|
||||
- `remove <alias>`
|
||||
|
||||
### `models fallbacks list|add|remove|clear`
|
||||
Options:
|
||||
- `list`: `--json`, `--plain`
|
||||
- `add <model>`
|
||||
- `remove <model>`
|
||||
- `clear`
|
||||
|
||||
### `models image-fallbacks list|add|remove|clear`
|
||||
Options:
|
||||
- `list`: `--json`, `--plain`
|
||||
- `add <model>`
|
||||
- `remove <model>`
|
||||
- `clear`
|
||||
|
||||
### `models scan`
|
||||
Options:
|
||||
- `--min-params <b>`
|
||||
- `--max-age-days <days>`
|
||||
- `--provider <name>`
|
||||
- `--max-candidates <n>`
|
||||
- `--timeout <ms>`
|
||||
- `--concurrency <n>`
|
||||
- `--yes`
|
||||
- `--no-input`
|
||||
- `--set-default`
|
||||
- `--set-image`
|
||||
- `--json`
|
||||
|
||||
## Cron + wake
|
||||
|
||||
### `wake`
|
||||
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
|
||||
|
||||
Required:
|
||||
- `--text <text>`
|
||||
|
||||
Options:
|
||||
- `--mode <now|next-heartbeat>`
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
### `cron`
|
||||
Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cron-jobs).
|
||||
|
||||
Subcommands:
|
||||
- `cron status [--json]`
|
||||
- `cron list [--all] [--json]`
|
||||
- `cron add` (requires `--name` and exactly one of `--at` | `--every` | `--cron`, and exactly one payload of `--system-event` | `--message`)
|
||||
- `cron edit <id>` (patch fields)
|
||||
- `cron rm <id>`
|
||||
- `cron enable <id>`
|
||||
- `cron disable <id>`
|
||||
- `cron runs --id <id> [--limit <n>]`
|
||||
- `cron run <id> [--force]`
|
||||
|
||||
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
||||
|
||||
## Nodes
|
||||
|
||||
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
||||
|
||||
Common options:
|
||||
- `--url`, `--token`, `--timeout`, `--json`
|
||||
|
||||
Subcommands:
|
||||
- `nodes status`
|
||||
- `nodes describe --node <id|name|ip>`
|
||||
- `nodes list`
|
||||
- `nodes pending`
|
||||
- `nodes approve <requestId>`
|
||||
- `nodes reject <requestId>`
|
||||
- `nodes rename --node <id|name|ip> --name <displayName>`
|
||||
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
|
||||
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac only)
|
||||
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
|
||||
|
||||
Camera:
|
||||
- `nodes camera list --node <id|name|ip>`
|
||||
- `nodes camera snap --node <id|name|ip> [--facing front|back|both] [--device-id <id>] [--max-width <px>] [--quality <0-1>] [--delay-ms <ms>] [--invoke-timeout <ms>]`
|
||||
- `nodes camera clip --node <id|name|ip> [--facing front|back] [--device-id <id>] [--duration <ms|10s|1m>] [--no-audio] [--invoke-timeout <ms>]`
|
||||
|
||||
Canvas + screen:
|
||||
- `nodes canvas snapshot --node <id|name|ip> [--format png|jpg|jpeg] [--max-width <px>] [--quality <0-1>] [--invoke-timeout <ms>]`
|
||||
- `nodes screen record --node <id|name|ip> [--screen <index>] [--duration <ms|10s>] [--fps <n>] [--no-audio] [--out <path>] [--invoke-timeout <ms>]`
|
||||
|
||||
Location:
|
||||
- `nodes location get --node <id|name|ip> [--max-age <ms>] [--accuracy <coarse|balanced|precise>] [--location-timeout <ms>] [--invoke-timeout <ms>]`
|
||||
|
||||
## Canvas
|
||||
|
||||
Canvas RPC helper (top-level wrapper for `node.invoke`). See [/platforms/mac/canvas](/platforms/mac/canvas).
|
||||
|
||||
Common options:
|
||||
- `--url`, `--token`, `--timeout`, `--json`
|
||||
|
||||
Subcommands:
|
||||
- `canvas snapshot [--node <id|name|ip>] [--format png|jpg] [--max-width <px>] [--quality <0-1>]`
|
||||
- `canvas present [--node <id|name|ip>] [--target <urlOrPath>] [--x <px>] [--y <px>] [--width <px>] [--height <px>]`
|
||||
- `canvas hide [--node <id|name|ip>]`
|
||||
- `canvas navigate <url> [--node <id|name|ip>]`
|
||||
- `canvas eval [<js>] [--js <code>] [--node <id|name|ip>]`
|
||||
- `canvas a2ui push (--jsonl <path> | --text <text>) [--node <id|name|ip>]`
|
||||
- `canvas a2ui reset [--node <id|name|ip>]`
|
||||
|
||||
## Browser
|
||||
|
||||
Browser control CLI (dedicated Chrome/Chromium). See [/tools/browser](/tools/browser).
|
||||
|
||||
Common options:
|
||||
- `--url <controlUrl>`
|
||||
- `--browser-profile <name>`
|
||||
- `--json`
|
||||
|
||||
Manage:
|
||||
- `browser status`
|
||||
- `browser start`
|
||||
- `browser stop`
|
||||
- `browser reset-profile`
|
||||
- `browser tabs`
|
||||
- `browser open <url>`
|
||||
- `browser focus <targetId>`
|
||||
- `browser close [targetId]`
|
||||
- `browser profiles`
|
||||
- `browser create-profile --name <name> [--color <hex>] [--cdp-url <url>]`
|
||||
- `browser delete-profile --name <name>`
|
||||
|
||||
Inspect:
|
||||
- `browser screenshot [targetId] [--full-page] [--ref <ref>] [--element <selector>] [--type png|jpeg]`
|
||||
- `browser snapshot [--format aria|ai] [--target-id <id>] [--limit <n>] [--out <path>]`
|
||||
|
||||
Actions:
|
||||
- `browser navigate <url> [--target-id <id>]`
|
||||
- `browser resize <width> <height> [--target-id <id>]`
|
||||
- `browser click <ref> [--double] [--button <left|right|middle>] [--modifiers <csv>] [--target-id <id>]`
|
||||
- `browser type <ref> <text> [--submit] [--slowly] [--target-id <id>]`
|
||||
- `browser press <key> [--target-id <id>]`
|
||||
- `browser hover <ref> [--target-id <id>]`
|
||||
- `browser drag <startRef> <endRef> [--target-id <id>]`
|
||||
- `browser select <ref> <values...> [--target-id <id>]`
|
||||
- `browser upload <paths...> [--ref <ref>] [--input-ref <ref>] [--element <selector>] [--target-id <id>] [--timeout-ms <ms>]`
|
||||
- `browser fill [--fields <json>] [--fields-file <path>] [--target-id <id>]`
|
||||
- `browser dialog --accept|--dismiss [--prompt <text>] [--target-id <id>] [--timeout-ms <ms>]`
|
||||
- `browser wait [--time <ms>] [--text <value>] [--text-gone <value>] [--target-id <id>]`
|
||||
- `browser evaluate --fn <code> [--ref <ref>] [--target-id <id>]`
|
||||
- `browser console [--level <error|warn|info>] [--target-id <id>]`
|
||||
- `browser pdf [--target-id <id>]`
|
||||
|
||||
## Docs search
|
||||
|
||||
### `docs [query...]`
|
||||
Search the live docs index.
|
||||
|
||||
## TUI
|
||||
|
||||
### `tui`
|
||||
Open the terminal UI connected to the Gateway.
|
||||
|
||||
Options:
|
||||
- `--url <url>`
|
||||
- `--token <token>`
|
||||
- `--password <password>`
|
||||
- `--session <key>`
|
||||
- `--deliver`
|
||||
- `--thinking <level>`
|
||||
- `--timeout-ms <ms>`
|
||||
- `--history-limit <n>`
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
summary: "Agent loop lifecycle, streams, and wait semantics"
|
||||
read_when:
|
||||
- You need an exact walkthrough of the agent loop or lifecycle events
|
||||
---
|
||||
# Agent Loop (Clawdis)
|
||||
|
||||
Short, exact flow of one agent run. Source of truth: current code in `src/`.
|
||||
|
||||
## Entry points
|
||||
- Gateway RPC: `agent` and `agent.wait` in [`src/gateway/server-methods/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/agent.ts).
|
||||
- CLI: `agentCommand` in [`src/commands/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/agent.ts).
|
||||
|
||||
## High-level flow
|
||||
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
||||
2) `agentCommand` runs the agent:
|
||||
- resolves model + thinking/verbose defaults
|
||||
- loads skills snapshot
|
||||
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||
3) `runEmbeddedPiAgent`:
|
||||
- builds `AgentSession` and subscribes to pi events
|
||||
- streams assistant deltas + tool events
|
||||
- enforces timeout -> aborts run if exceeded
|
||||
- returns payloads + usage metadata
|
||||
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdis `agent` stream:
|
||||
- tool events => `stream: "tool"`
|
||||
- assistant deltas => `stream: "assistant"`
|
||||
- lifecycle events => `stream: "lifecycle"` (`phase: "start" | "end" | "error"`)
|
||||
5) `agent.wait` uses `waitForAgentJob`:
|
||||
- waits for **lifecycle end/error** for `runId`
|
||||
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
||||
|
||||
## Event streams (today)
|
||||
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
||||
- `assistant`: streamed deltas from pi-agent-core
|
||||
- `tool`: streamed tool events from pi-agent-core
|
||||
|
||||
## Chat provider handling
|
||||
- `createAgentEventHandler` in [`src/gateway/server-chat.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-chat.ts):
|
||||
- buffers assistant deltas
|
||||
- emits chat `delta` messages
|
||||
- emits chat `final` when **lifecycle end/error** arrives
|
||||
|
||||
## Timeouts
|
||||
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
|
||||
- Agent runtime: `agent.timeoutSeconds` default 600s; enforced in `runEmbeddedPiAgent` abort timer.
|
||||
|
||||
## Where things can end early
|
||||
- Agent timeout (abort)
|
||||
- AbortSignal (cancel)
|
||||
- Gateway disconnect or RPC timeout
|
||||
- `agent.wait` timeout (wait-only, does not stop agent)
|
||||
|
||||
## Files
|
||||
- [`src/gateway/server-methods/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/agent.ts)
|
||||
- [`src/gateway/server-methods/agent-job.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/agent-job.ts)
|
||||
- [`src/commands/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/agent.ts)
|
||||
- [`src/agents/pi-embedded-runner.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-embedded-runner.ts)
|
||||
- [`src/agents/pi-embedded-subscribe.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-embedded-subscribe.ts)
|
||||
- [`src/gateway/server-chat.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-chat.ts)
|
||||
@@ -1,187 +0,0 @@
|
||||
---
|
||||
summary: "Agent workspace: location, layout, and backup strategy"
|
||||
read_when:
|
||||
- You need to explain the agent workspace or its file layout
|
||||
- You want to back up or migrate an agent workspace
|
||||
---
|
||||
# Agent workspace
|
||||
|
||||
The workspace is the agent's home. It is the only working directory used for
|
||||
file tools and for workspace context. Keep it private and treat it as memory.
|
||||
|
||||
This is separate from `~/.clawdbot/`, which stores config, credentials, and
|
||||
sessions.
|
||||
|
||||
## Default location
|
||||
|
||||
- Default: `~/clawd`
|
||||
- If `CLAWDBOT_PROFILE` is set and not `"default"`, the default becomes
|
||||
`~/clawd-<profile>`.
|
||||
- Override in `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
workspace: "~/clawd"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`clawdbot onboard`, `clawdbot configure`, or `clawdbot setup` will create the
|
||||
workspace and seed the bootstrap files if they are missing.
|
||||
|
||||
If you already manage the workspace files yourself, you can disable bootstrap
|
||||
file creation:
|
||||
|
||||
```json5
|
||||
{ agent: { skipBootstrap: true } }
|
||||
```
|
||||
|
||||
## Workspace file map (what each file means)
|
||||
|
||||
These are the standard files Clawdbot expects inside the workspace:
|
||||
|
||||
- `AGENTS.md`
|
||||
- Operating instructions for the agent and how it should use memory.
|
||||
- Loaded at the start of every session.
|
||||
- Good place for rules, priorities, and "how to behave" details.
|
||||
|
||||
- `SOUL.md`
|
||||
- Persona, tone, and boundaries.
|
||||
- Loaded every session.
|
||||
|
||||
- `USER.md`
|
||||
- Who the user is and how to address them.
|
||||
- Loaded every session.
|
||||
|
||||
- `IDENTITY.md`
|
||||
- The agent's name, vibe, and emoji.
|
||||
- Created/updated during the bootstrap ritual.
|
||||
|
||||
- `TOOLS.md`
|
||||
- Notes about your local tools and conventions.
|
||||
- Does not control tool availability; it is only guidance.
|
||||
|
||||
- `HEARTBEAT.md`
|
||||
- Optional tiny checklist for heartbeat runs.
|
||||
- Keep it short to avoid token burn.
|
||||
|
||||
- `BOOTSTRAP.md`
|
||||
- One-time first-run ritual.
|
||||
- Only created for a brand-new workspace.
|
||||
- Delete it after the ritual is complete.
|
||||
|
||||
- `memory/YYYY-MM-DD.md`
|
||||
- Daily memory log (one file per day).
|
||||
- Recommended to read today + yesterday on session start.
|
||||
|
||||
- `MEMORY.md` (optional)
|
||||
- Curated long-term memory.
|
||||
- Only load in the main, private session (not shared/group contexts).
|
||||
|
||||
- `skills/` (optional)
|
||||
- Workspace-specific skills.
|
||||
- Overrides managed/bundled skills when names collide.
|
||||
|
||||
- `canvas/` (optional)
|
||||
- Canvas UI files for node displays (for example `canvas/index.html`).
|
||||
|
||||
If any bootstrap file is missing, Clawdbot injects a "missing file" marker into
|
||||
the session and continues. `clawdbot setup` can recreate missing defaults
|
||||
without overwriting existing files.
|
||||
|
||||
## What is NOT in the workspace
|
||||
|
||||
These live under `~/.clawdbot/` and should NOT be committed to the workspace repo:
|
||||
|
||||
- `~/.clawdbot/clawdbot.json` (config)
|
||||
- `~/.clawdbot/credentials/` (OAuth tokens, API keys)
|
||||
- `~/.clawdbot/agents/<agentId>/sessions/` (session transcripts + metadata)
|
||||
- `~/.clawdbot/skills/` (managed skills)
|
||||
|
||||
If you need to migrate sessions or config, copy them separately and keep them
|
||||
out of version control.
|
||||
|
||||
## Git backup (recommended, private)
|
||||
|
||||
Treat the workspace as private memory. Put it in a **private** git repo so it is
|
||||
backed up and recoverable.
|
||||
|
||||
Run these steps on the machine where the Gateway runs (that is where the
|
||||
workspace lives).
|
||||
|
||||
### 1) Initialize the repo
|
||||
|
||||
```bash
|
||||
cd ~/clawd
|
||||
git init
|
||||
git add AGENTS.md SOUL.md TOOLS.md IDENTITY.md USER.md HEARTBEAT.md memory/
|
||||
git commit -m "Add agent workspace"
|
||||
```
|
||||
|
||||
### 2) Add a private remote (beginner-friendly options)
|
||||
|
||||
Option A: GitHub web UI
|
||||
|
||||
1. Create a new **private** repository on GitHub.
|
||||
2. Do not initialize with a README (avoids merge conflicts).
|
||||
3. Copy the HTTPS remote URL.
|
||||
4. Add the remote and push:
|
||||
|
||||
```bash
|
||||
git branch -M main
|
||||
git remote add origin <https-url>
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
Option B: GitHub CLI (`gh`)
|
||||
|
||||
```bash
|
||||
gh auth login
|
||||
gh repo create clawd-workspace --private --source . --remote origin --push
|
||||
```
|
||||
|
||||
### 3) Ongoing updates
|
||||
|
||||
```bash
|
||||
git status
|
||||
git add .
|
||||
git commit -m "Update memory"
|
||||
git push
|
||||
```
|
||||
|
||||
## Do not commit secrets
|
||||
|
||||
Even in a private repo, avoid storing secrets in the workspace:
|
||||
|
||||
- API keys, OAuth tokens, passwords, or private credentials.
|
||||
- Anything under `~/.clawdbot/`.
|
||||
- Raw dumps of chats or sensitive attachments.
|
||||
|
||||
If you must store sensitive references, use placeholders and keep the real
|
||||
secret elsewhere (password manager, environment variables, or `~/.clawdbot/`).
|
||||
|
||||
Suggested `.gitignore` starter:
|
||||
|
||||
```gitignore
|
||||
.DS_Store
|
||||
.env
|
||||
**/*.key
|
||||
**/*.pem
|
||||
**/secrets*
|
||||
```
|
||||
|
||||
## Moving the workspace to a new machine
|
||||
|
||||
1. Clone the repo to the desired path (default `~/clawd`).
|
||||
2. Set `agent.workspace` to that path in `~/.clawdbot/clawdbot.json`.
|
||||
3. Run `clawdbot setup --workspace <path>` to seed any missing files.
|
||||
4. If you need sessions, copy `~/.clawdbot/agents/<agentId>/sessions/` from the
|
||||
old machine separately.
|
||||
|
||||
## Advanced notes
|
||||
|
||||
- Multi-agent routing can use different workspaces per agent. See
|
||||
`docs/provider-routing.md` for routing configuration.
|
||||
- If `agent.sandbox` is enabled, non-main sessions can use per-session sandbox
|
||||
workspaces under `agent.sandbox.workspaceRoot`.
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage)"
|
||||
read_when:
|
||||
- Changing group chat behavior or mention gating
|
||||
---
|
||||
# Groups
|
||||
|
||||
Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage.
|
||||
|
||||
## Session keys
|
||||
- Group sessions use `agent:<agentId>:<provider>:group:<id>` session keys (rooms/channels use `agent:<agentId>:<provider>:channel:<id>`).
|
||||
- Telegram forum topics add `:topic:<threadId>` to the group id so each topic has its own session.
|
||||
- Direct chats use the main session (or per-sender if configured).
|
||||
- Heartbeats are skipped for group sessions.
|
||||
|
||||
## Display labels
|
||||
- UI labels use `displayName` when available, formatted as `<provider>:<token>`.
|
||||
- `#room` is reserved for rooms/channels; group chats use `g-<slug>` (lowercase, spaces -> `-`, keep `#@+._-`).
|
||||
|
||||
## Group policy
|
||||
Control how group/room messages are handled per provider:
|
||||
|
||||
```json5
|
||||
{
|
||||
whatsapp: {
|
||||
groupPolicy: "disabled", // "open" | "disabled" | "allowlist"
|
||||
groupAllowFrom: ["+15551234567"]
|
||||
},
|
||||
telegram: {
|
||||
groupPolicy: "disabled",
|
||||
groupAllowFrom: ["123456789", "@username"]
|
||||
},
|
||||
signal: {
|
||||
groupPolicy: "disabled",
|
||||
groupAllowFrom: ["+15551234567"]
|
||||
},
|
||||
imessage: {
|
||||
groupPolicy: "disabled",
|
||||
groupAllowFrom: ["chat_id:123"]
|
||||
},
|
||||
discord: {
|
||||
groupPolicy: "allowlist",
|
||||
guilds: {
|
||||
"GUILD_ID": { channels: { help: { allow: true } } }
|
||||
}
|
||||
},
|
||||
slack: {
|
||||
groupPolicy: "allowlist",
|
||||
channels: { "#general": { allow: true } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Policy | Behavior |
|
||||
|--------|----------|
|
||||
| `"open"` | Default. Groups bypass allowlists; mention-gating still applies. |
|
||||
| `"disabled"` | Block all group messages entirely. |
|
||||
| `"allowlist"` | Only allow groups/rooms that match the configured allowlist. |
|
||||
|
||||
Notes:
|
||||
- `groupPolicy` is separate from mention-gating (which requires @mentions).
|
||||
- WhatsApp/Telegram/Signal/iMessage: use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||
- Discord: allowlist uses `discord.guilds.<id>.channels`.
|
||||
- Slack: allowlist uses `slack.channels`.
|
||||
- Group DMs are controlled separately (`discord.dm.*`, `slack.dm.*`).
|
||||
- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
|
||||
|
||||
## Mention gating (default)
|
||||
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
|
||||
|
||||
```json5
|
||||
{
|
||||
whatsapp: {
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123@g.us": { requireMention: false }
|
||||
}
|
||||
},
|
||||
telegram: {
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123456789": { requireMention: false }
|
||||
}
|
||||
},
|
||||
imessage: {
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123": { requireMention: false }
|
||||
}
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["@clawd", "clawdbot", "\\+15555550123"],
|
||||
historyLimit: 50
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `mentionPatterns` are case-insensitive regexes.
|
||||
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
|
||||
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
|
||||
- Discord defaults live in `discord.guilds."*"` (overridable per guild/channel).
|
||||
|
||||
## Group allowlists
|
||||
When `whatsapp.groups`, `telegram.groups`, or `imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
|
||||
|
||||
## Activation (owner-only)
|
||||
Group owners can toggle per-group activation:
|
||||
- `/activation mention`
|
||||
- `/activation always`
|
||||
|
||||
Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Send the command as a standalone message. Other surfaces currently ignore `/activation`.
|
||||
|
||||
## Context fields
|
||||
Group inbound payloads set:
|
||||
- `ChatType=group`
|
||||
- `GroupSubject` (if known)
|
||||
- `GroupMembers` (if known)
|
||||
- `WasMentioned` (mention gating result)
|
||||
- Telegram forum topics also include `MessageThreadId` and `IsForum`.
|
||||
|
||||
The agent system prompt includes a group intro on the first turn of a new group session.
|
||||
|
||||
## iMessage specifics
|
||||
- Prefer `chat_id:<id>` when routing or allowlisting.
|
||||
- List chats: `imsg chats --limit 20`.
|
||||
- Group replies always go back to the same `chat_id`.
|
||||
|
||||
## WhatsApp specifics
|
||||
See [`docs/group-messages.md`](/concepts/group-messages) for WhatsApp-only behavior (history injection, mention handling details).
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
summary: "How Clawdbot rotates auth profiles and falls back across models"
|
||||
read_when:
|
||||
- Diagnosing auth profile rotation, cooldowns, or model fallback behavior
|
||||
- Updating failover rules for auth profiles or models
|
||||
---
|
||||
|
||||
# Model failover
|
||||
|
||||
Clawdbot handles failures in two stages:
|
||||
1) **Auth profile rotation** within the current provider.
|
||||
2) **Model fallback** to the next model in `agent.model.fallbacks`.
|
||||
|
||||
This doc explains the runtime rules and the data that backs them.
|
||||
|
||||
## Auth storage (keys + OAuth)
|
||||
|
||||
Clawdbot uses **auth profiles** for both API keys and OAuth tokens.
|
||||
|
||||
- Secrets live in `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` (legacy: `~/.clawdbot/agent/auth-profiles.json`).
|
||||
- Config `auth.profiles` / `auth.order` are **metadata + routing only** (no secrets).
|
||||
- Legacy import-only OAuth file: `~/.clawdbot/credentials/oauth.json` (imported into `auth-profiles.json` on first use).
|
||||
|
||||
Credential types:
|
||||
- `type: "api_key"` → `{ provider, key }`
|
||||
- `type: "oauth"` → `{ provider, access, refresh, expires, email? }` (+ `projectId`/`enterpriseUrl` for some providers)
|
||||
|
||||
## Profile IDs
|
||||
|
||||
OAuth logins create distinct profiles so multiple accounts can coexist.
|
||||
- Default: `provider:default` when no email is available.
|
||||
- OAuth with email: `provider:<email>` (for example `google-antigravity:user@gmail.com`).
|
||||
|
||||
Profiles live in `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` under `profiles`.
|
||||
|
||||
## Rotation order
|
||||
|
||||
When a provider has multiple profiles, Clawdbot chooses an order like this:
|
||||
|
||||
1) **Explicit config**: `auth.order[provider]` (if set).
|
||||
2) **Configured profiles**: `auth.profiles` filtered by provider.
|
||||
3) **Stored profiles**: entries in `auth-profiles.json` for the provider.
|
||||
|
||||
If no explicit order is configured, Clawdbot uses a round‑robin order:
|
||||
- **Primary key:** profile type (**OAuth before API keys**).
|
||||
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
|
||||
- **Cooldown profiles** are moved to the end, ordered by soonest cooldown expiry.
|
||||
|
||||
### Why OAuth can “look lost”
|
||||
|
||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||
- Pin with `auth.order[provider] = ["provider:profileId"]`, or
|
||||
- Use a per-session override via `/model …` with a profile override (when supported by your UI/chat surface).
|
||||
|
||||
## Cooldowns
|
||||
|
||||
When a profile fails due to auth/rate‑limit errors (or a timeout that looks
|
||||
like rate limiting), Clawdbot marks it in cooldown and moves to the next profile.
|
||||
|
||||
Cooldowns use exponential backoff:
|
||||
- 1 minute
|
||||
- 5 minutes
|
||||
- 25 minutes
|
||||
- 1 hour (cap)
|
||||
|
||||
State is stored in `auth-profiles.json` under `usageStats`:
|
||||
|
||||
```json
|
||||
{
|
||||
"usageStats": {
|
||||
"provider:profile": {
|
||||
"lastUsed": 1736160000000,
|
||||
"cooldownUntil": 1736160600000,
|
||||
"errorCount": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Model fallback
|
||||
|
||||
If all profiles for a provider fail, Clawdbot moves to the next model in
|
||||
`agent.model.fallbacks`. This applies to auth failures, rate limits, and
|
||||
timeouts that exhausted profile rotation.
|
||||
|
||||
## Related config
|
||||
|
||||
See [`docs/configuration.md`](/gateway/configuration) for:
|
||||
- `auth.profiles` / `auth.order`
|
||||
- `agent.model.primary` / `agent.model.fallbacks`
|
||||
- `agent.imageModel` routing
|
||||
|
||||
See [`docs/models.md`](/concepts/models) for the broader model selection and fallback overview.
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
summary: "Plan for models CLI: scan, list, aliases, fallbacks, status"
|
||||
read_when:
|
||||
- Adding or modifying models CLI (models list/set/scan/aliases/fallbacks)
|
||||
- Changing model fallback behavior or selection UX
|
||||
- Updating model scan probes (tools/images)
|
||||
---
|
||||
# Models CLI plan
|
||||
|
||||
See [`docs/model-failover.md`](/concepts/model-failover) for how auth profiles rotate (OAuth vs API keys), cooldowns, and how that interacts with model fallbacks.
|
||||
|
||||
Goal: give clear model visibility + control (configured vs available), plus scan tooling
|
||||
that prefers tool-call + image-capable models and maintains ordered fallbacks.
|
||||
|
||||
## Model recommendations
|
||||
|
||||
Through testing, we’ve found [Claude Opus 4.5](https://www.anthropic.com/claude/opus) is the most useful general-purpose model for anything coding-related. We suggest [GPT-5.2-Codex](https://developers.openai.com/codex/models) for coding and sub-agents. For personal assistant work, nothing comes close to Opus. If you’re going all-in on Claude, we recommend the [Claude Max $200 subscription](https://www.anthropic.com/pricing/).
|
||||
|
||||
## Model discussions (community notes)
|
||||
|
||||
Anecdotal notes from the Discord thread on January 4–5, 2026. Treat as “what people reported,” not guarantees.
|
||||
|
||||
**Reported working well**
|
||||
- [Claude Opus 4.5](https://www.anthropic.com/claude/opus): best quality, but expensive and easy to hit limits.
|
||||
- [Claude Sonnet 4.5](https://www.anthropic.com/claude/sonnet): solid fallback when Opus caps out.
|
||||
- [GLM](https://www.zhipuai.cn/en/): used as a worker model under orchestration.
|
||||
- [MiniMax M2.1](https://platform.minimax.io/docs/guides/models-intro): “good enough” fallback for grunt tasks.
|
||||
- “Temu-Sonnet” (community shorthand) for MiniMax quality vs Claude Sonnet.
|
||||
- [Gemini 3 Pro](https://deepmind.google/en/models/gemini/pro/): some users said it maps Clawdbot structure well.
|
||||
|
||||
**Mixed / unclear**
|
||||
- [Antigravity](https://blog.google/technology/ai/google-ai-updates-november-2025/) (Claude Opus access): some reported extra Opus quota, pricing/limits unclear.
|
||||
|
||||
**Reported weak in Clawdbot**
|
||||
- [GPT-5.2-Codex](https://developers.openai.com/codex/models) inside Clawdbot: considered rough for conversation or assistant tasks.
|
||||
- [Grok](https://docs.x.ai/docs/models/grok-4): tried, abandoned.
|
||||
|
||||
**Tooling note**
|
||||
- [Codex CLI](https://developers.openai.com/codex/cli) felt stronger than embedded use.
|
||||
|
||||
**Theme**
|
||||
- Token burn feels higher than expected in long sessions; people suspect context buildup + tool outputs. Pruning/compaction helps. Check session logs before blaming providers. See [/concepts/session](/concepts/session) and [/concepts/model-failover](/concepts/model-failover).
|
||||
|
||||
## Models CLI
|
||||
|
||||
See [/cli](/cli) for the full command tree and CLI flags.
|
||||
|
||||
## Config changes
|
||||
|
||||
- `agent.models` (configured model catalog + aliases).
|
||||
- `agent.model.primary` + `agent.model.fallbacks`.
|
||||
- `agent.imageModel.primary` + `agent.imageModel.fallbacks` (optional).
|
||||
- `auth.profiles` + `auth.order` for per-provider auth failover.
|
||||
|
||||
## Scan behavior (models scan)
|
||||
|
||||
Input
|
||||
- OpenRouter `/models` list (filter `:free`)
|
||||
- Requires OpenRouter API key from auth profiles or `OPENROUTER_API_KEY`
|
||||
- Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates`
|
||||
- Probe controls: `--timeout`, `--concurrency`
|
||||
|
||||
Probes (direct pi-ai complete)
|
||||
- Tool-call probe (required):
|
||||
- Provide a dummy tool, verify tool call emitted.
|
||||
- Image probe (preferred):
|
||||
- Prompt includes 1x1 PNG; success if no "unsupported image" error.
|
||||
|
||||
Scoring/selection
|
||||
- Prefer models passing tool + image for text/tool fallbacks.
|
||||
- Prefer image-only models for image tool fallback (even if tool probe fails).
|
||||
- Rank by: image ok, then lower tool latency, then larger context, then params.
|
||||
|
||||
Interactive selection (TTY)
|
||||
- Multiselect list with per-model stats:
|
||||
- model id, tool ok, image ok, median latency, context, inferred params.
|
||||
- Pre-select top N (default 6).
|
||||
- Non-TTY: auto-select; require `--yes`/`--no-input` to apply.
|
||||
|
||||
Output
|
||||
- Writes `agent.model.fallbacks` ordered.
|
||||
- Writes `agent.imageModel.fallbacks` ordered (image-capable models).
|
||||
- Ensures `agent.models` entries exist for selected models.
|
||||
- Optional `--set-default` to set `agent.model.primary`.
|
||||
- Optional `--set-image` to set `agent.imageModel.primary`.
|
||||
|
||||
## Runtime fallback
|
||||
|
||||
- On model failure: try `agent.model.fallbacks` in order.
|
||||
- Per-provider auth failover uses `auth.order` (or stored profile order) **before**
|
||||
moving to the next model.
|
||||
- Image routing uses `agent.imageModel` **only when configured** and the primary
|
||||
model lacks image input.
|
||||
- Persist last successful provider/model to session entry; auth profile success is global.
|
||||
- See [`docs/model-failover.md`](/concepts/model-failover) for auth profile rotation, cooldowns, and timeout handling.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit: scan selection ordering + probe classification.
|
||||
- CLI: list/aliases/fallbacks add/remove + scan writes config.
|
||||
- Status: shows last used model + fallbacks.
|
||||
|
||||
## Docs
|
||||
|
||||
- Update [`docs/configuration.md`](/gateway/configuration) with `agent.models` + `agent.model` + `agent.imageModel`.
|
||||
- Keep this doc current when CLI surface or scan logic changes.
|
||||
- Note provider aliases like `z.ai/*` -> `zai/*` when relevant.
|
||||
- Provider ids in model refs are normalized to lowercase.
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
summary: "Multi-agent routing: isolated agents, provider accounts, and bindings"
|
||||
title: Multi-Agent Routing
|
||||
read_when: "You want multiple isolated agents (workspaces + auth) in one gateway process."
|
||||
status: active
|
||||
---
|
||||
|
||||
# Multi-Agent Routing
|
||||
|
||||
Goal: multiple *isolated* agents (separate workspace + `agentDir` + sessions), plus multiple provider accounts (e.g. two WhatsApps) in one running Gateway. Inbound is routed to an agent via bindings.
|
||||
|
||||
## What is “one agent”?
|
||||
|
||||
An **agent** is a fully scoped brain with its own:
|
||||
|
||||
- **Workspace** (files, AGENTS.md/SOUL.md/USER.md, local notes, persona rules).
|
||||
- **State directory** (`agentDir`) for auth profiles, model registry, and per-agent config.
|
||||
- **Session store** (chat history + routing state) under `~/.clawdbot/agents/<agentId>/sessions`.
|
||||
|
||||
The Gateway can host **one agent** (default) or **many agents** side-by-side.
|
||||
|
||||
### Single-agent mode (default)
|
||||
|
||||
If you do nothing, Clawdbot runs a single agent:
|
||||
|
||||
- `agentId` defaults to **`main`**.
|
||||
- Sessions are keyed as `agent:main:<mainKey>`.
|
||||
- Workspace defaults to `~/clawd` (or `~/clawd-<profile>` when `CLAWDBOT_PROFILE` is set).
|
||||
- State defaults to `~/.clawdbot/agents/main/agent`.
|
||||
|
||||
## Agent helper
|
||||
|
||||
Use the agent wizard to add a new isolated agent:
|
||||
|
||||
```bash
|
||||
clawdbot agents add work
|
||||
```
|
||||
|
||||
Then add `routing.bindings` (or let the wizard do it) to route inbound messages.
|
||||
|
||||
## Multiple agents = multiple people, multiple personalities
|
||||
|
||||
With **multiple agents**, each `agentId` becomes a **fully isolated persona**:
|
||||
|
||||
- **Different phone numbers/accounts** (per provider `accountId`).
|
||||
- **Different personalities** (per-agent workspace files like `AGENTS.md` and `SOUL.md`).
|
||||
- **Separate auth + sessions** (no cross-talk unless explicitly enabled).
|
||||
|
||||
This lets **multiple people** share one Gateway server while keeping their AI “brains” and data isolated.
|
||||
|
||||
## Routing rules (how messages pick an agent)
|
||||
|
||||
Bindings are **deterministic** and **most-specific wins**:
|
||||
|
||||
1. `peer` match (exact DM/group/channel id)
|
||||
2. `guildId` (Discord)
|
||||
3. `teamId` (Slack)
|
||||
4. `accountId` match for a provider
|
||||
5. provider-level match (`accountId: "*"`)
|
||||
6. fallback to `routing.defaultAgentId` (default: `main`)
|
||||
|
||||
## Multiple accounts / phone numbers
|
||||
|
||||
Providers that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify
|
||||
each login. Each `accountId` can be routed to a different agent, so one server can host
|
||||
multiple phone numbers without mixing sessions.
|
||||
|
||||
## Concepts
|
||||
|
||||
- `agentId`: one “brain” (workspace, per-agent auth, per-agent session store).
|
||||
- `accountId`: one provider account instance (e.g. WhatsApp account `"personal"` vs `"biz"`).
|
||||
- `binding`: routes inbound messages to an `agentId` by `(provider, accountId, peer)` and optionally guild/team ids.
|
||||
- Direct chats collapse to `agent:<agentId>:<mainKey>` (per-agent “main”; `session.mainKey`).
|
||||
|
||||
## Example: two WhatsApps → two agents
|
||||
|
||||
`~/.clawdbot/clawdbot.json` (JSON5):
|
||||
|
||||
```js
|
||||
{
|
||||
routing: {
|
||||
defaultAgentId: "home",
|
||||
|
||||
agents: {
|
||||
home: {
|
||||
name: "Home",
|
||||
workspace: "~/clawd-home",
|
||||
agentDir: "~/.clawdbot/agents/home/agent",
|
||||
},
|
||||
work: {
|
||||
name: "Work",
|
||||
workspace: "~/clawd-work",
|
||||
agentDir: "~/.clawdbot/agents/work/agent",
|
||||
},
|
||||
},
|
||||
|
||||
// Deterministic routing: first match wins (most-specific first).
|
||||
bindings: [
|
||||
{ agentId: "home", match: { provider: "whatsapp", accountId: "personal" } },
|
||||
{ agentId: "work", match: { provider: "whatsapp", accountId: "biz" } },
|
||||
|
||||
// Optional per-peer override (example: send a specific group to work agent).
|
||||
{
|
||||
agentId: "work",
|
||||
match: {
|
||||
provider: "whatsapp",
|
||||
accountId: "personal",
|
||||
peer: { kind: "group", id: "1203630...@g.us" },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Off by default: agent-to-agent messaging must be explicitly enabled + allowlisted.
|
||||
agentToAgent: {
|
||||
enabled: false,
|
||||
allow: ["home", "work"],
|
||||
},
|
||||
},
|
||||
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
personal: {
|
||||
// Optional override. Default: ~/.clawdbot/credentials/whatsapp/personal
|
||||
// authDir: "~/.clawdbot/credentials/whatsapp/personal",
|
||||
},
|
||||
biz: {
|
||||
// Optional override. Default: ~/.clawdbot/credentials/whatsapp/biz
|
||||
// authDir: "~/.clawdbot/credentials/whatsapp/biz",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
summary: "Routing rules per provider (WhatsApp, Telegram, Discord, web) and shared context"
|
||||
read_when:
|
||||
- Changing provider routing or inbox behavior
|
||||
---
|
||||
# Providers & Routing
|
||||
|
||||
Updated: 2026-01-06
|
||||
|
||||
Goal: deterministic replies per provider, while supporting multi-agent + multi-account routing.
|
||||
|
||||
- **Provider**: provider label (`whatsapp`, `webchat`, `telegram`, `discord`, `signal`, `imessage`, …). Routing is fixed: replies go back to the origin provider; the model doesn’t choose.
|
||||
- **AccountId**: provider account instance (e.g. WhatsApp account `"default"` vs `"work"`). Not every provider supports multi-account yet.
|
||||
- **AgentId**: one isolated “brain” (workspace + per-agent agentDir + per-agent session store).
|
||||
- **Reply context:** inbound replies include `ReplyToId`, `ReplyToBody`, and `ReplyToSender`, and the quoted context is appended to `Body` as a `[Replying to ...]` block.
|
||||
- **Canonical direct session (per agent):** direct chats collapse to `agent:<agentId>:<mainKey>` (default `main`). Groups/channels stay isolated per agent:
|
||||
- group: `agent:<agentId>:<provider>:group:<id>`
|
||||
- channel/room: `agent:<agentId>:<provider>:channel:<id>`
|
||||
- Telegram forum topics: `agent:<agentId>:telegram:group:<chatId>:topic:<threadId>`
|
||||
- **Session store:** per-agent store lives under `~/.clawdbot/agents/<agentId>/sessions/sessions.json` (override via `session.store` with `{agentId}` templating). JSONL transcripts live next to it.
|
||||
- **WebChat:** attaches to the selected agent’s main session (so desktop reflects cross-provider history for that agent).
|
||||
- **Implementation hints:**
|
||||
- Set `Provider` + `AccountId` in each ingress.
|
||||
- Route inbound to an agent via `routing.bindings` (match on `provider`, `accountId`, plus optional peer/guild/team).
|
||||
- Keep routing deterministic: originate → same provider. Use the gateway WebSocket for sends; avoid side channels.
|
||||
- Do not let the agent emit “send to X” decisions; keep that policy in the host code.
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
summary: "Timezone handling for agents, envelopes, and prompts"
|
||||
read_when:
|
||||
- You need to understand how timestamps are normalized for the model
|
||||
- Configuring the user timezone for system prompts
|
||||
---
|
||||
|
||||
# Timezones
|
||||
|
||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||
|
||||
## Message envelopes (UTC)
|
||||
|
||||
Inbound messages are wrapped in an envelope like:
|
||||
|
||||
```
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
The timestamp in the envelope is **always UTC**, with minutes precision.
|
||||
|
||||
## Tool payloads (raw provider data)
|
||||
|
||||
Tool calls (`discord.readMessages`, `slack.readMessages`, etc.) return **raw provider timestamps**.
|
||||
These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We do not rewrite them.
|
||||
|
||||
## User timezone for the system prompt
|
||||
|
||||
Set `agent.userTimezone` to tell the model the user's local time zone. If it is
|
||||
unset, Clawdbot resolves the **host timezone at runtime** (no config write).
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { userTimezone: "America/Chicago" }
|
||||
}
|
||||
```
|
||||
|
||||
The system prompt includes:
|
||||
- `User timezone: America/Chicago`
|
||||
- `Current user time: 2026-01-05 15:26`
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
summary: "Usage tracking surfaces and credential requirements"
|
||||
read_when:
|
||||
- You are wiring provider usage/quota surfaces
|
||||
- You need to explain usage tracking behavior or auth requirements
|
||||
---
|
||||
# Usage tracking
|
||||
|
||||
## What it is
|
||||
- Pulls provider usage/quota directly from their usage endpoints.
|
||||
- No estimated costs; only the provider-reported windows.
|
||||
|
||||
## Where it shows up
|
||||
- `/status` in chats: adds a short “Usage” line (only if available).
|
||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||
- macOS menu bar: “Usage” section under Context (only if available).
|
||||
|
||||
## Providers + credentials
|
||||
- **Anthropic (Claude)**: OAuth tokens in auth profiles.
|
||||
- **GitHub Copilot**: OAuth tokens in auth profiles.
|
||||
- **Gemini CLI**: OAuth tokens in auth profiles.
|
||||
- **Antigravity**: OAuth tokens in auth profiles.
|
||||
- **OpenAI Codex**: OAuth tokens in auth profiles (accountId used when present).
|
||||
- **z.ai**: API key via env/config/auth store.
|
||||
|
||||
Usage is hidden if no matching OAuth/API credentials exist.
|
||||
|
||||
@@ -3,13 +3,14 @@ summary: "All configuration options for ~/.clawdbot/clawdbot.json with examples"
|
||||
read_when:
|
||||
- Adding or modifying config fields
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Configuration 🔧
|
||||
|
||||
CLAWDBOT reads an optional **JSON5** config from `~/.clawdbot/clawdbot.json` (comments + trailing commas allowed).
|
||||
|
||||
If the file is missing, CLAWDBOT uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
|
||||
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
|
||||
- control group allowlists + mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`)
|
||||
- control group mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`)
|
||||
- customize message prefixes (`messages`)
|
||||
- set the agent's workspace (`agent.workspace`)
|
||||
- tune the embedded agent (`agent`) and session behavior (`session`)
|
||||
@@ -59,83 +60,13 @@ To prevent the bot from responding to WhatsApp @-mentions in groups (only respon
|
||||
|
||||
## Common options
|
||||
|
||||
### Env vars + `.env`
|
||||
|
||||
CLAWDBOT reads env vars from the parent process (shell, launchd/systemd, CI, etc.).
|
||||
|
||||
Additionally, it loads:
|
||||
- `.env` from the current working directory (if present)
|
||||
- a global fallback `.env` from `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`)
|
||||
|
||||
Neither `.env` file overrides existing env vars.
|
||||
|
||||
### `env.shellEnv` (optional)
|
||||
|
||||
Opt-in convenience: if enabled and none of the expected keys are set yet, CLAWDBOT runs your login shell and imports only the missing expected keys (never overrides).
|
||||
This effectively sources your shell profile.
|
||||
|
||||
```json5
|
||||
{
|
||||
env: {
|
||||
shellEnv: {
|
||||
enabled: true,
|
||||
timeoutMs: 15000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Env var equivalent:
|
||||
- `CLAWDBOT_LOAD_SHELL_ENV=1`
|
||||
- `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`
|
||||
|
||||
### Auth storage (OAuth + API keys)
|
||||
|
||||
Clawdbot stores **per-agent** auth profiles (OAuth + API keys) in:
|
||||
- `<agentDir>/auth-profiles.json` (default: `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json`)
|
||||
|
||||
Legacy OAuth imports:
|
||||
- `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)
|
||||
|
||||
The embedded Pi agent maintains a runtime cache at:
|
||||
- `<agentDir>/auth.json` (managed automatically; don’t edit manually)
|
||||
|
||||
Legacy agent dir (pre multi-agent):
|
||||
- `~/.clawdbot/agent/*` (migrated by `clawdbot doctor` into `~/.clawdbot/agents/<defaultAgentId>/agent/*`)
|
||||
|
||||
Overrides:
|
||||
- OAuth dir (legacy import only): `CLAWDBOT_OAUTH_DIR`
|
||||
- Agent dir (default agent root override): `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy)
|
||||
|
||||
On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`.
|
||||
|
||||
### `auth`
|
||||
|
||||
Optional metadata for auth profiles. This does **not** store secrets; it maps
|
||||
profile IDs to a provider + mode (and optional email) and defines the provider
|
||||
rotation order used for failover.
|
||||
|
||||
```json5
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:me@example.com": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
|
||||
"anthropic:work": { provider: "anthropic", mode: "api_key" }
|
||||
},
|
||||
order: {
|
||||
anthropic: ["anthropic:me@example.com", "anthropic:work"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `identity`
|
||||
|
||||
Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
|
||||
|
||||
If set, CLAWDBOT derives defaults (only when you haven’t set them explicitly):
|
||||
- `messages.ackReaction` from `identity.emoji` (falls back to 👀)
|
||||
- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
|
||||
- `messages.responsePrefix` from `identity.emoji`
|
||||
- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups)
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -151,7 +82,7 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
||||
{
|
||||
wizard: {
|
||||
lastRunAt: "2026-01-01T00:00:00.000Z",
|
||||
lastRunVersion: "2026.1.4",
|
||||
lastRunVersion: "2.0.0-beta5",
|
||||
lastRunCommit: "abc1234",
|
||||
lastRunCommand: "configure",
|
||||
lastRunMode: "local"
|
||||
@@ -166,9 +97,6 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
||||
- Console output can be tuned separately via:
|
||||
- `logging.consoleLevel` (defaults to `info`, bumps to `debug` when `--verbose`)
|
||||
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
|
||||
- Tool summaries can be redacted to avoid leaking secrets:
|
||||
- `logging.redactSensitive` (`off` | `tools`, default: `tools`)
|
||||
- `logging.redactPatterns` (array of regex strings; overrides defaults)
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -176,70 +104,25 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
||||
level: "info",
|
||||
file: "/tmp/clawdbot/clawdbot.log",
|
||||
consoleLevel: "info",
|
||||
consoleStyle: "pretty",
|
||||
redactSensitive: "tools",
|
||||
redactPatterns: [
|
||||
// Example: override defaults with your own rules.
|
||||
"\\bTOKEN\\b\\s*[=:]\\s*([\"']?)([^\\s\"']+)\\1",
|
||||
"/\\bsk-[A-Za-z0-9_-]{8,}\\b/gi"
|
||||
]
|
||||
consoleStyle: "pretty"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `whatsapp.dmPolicy`
|
||||
|
||||
Controls how WhatsApp direct chats (DMs) are handled:
|
||||
- `"pairing"` (default): unknown senders get a pairing code; owner must approve
|
||||
- `"allowlist"`: only allow senders in `whatsapp.allowFrom` (or paired allow store)
|
||||
- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`)
|
||||
- `"disabled"`: ignore all inbound DMs
|
||||
|
||||
Pairing codes expire after 1 hour; the bot only sends a pairing code when a new request is created.
|
||||
|
||||
Pairing approvals:
|
||||
- `clawdbot pairing list --provider whatsapp`
|
||||
- `clawdbot pairing approve --provider whatsapp <code>`
|
||||
|
||||
### `whatsapp.allowFrom`
|
||||
|
||||
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (**DMs only**).
|
||||
If empty and `whatsapp.dmPolicy="pairing"`, unknown senders will receive a pairing code.
|
||||
For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`.
|
||||
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (DMs only).
|
||||
If empty, the default allowlist is your own WhatsApp number (self-chat mode).
|
||||
|
||||
```json5
|
||||
{
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["+15555550123", "+447700900123"],
|
||||
textChunkLimit: 4000 // optional outbound chunk size (chars)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `whatsapp.accounts` (multi-account)
|
||||
|
||||
Run multiple WhatsApp accounts in one gateway:
|
||||
|
||||
```json5
|
||||
{
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
default: {}, // optional; keeps the default id stable
|
||||
personal: {},
|
||||
biz: {
|
||||
// Optional override. Default: ~/.clawdbot/credentials/whatsapp/biz
|
||||
// authDir: "~/.clawdbot/credentials/whatsapp/biz",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
|
||||
- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`.
|
||||
|
||||
### `routing.groupChat`
|
||||
|
||||
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
|
||||
@@ -247,7 +130,6 @@ Group messages default to **require mention** (either metadata mention or regex
|
||||
**Mention types:**
|
||||
- **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`).
|
||||
- **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode.
|
||||
- Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`).
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -260,7 +142,7 @@ Group messages default to **require mention** (either metadata mention or regex
|
||||
}
|
||||
```
|
||||
|
||||
Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`). When `*.groups` is set, it also acts as a group allowlist; include `"*"` to allow all groups.
|
||||
Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`).
|
||||
|
||||
To respond **only** to specific text triggers (ignoring native @-mentions):
|
||||
```json5
|
||||
@@ -279,116 +161,6 @@ To respond **only** to specific text triggers (ignoring native @-mentions):
|
||||
}
|
||||
```
|
||||
|
||||
### Group policy (per provider)
|
||||
|
||||
Use `*.groupPolicy` to control whether group/room messages are accepted at all:
|
||||
|
||||
```json5
|
||||
{
|
||||
whatsapp: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["+15551234567"]
|
||||
},
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["tg:123456789", "@alice"]
|
||||
},
|
||||
signal: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["+15551234567"]
|
||||
},
|
||||
imessage: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["chat_id:123"]
|
||||
},
|
||||
discord: {
|
||||
groupPolicy: "allowlist",
|
||||
guilds: {
|
||||
"GUILD_ID": {
|
||||
channels: { help: { allow: true } }
|
||||
}
|
||||
}
|
||||
},
|
||||
slack: {
|
||||
groupPolicy: "allowlist",
|
||||
channels: { "#general": { allow: true } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `"open"` (default): groups bypass allowlists; mention-gating still applies.
|
||||
- `"disabled"`: block all group/room messages.
|
||||
- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
|
||||
- WhatsApp/Telegram/Signal/iMessage use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||
- Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`).
|
||||
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
|
||||
|
||||
### Multi-agent routing (`routing.agents` + `routing.bindings`)
|
||||
|
||||
Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside one Gateway. Inbound messages are routed to an agent via bindings.
|
||||
|
||||
- `routing.defaultAgentId`: fallback when no binding matches (default: `main`).
|
||||
- `routing.agents.<agentId>`: per-agent overrides.
|
||||
- `name`: display name for the agent.
|
||||
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to legacy `agent.workspace`).
|
||||
- `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
|
||||
- `model`: per-agent default model (provider/model), overrides `agent.model` for that agent.
|
||||
- `routing.bindings[]`: routes inbound messages to an `agentId`.
|
||||
- `match.provider` (required)
|
||||
- `match.accountId` (optional; `*` = any account; omitted = default account)
|
||||
- `match.peer` (optional; `{ kind: dm|group|channel, id }`)
|
||||
- `match.guildId` / `match.teamId` (optional; provider-specific)
|
||||
|
||||
Deterministic match order:
|
||||
1) `match.peer`
|
||||
2) `match.guildId`
|
||||
3) `match.teamId`
|
||||
4) `match.accountId` (exact, no peer/guild/team)
|
||||
5) `match.accountId: "*"` (provider-wide, no peer/guild/team)
|
||||
6) `routing.defaultAgentId`
|
||||
|
||||
Within each match tier, the first matching entry in `routing.bindings` wins.
|
||||
|
||||
Example: two WhatsApp accounts → two agents:
|
||||
|
||||
```json5
|
||||
{
|
||||
routing: {
|
||||
defaultAgentId: "home",
|
||||
agents: {
|
||||
home: { workspace: "~/clawd-home" },
|
||||
work: { workspace: "~/clawd-work" },
|
||||
},
|
||||
bindings: [
|
||||
{ agentId: "home", match: { provider: "whatsapp", accountId: "personal" } },
|
||||
{ agentId: "work", match: { provider: "whatsapp", accountId: "biz" } },
|
||||
],
|
||||
},
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
personal: {},
|
||||
biz: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `routing.agentToAgent` (optional)
|
||||
|
||||
Agent-to-agent messaging is opt-in:
|
||||
|
||||
```json5
|
||||
{
|
||||
routing: {
|
||||
agentToAgent: {
|
||||
enabled: false,
|
||||
allow: ["home", "work"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `routing.queue`
|
||||
|
||||
Controls how inbound messages behave when an agent run is already active.
|
||||
@@ -401,7 +173,7 @@ Controls how inbound messages behave when an agent run is already active.
|
||||
debounceMs: 1000,
|
||||
cap: 20,
|
||||
drop: "summarize", // old | new | summarize
|
||||
byProvider: {
|
||||
bySurface: {
|
||||
whatsapp: "collect",
|
||||
telegram: "collect",
|
||||
discord: "collect",
|
||||
@@ -413,27 +185,6 @@ Controls how inbound messages behave when an agent run is already active.
|
||||
}
|
||||
```
|
||||
|
||||
### `commands` (chat command handling)
|
||||
|
||||
Controls how chat commands are enabled across connectors.
|
||||
|
||||
```json5
|
||||
{
|
||||
commands: {
|
||||
native: false, // register native commands when supported
|
||||
text: true, // parse slash commands in chat messages
|
||||
useAccessGroups: true // enforce access-group allowlists/policies for commands
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Text commands must be sent as a **standalone** message and use the leading `/` (no plain-text aliases).
|
||||
- `commands.text: false` disables parsing chat messages for commands.
|
||||
- `commands.native: true` registers native commands on supported connectors (Discord/Slack/Telegram). Platforms without native commands still rely on text commands.
|
||||
- `commands.native: false` skips native registration; Discord/Telegram clear previously registered commands on startup. Slack commands are managed in the Slack app.
|
||||
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.
|
||||
|
||||
### `web` (WhatsApp web provider)
|
||||
|
||||
WhatsApp runs through the gateway’s web provider. It starts automatically when a linked session exists.
|
||||
@@ -465,24 +216,8 @@ Set `telegram.enabled: false` to disable automatic startup.
|
||||
telegram: {
|
||||
enabled: true,
|
||||
botToken: "your-bot-token",
|
||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["tg:123456789"], // optional; "open" requires ["*"]
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"-1001234567890": {
|
||||
allowFrom: ["@admin"],
|
||||
systemPrompt: "Keep answers brief.",
|
||||
topics: {
|
||||
"99": {
|
||||
requireMention: false,
|
||||
skills: ["search"],
|
||||
systemPrompt: "Stay on topic."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
streamMode: "partial", // off | partial | block (draft streaming)
|
||||
actions: { reactions: true }, // tool action gates (false disables)
|
||||
requireMention: true,
|
||||
allowFrom: ["123456789"],
|
||||
mediaMaxMb: 5,
|
||||
proxy: "socks5://localhost:9050",
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
@@ -492,11 +227,6 @@ Set `telegram.enabled: false` to disable automatic startup.
|
||||
}
|
||||
```
|
||||
|
||||
Draft streaming notes:
|
||||
- Uses Telegram `sendMessageDraft` (draft bubble, not a real message).
|
||||
- Requires **private chat topics** (message_thread_id in DMs; bot has topics enabled).
|
||||
- `/reasoning stream` streams reasoning into the draft, then sends the final answer.
|
||||
|
||||
### `discord` (bot transport)
|
||||
|
||||
Configure the Discord bot by setting the bot token and optional gating:
|
||||
@@ -525,10 +255,15 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
moderation: false
|
||||
},
|
||||
replyToMode: "off", // off | first | all
|
||||
slashCommand: { // user-installed app slash commands
|
||||
enabled: true,
|
||||
name: "clawd",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeral: true
|
||||
},
|
||||
dm: {
|
||||
enabled: true, // disable all DMs when false
|
||||
policy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["1234567890", "steipete"], // optional DM allowlist ("open" requires ["*"])
|
||||
allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names)
|
||||
groupEnabled: false, // enable group DMs
|
||||
groupChannels: ["clawd-dm"] // optional group DM allowlist
|
||||
},
|
||||
@@ -540,13 +275,7 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
users: ["987654321098765432"], // optional per-guild user allowlist
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
help: {
|
||||
allow: true,
|
||||
requireMention: true,
|
||||
users: ["987654321098765432"],
|
||||
skills: ["docs"],
|
||||
systemPrompt: "Short answers only."
|
||||
}
|
||||
help: { allow: true, requireMention: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -575,20 +304,13 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
appToken: "xapp-...",
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["U123", "U456", "*"], // optional; "open" requires ["*"]
|
||||
allowFrom: ["U123", "U456", "*"],
|
||||
groupEnabled: false,
|
||||
groupChannels: ["G123"]
|
||||
},
|
||||
channels: {
|
||||
C123: { allow: true, requireMention: true },
|
||||
"#general": {
|
||||
allow: true,
|
||||
requireMention: true,
|
||||
users: ["U123"],
|
||||
skills: ["docs"],
|
||||
systemPrompt: "Short answers only."
|
||||
}
|
||||
"#general": { allow: true, requireMention: false }
|
||||
},
|
||||
reactionNotifications: "own", // off | own | all | allowlist
|
||||
reactionAllowlist: ["U123"],
|
||||
@@ -637,7 +359,6 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
enabled: true,
|
||||
cliPath: "imsg",
|
||||
dbPath: "~/Library/Messages/chat.db",
|
||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
|
||||
includeAttachments: false,
|
||||
mediaMaxMb: 16,
|
||||
@@ -665,59 +386,22 @@ Default: `~/clawd`.
|
||||
```
|
||||
|
||||
If `agent.sandbox` is enabled, non-main sessions can override this with their
|
||||
own per-scope workspaces under `agent.sandbox.workspaceRoot`.
|
||||
|
||||
### `agent.skipBootstrap`
|
||||
|
||||
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
|
||||
|
||||
Use this for pre-seeded deployments where your workspace files come from a repo.
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { skipBootstrap: true }
|
||||
}
|
||||
```
|
||||
|
||||
### `agent.userTimezone`
|
||||
|
||||
Sets the user’s timezone for **system prompt context** (not for timestamps in
|
||||
message envelopes). If unset, Clawdbot uses the host timezone at runtime.
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { userTimezone: "America/Chicago" }
|
||||
}
|
||||
```
|
||||
own per-session workspaces under `agent.sandbox.workspaceRoot`.
|
||||
|
||||
### `messages`
|
||||
|
||||
Controls inbound/outbound prefixes and optional ack reactions.
|
||||
Controls inbound/outbound prefixes and timestamps.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
messagePrefix: "[clawdbot]",
|
||||
responsePrefix: "🦞",
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "group-mentions"
|
||||
timestampPrefix: "Europe/London"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
|
||||
streaming, final replies) across providers unless already present.
|
||||
|
||||
`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages
|
||||
on providers that support reactions (Slack/Discord/Telegram). Defaults to the
|
||||
configured `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable.
|
||||
|
||||
`ackReactionScope` controls when reactions fire:
|
||||
- `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned
|
||||
- `group-all`: all group/room messages
|
||||
- `direct`: direct messages only
|
||||
- `all`: all messages
|
||||
|
||||
### `talk`
|
||||
|
||||
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.
|
||||
@@ -743,42 +427,21 @@ Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_V
|
||||
### `agent`
|
||||
|
||||
Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
`agent.models` defines the configured model catalog (and acts as the allowlist for `/model`).
|
||||
`agent.model.primary` sets the default model; `agent.model.fallbacks` are global failovers.
|
||||
`agent.imageModel` is optional and is **only used if the primary model lacks image input**.
|
||||
|
||||
Clawdbot also ships a few built-in alias shorthands. Defaults only apply when the model
|
||||
is already present in `agent.models`:
|
||||
|
||||
- `opus` -> `anthropic/claude-opus-4-5`
|
||||
- `sonnet` -> `anthropic/claude-sonnet-4-5`
|
||||
- `gpt` -> `openai/gpt-5.2`
|
||||
- `gpt-mini` -> `openai/gpt-5-mini`
|
||||
- `gemini` -> `google/gemini-3-pro-preview`
|
||||
- `gemini-flash` -> `google/gemini-3-flash-preview`
|
||||
|
||||
If you configure the same alias name (case-insensitive) yourself, your value wins (defaults never override).
|
||||
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist
|
||||
(omit to show the full catalog).
|
||||
`modelAliases` adds short names for `/model` (alias -> provider/model).
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||
"openrouter/deepseek/deepseek-r1:free": {}
|
||||
},
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-5",
|
||||
fallbacks: [
|
||||
"openrouter/deepseek/deepseek-r1:free",
|
||||
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
|
||||
]
|
||||
},
|
||||
imageModel: {
|
||||
primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
|
||||
fallbacks: [
|
||||
"openrouter/google/gemini-2.0-flash-vision:free"
|
||||
]
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
allowedModels: [
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-1"
|
||||
],
|
||||
modelAliases: {
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
Sonnet: "anthropic/claude-sonnet-4-1"
|
||||
},
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
@@ -790,10 +453,6 @@ If you configure the same alias name (case-insensitive) yourself, your value win
|
||||
target: "last"
|
||||
},
|
||||
maxConcurrent: 3,
|
||||
subagents: {
|
||||
maxConcurrent: 1,
|
||||
archiveAfterMinutes: 60
|
||||
},
|
||||
bash: {
|
||||
backgroundMs: 10000,
|
||||
timeoutSec: 1800,
|
||||
@@ -818,52 +477,29 @@ Block streaming:
|
||||
}
|
||||
```
|
||||
|
||||
`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
|
||||
Aliases come from `agent.models.*.alias` (e.g. `Opus`).
|
||||
`agent.model` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
|
||||
If `modelAliases` is configured, you may also use the alias key (e.g. `Opus`).
|
||||
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
|
||||
deprecation fallback.
|
||||
Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
`ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment.
|
||||
|
||||
`agent.heartbeat` configures periodic heartbeat runs:
|
||||
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default:
|
||||
`30m`. Set `0m` to disable.
|
||||
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
|
||||
`0m` to disable.
|
||||
- `model`: optional override model for heartbeat runs (`provider/model`).
|
||||
- `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
||||
- `to`: optional recipient override (provider-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
|
||||
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
|
||||
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `imessage`, `none`). Default: `last`.
|
||||
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
|
||||
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
|
||||
|
||||
`agent.bash` configures background bash defaults:
|
||||
- `backgroundMs`: time before auto-background (ms, default 10000)
|
||||
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
|
||||
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
|
||||
|
||||
`agent.subagents` configures sub-agent defaults:
|
||||
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
||||
- `tools.allow` / `tools.deny`: per-subagent tool allow/deny policy (deny wins)
|
||||
|
||||
`agent.tools` configures a global tool allow/deny policy (deny wins).
|
||||
This is applied even when the Docker sandbox is **off**.
|
||||
|
||||
Example (disable browser/canvas everywhere):
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
tools: {
|
||||
deny: ["browser", "canvas"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`agent.elevated` controls elevated (host) bash access:
|
||||
- `enabled`: allow elevated mode (default true)
|
||||
- `allowFrom`: per-provider allowlists (empty = disabled)
|
||||
- `allowFrom`: per-surface allowlists (empty = disabled)
|
||||
- `whatsapp`: E.164 numbers
|
||||
- `telegram`: chat ids or usernames
|
||||
- `discord`: user ids or usernames (falls back to `discord.dm.allowFrom` if omitted)
|
||||
@@ -892,34 +528,24 @@ per session key at a time). Default: 1.
|
||||
|
||||
### `agent.sandbox`
|
||||
|
||||
Optional **Docker sandboxing** for the embedded agent. Intended for non-main
|
||||
sessions so they cannot access your host system.
|
||||
Optional per-session **Docker sandboxing** for the embedded agent. Intended for
|
||||
non-main sessions so they cannot access your host system.
|
||||
|
||||
Defaults (if enabled):
|
||||
- scope: `"agent"` (one container + workspace per agent)
|
||||
- one container per session
|
||||
- Debian bookworm-slim based image
|
||||
- agent workspace access: `workspaceAccess: "none"` (default)
|
||||
- `"none"`: use a per-scope sandbox workspace under `~/.clawdbot/sandboxes`
|
||||
- `"ro"`: keep the sandbox workspace at `/workspace`, and mount the agent workspace read-only at `/agent` (disables `write`/`edit`)
|
||||
- `"rw"`: mount the agent workspace read/write at `/workspace`
|
||||
- workspace per session under `~/.clawdbot/sandboxes`
|
||||
- auto-prune: idle > 24h OR age > 7d
|
||||
- tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins)
|
||||
- tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins)
|
||||
- optional sandboxed browser (Chromium + CDP, noVNC observer)
|
||||
- hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`
|
||||
|
||||
Warning: `scope: "shared"` means a shared container and shared workspace. No
|
||||
cross-session isolation. Use `scope: "session"` for per-session isolation.
|
||||
|
||||
Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
||||
`false` → `scope: "shared"`).
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
sandbox: {
|
||||
mode: "non-main", // off | non-main | all
|
||||
scope: "agent", // session | agent | shared (agent is default)
|
||||
workspaceAccess: "none", // none | ro | rw
|
||||
perSession: true,
|
||||
workspaceRoot: "~/.clawdbot/sandboxes",
|
||||
docker: {
|
||||
image: "clawdbot-sandbox:bookworm-slim",
|
||||
@@ -956,7 +582,7 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
||||
enableNoVnc: true
|
||||
},
|
||||
tools: {
|
||||
allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
|
||||
allow: ["bash", "process", "read", "write", "edit"],
|
||||
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
|
||||
},
|
||||
prune: {
|
||||
@@ -976,8 +602,6 @@ scripts/sandbox-setup.sh
|
||||
Note: sandbox containers default to `network: "none"`; set `agent.sandbox.docker.network`
|
||||
to `"bridge"` (or your custom network) if the agent needs outbound access.
|
||||
|
||||
Note: inbound attachments are staged into the active workspace at `media/inbound/*`. With `workspaceAccess: "rw"`, that means files are written into the agent workspace.
|
||||
|
||||
Build the optional browser image with:
|
||||
```bash
|
||||
scripts/sandbox-browser-setup.sh
|
||||
@@ -993,24 +617,19 @@ URL is injected per session.
|
||||
|
||||
Clawdbot uses the **pi-coding-agent** model catalog. You can add custom providers
|
||||
(LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.) by writing
|
||||
`~/.clawdbot/agents/<agentId>/agent/models.json` or by defining the same schema inside your
|
||||
`~/.clawdbot/agent/models.json` or by defining the same schema inside your
|
||||
Clawdbot config under `models.providers`.
|
||||
|
||||
When `models.providers` is present, Clawdbot writes/merges a `models.json` into
|
||||
`~/.clawdbot/agents/<agentId>/agent/` on startup:
|
||||
`~/.clawdbot/agent/` on startup:
|
||||
- default behavior: **merge** (keeps existing providers, overrides on name)
|
||||
- set `models.mode: "replace"` to overwrite the file contents
|
||||
|
||||
Select the model via `agent.model.primary` (provider/model).
|
||||
Select the model via `agent.model` (provider/model).
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "custom-proxy/llama-3.1-8b" },
|
||||
models: {
|
||||
"custom-proxy/llama-3.1-8b": {}
|
||||
}
|
||||
},
|
||||
agent: { model: "custom-proxy/llama-3.1-8b" },
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
@@ -1035,31 +654,6 @@ Select the model via `agent.model.primary` (provider/model).
|
||||
}
|
||||
```
|
||||
|
||||
### Z.AI (GLM-4.7) — provider alias support
|
||||
|
||||
Z.AI models are available via the built-in `zai` provider. Set `ZAI_API_KEY`
|
||||
in your environment and reference the model by provider/model.
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "zai/glm-4.7",
|
||||
allowedModels: ["zai/glm-4.7"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `z.ai/*` and `z-ai/*` are accepted aliases and normalize to `zai/*`.
|
||||
- If `ZAI_API_KEY` is missing, requests to `zai/*` will fail with an auth error at runtime.
|
||||
- Example error: `No API key found for provider "zai".`
|
||||
- Z.AI’s general API endpoint is `https://api.z.ai/api/paas/v4`. The GLM Coding
|
||||
Plan uses the dedicated Coding endpoint `https://api.z.ai/api/coding/paas/v4`.
|
||||
The built-in `zai` provider uses the Coding endpoint. If you need the general
|
||||
endpoint, define a custom provider in `models.providers` with the base URL
|
||||
override (see the custom providers section above).
|
||||
- Use a fake placeholder in docs/configs; never commit real API keys.
|
||||
|
||||
### Local models (LM Studio) — recommended setup
|
||||
|
||||
Best current local setup (what we’re running): **MiniMax M2.1** on a beefy Mac Studio
|
||||
@@ -1068,10 +662,14 @@ via **LM Studio** using the **Responses API**.
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "Minimax" }
|
||||
model: "Minimax",
|
||||
allowedModels: [
|
||||
"anthropic/claude-opus-4-5",
|
||||
"lmstudio/minimax-m2.1-gs32"
|
||||
],
|
||||
modelAliases: {
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
Minimax: "lmstudio/minimax-m2.1-gs32"
|
||||
}
|
||||
},
|
||||
models: {
|
||||
@@ -1108,7 +706,7 @@ Notes:
|
||||
`google-generative-ai`
|
||||
- Use `authHeader: true` + `headers` for custom auth needs.
|
||||
- Override the agent config root with `CLAWDBOT_AGENT_DIR` (or `PI_CODING_AGENT_DIR`)
|
||||
if you want `models.json` stored elsewhere (default: `~/.clawdbot/agents/main/agent`).
|
||||
if you want `models.json` stored elsewhere.
|
||||
|
||||
### `session`
|
||||
|
||||
@@ -1120,18 +718,15 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
||||
scope: "per-sender",
|
||||
idleMinutes: 60,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||
// You can override with {agentId} templating:
|
||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||
// Direct chats collapse to agent:<agentId>:<mainKey> (default: "main").
|
||||
mainKey: "main",
|
||||
store: "~/.clawdbot/sessions/sessions.json",
|
||||
// mainKey is ignored; primary key is fixed to "main"
|
||||
agentToAgent: {
|
||||
// Max ping-pong reply turns between requester/target (0–5).
|
||||
maxPingPongTurns: 5
|
||||
},
|
||||
sendPolicy: {
|
||||
rules: [
|
||||
{ action: "deny", match: { provider: "discord", chatType: "group" } }
|
||||
{ action: "deny", match: { surface: "discord", chatType: "group" } }
|
||||
],
|
||||
default: "allow"
|
||||
}
|
||||
@@ -1140,10 +735,9 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`.
|
||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||
- `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||
- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||
|
||||
### `skills` (skills config)
|
||||
|
||||
@@ -1273,12 +867,6 @@ Control UI base path:
|
||||
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
||||
- Default: root (`/`) (unchanged).
|
||||
|
||||
Related docs:
|
||||
- [Control UI](/web/control-ui)
|
||||
- [Web overview](/web)
|
||||
- [Tailscale](/gateway/tailscale)
|
||||
- [Remote access](/gateway/remote)
|
||||
|
||||
Notes:
|
||||
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||
@@ -1363,16 +951,10 @@ Requires full Gateway restart:
|
||||
|
||||
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
|
||||
- `CLAWDBOT_CONFIG_PATH` (per-instance config)
|
||||
- `CLAWDBOT_STATE_DIR` (sessions/creds)
|
||||
- `CLAWDBOT_STATE_DIR` (sessions/creds/logs)
|
||||
- `agent.workspace` (memories)
|
||||
- `gateway.port` (unique per instance)
|
||||
|
||||
Convenience flags (CLI):
|
||||
- `clawdbot --dev …` → uses `~/.clawdbot-dev` + shifts ports from base `19001`
|
||||
- `clawdbot --profile <name> …` → uses `~/.clawdbot-<name>` (port via config/env/flags)
|
||||
|
||||
See [`docs/gateway.md`](/gateway) for the derived port mapping (gateway/bridge/browser/canvas).
|
||||
|
||||
Example:
|
||||
```bash
|
||||
CLAWDBOT_CONFIG_PATH=~/.clawdbot/a.json \
|
||||
@@ -1382,7 +964,7 @@ clawdbot gateway --port 19001
|
||||
|
||||
### `hooks` (Gateway webhooks)
|
||||
|
||||
Enable a simple HTTP webhook endpoint on the Gateway HTTP server.
|
||||
Enable a simple HTTP webhook surface on the Gateway HTTP server.
|
||||
|
||||
Defaults:
|
||||
- enabled: `false`
|
||||
@@ -1419,7 +1001,7 @@ Requests must include the hook token:
|
||||
|
||||
Endpoints:
|
||||
- `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
|
||||
- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, provider?, to?, thinking?, timeoutSeconds? }`
|
||||
- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, thinking?, timeoutSeconds? }`
|
||||
- `POST /hooks/<name>` → resolved via `hooks.mappings`
|
||||
|
||||
`/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`).
|
||||
@@ -1549,7 +1131,7 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any
|
||||
|----------|-------------|
|
||||
| `{{Body}}` | Full inbound message body |
|
||||
| `{{BodyStripped}}` | Body with group mentions stripped (best default for agents) |
|
||||
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per provider) |
|
||||
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per surface) |
|
||||
| `{{To}}` | Destination identifier |
|
||||
| `{{MessageSid}}` | Provider message id (when available) |
|
||||
| `{{SessionId}}` | Current session UUID |
|
||||
@@ -1563,11 +1145,11 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any
|
||||
| `{{GroupMembers}}` | Group members preview (best effort) |
|
||||
| `{{SenderName}}` | Sender display name (best effort) |
|
||||
| `{{SenderE164}}` | Sender phone number (best effort) |
|
||||
| `{{Provider}}` | Provider hint (whatsapp|telegram|discord|imessage|webchat|…) |
|
||||
| `{{Surface}}` | Surface hint (whatsapp|telegram|discord|imessage|webchat|…) |
|
||||
|
||||
## Cron (Gateway scheduler)
|
||||
|
||||
Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron jobs](/automation/cron-jobs) for the feature overview and CLI examples.
|
||||
Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wakeups](./cron.md) for the full RFC and CLI examples.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -1580,4 +1162,5 @@ Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron jobs
|
||||
|
||||
---
|
||||
|
||||
*Next: [Agent Runtime](/concepts/agent)* 🦞
|
||||
*Next: [Agent Runtime](./agent.md)* 🦞
|
||||
<!-- {% endraw %} -->
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
summary: "Deprecated newline-delimited control channel API (pre-gateway)"
|
||||
read_when:
|
||||
- Maintaining legacy control channel support
|
||||
---
|
||||
# Control channel API (newline-delimited JSON)
|
||||
|
||||
**Deprecated (historical):** superseded by the WebSocket Gateway protocol (`clawdbot gateway`, see `docs/architecture.md` and `docs/gateway.md`).
|
||||
Current builds use a WebSocket server on `ws://127.0.0.1:18789` and do **not** expose this TCP control channel.
|
||||
|
||||
Legacy endpoint (if present in an older build): `127.0.0.1:18789` (TCP, localhost only), typically reached via SSH port forward in remote mode.
|
||||
|
||||
## Frame format
|
||||
Each line is a JSON object. Two shapes exist:
|
||||
- **Request**: `{ "type": "request", "id": "<uuid>", "method": "health" | "status" | "last-heartbeat" | "set-heartbeats" | "ping", "params"?: { ... } }`
|
||||
- **Response**: `{ "type": "response", "id": "<same id>", "ok": true, "payload"?: { ... } }` or `{ "type": "response", "id": "<same id>", "ok": false, "error": "message" }`
|
||||
- **Event**: `{ "type": "event", "event": "heartbeat" | "gateway-status" | "log", "payload": { ... } }`
|
||||
|
||||
## Methods
|
||||
- `ping`: sanity check. Payload: `{ pong: true, ts }`.
|
||||
- `health`: returns the gateway health snapshot (same shape as `clawdbot health --json`).
|
||||
- `status`: shorter summary (linked/authAge/heartbeatSeconds, session counts).
|
||||
- `last-heartbeat`: returns the most recent heartbeat event the gateway has seen.
|
||||
- `set-heartbeats { enabled: boolean }`: toggle heartbeat scheduling.
|
||||
|
||||
## Events
|
||||
- `heartbeat` payload:
|
||||
```json
|
||||
{
|
||||
"ts": 1765224052664,
|
||||
"status": "sent" | "ok-empty" | "ok-token" | "skipped" | "failed",
|
||||
"to": "+15551234567",
|
||||
"preview": "Heartbeat OK",
|
||||
"hasMedia": false,
|
||||
"durationMs": 1025,
|
||||
"reason": "<error text>" // only on failed/skipped
|
||||
}
|
||||
```
|
||||
- `gateway-status` payload: `{ "state": "starting" | "running" | "restarting" | "failed" | "stopped", "pid"?: number, "reason"?: string }`
|
||||
- `log` payload: arbitrary log line; optional, can be disabled.
|
||||
|
||||
## Suggested client flow
|
||||
1) Connect (or reconnect) → send `ping`.
|
||||
2) Send `health` and `last-heartbeat` to populate UI.
|
||||
3) Listen for `event` frames; update UI in real time.
|
||||
4) For user toggles, send `set-heartbeats` and await response.
|
||||
|
||||
## Backward compatibility
|
||||
- If the control channel is unavailable: that’s expected on modern builds. Use the Gateway WS protocol instead.
|
||||
@@ -13,14 +13,6 @@ The Control UI is a small **Vite + Lit** single-page app served by the Gateway:
|
||||
|
||||
It speaks **directly to the Gateway WebSocket** on the same port.
|
||||
|
||||
## Quick open (local)
|
||||
|
||||
If the Gateway is running on the same computer, open:
|
||||
|
||||
- http://127.0.0.1:18789/ (or http://localhost:18789/)
|
||||
|
||||
If the page fails to load, start the Gateway first: `clawdbot gateway`.
|
||||
|
||||
Auth is supplied during the WebSocket handshake via:
|
||||
- `connect.params.auth.token`
|
||||
- `connect.params.auth.password`
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
---
|
||||
summary: "RFC: Cron jobs + wakeups for Clawd/Clawdbot (main vs isolated sessions)"
|
||||
read_when:
|
||||
- Designing scheduled jobs, alarms, or wakeups
|
||||
- Adding Gateway methods or CLI commands for automation
|
||||
- Adjusting heartbeat behavior or session routing
|
||||
---
|
||||
|
||||
# RFC: Cron jobs + wakeups for Clawd
|
||||
|
||||
Status: Draft
|
||||
Last updated: 2025-12-13
|
||||
|
||||
## Context
|
||||
|
||||
Clawdbot already has:
|
||||
- A **gateway heartbeat runner** that runs the agent with `HEARTBEAT` and suppresses `HEARTBEAT_OK` (`src/infra/heartbeat-runner.ts`).
|
||||
- A lightweight, in-memory **system event queue** (`enqueueSystemEvent`) that is injected into the next **main session** turn (`drainSystemEvents` in `src/auto-reply/reply.ts`).
|
||||
- A WebSocket **Gateway** daemon that is intended to be always-on (`docs/gateway.md`).
|
||||
|
||||
This RFC adds a small “cron job system” so Clawd can schedule future work and reliably wake itself up:
|
||||
- **Delayed**: run on the *next* normal heartbeat tick
|
||||
- **Immediate**: run *now* (trigger a heartbeat immediately)
|
||||
- **Isolated jobs**: optionally run in their own session that does not pollute the main session and can run concurrently (within configured limits).
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide a **persistent job store** and an **in-process scheduler** owned by the Gateway.
|
||||
- Allow each job to target either:
|
||||
- `sessionTarget: "main"`: inject as `System:` lines and rely on the main heartbeat (or trigger it immediately).
|
||||
- `sessionTarget: "isolated"`: run an agent turn in a dedicated session key (job session), optionally delivering a message and/or posting a summary back to main.
|
||||
- Expose a stable control surface:
|
||||
- **Gateway methods** (`cron.*`, `wake`) for programmatic usage (mac app, CLI, agents).
|
||||
- **CLI commands** (`clawdbot cron ...`) to add/remove/edit/list and to debug `run`.
|
||||
- Produce clear, structured **logs** for job lifecycle and execution outcomes.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Multi-host distributed scheduling.
|
||||
- Exactly-once semantics across crashes (we aim for “at-least-once with idempotency hooks”).
|
||||
- A full Unix-cron parser as the only schedule format (we can support it, but v1 should not require complex cron features to be useful).
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Wake**: a request to ensure the agent gets a turn soon (either right now or next heartbeat).
|
||||
- **Main session**: the canonical session bucket (default key `"main"`) that receives `System:` events.
|
||||
- **Isolated session**: a per-job session key (e.g. `cron:<jobId>`) with its own session id / session file.
|
||||
|
||||
## User stories
|
||||
|
||||
- “Remind me in 20 minutes” → add a one-shot job that triggers an immediate heartbeat at T+20m.
|
||||
- “Every weekday at 7:30, wake me up and start music” → recurring job, isolated session, deliver to WhatsApp.
|
||||
- “Every hour, check battery; only interrupt me if < 20%” → isolated job that decides whether to deliver; may also post a brief status to main.
|
||||
- “Next heartbeat, please check calendar” → delayed wake targeting main session.
|
||||
|
||||
## Job model
|
||||
|
||||
### Storage schema (v1)
|
||||
|
||||
Each job is a JSON object with stable keys (unknown keys ignored for forward compatibility):
|
||||
|
||||
- `id: string` (UUID)
|
||||
- `name: string` (required)
|
||||
- `description?: string` (optional)
|
||||
- `enabled: boolean`
|
||||
- `createdAtMs: number`
|
||||
- `updatedAtMs: number`
|
||||
- `schedule` (one of)
|
||||
- `{"kind":"at","atMs":number}` (one-shot)
|
||||
- `{"kind":"every","everyMs":number,"anchorMs"?:number}` (simple interval)
|
||||
- `{"kind":"cron","expr":string,"tz"?:string}` (optional; see “Schedule parsing”)
|
||||
- `sessionTarget: "main" | "isolated"`
|
||||
- `wakeMode: "next-heartbeat" | "now"`
|
||||
- For `sessionTarget:"isolated"`, `wakeMode:"now"` means “run immediately when due”.
|
||||
- For `sessionTarget:"main"`, `wakeMode` controls whether we trigger the heartbeat immediately or just enqueue and wait.
|
||||
- `payload` (one of)
|
||||
- `{"kind":"systemEvent","text":string}` (enqueue as `System:`)
|
||||
- `{"kind":"agentTurn","message":string,"deliver"?:boolean,"channel"?: "last"|"whatsapp"|"telegram"|"discord"|"signal"|"imessage","to"?:string,"timeoutSeconds"?:number}`
|
||||
- `isolation` (optional; only meaningful for isolated jobs)
|
||||
- `{"postToMainPrefix"?: string}`
|
||||
- `runtime` (optional)
|
||||
- `{"maxAttempts"?:number,"retryBackoffMs"?:number}` (best-effort retries; defaults off)
|
||||
- `state` (runtime-maintained)
|
||||
- `{"nextRunAtMs":number,"lastRunAtMs"?:number,"lastStatus"?: "ok"|"error"|"skipped","lastError"?:string,"lastDurationMs"?:number}`
|
||||
|
||||
### Key behavior
|
||||
|
||||
- `sessionTarget:"main"` jobs always enqueue `payload.kind:"systemEvent"` (directly or derived from `agentTurn` results; see below).
|
||||
- `sessionTarget:"isolated"` jobs create/use a stable session key: `cron:<jobId>`.
|
||||
|
||||
## Storage location
|
||||
|
||||
Cron persists everything under `~/.clawdbot/cron/`:
|
||||
- Job store: `~/.clawdbot/cron/jobs.json`
|
||||
- Run history: `~/.clawdbot/cron/runs/<jobId>.jsonl`
|
||||
|
||||
You can override the job store path via `cron.store` in config.
|
||||
|
||||
The scheduler should never require additional configuration for the base directory (Clawdbot already treats `~/.clawdbot` as fixed).
|
||||
|
||||
## Enabling
|
||||
|
||||
Cron execution is enabled by default inside the Gateway.
|
||||
|
||||
To disable it, set:
|
||||
|
||||
```json5
|
||||
{
|
||||
cron: {
|
||||
enabled: false,
|
||||
// optional:
|
||||
store: "~/.clawdbot/cron/jobs.json",
|
||||
maxConcurrentRuns: 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also disable scheduling via the environment variable `CLAWDBOT_SKIP_CRON=1`.
|
||||
|
||||
## Scheduler design
|
||||
|
||||
### Ownership
|
||||
|
||||
The Gateway owns:
|
||||
- the scheduler timer,
|
||||
- job store reads/writes,
|
||||
- job execution (enqueue system events and/or agent turns).
|
||||
|
||||
This keeps scheduling unified with the always-on process and prevents “two schedulers” when multiple CLIs run.
|
||||
|
||||
### Timer strategy
|
||||
|
||||
- Maintain an in-memory heap/array of enabled jobs keyed by `state.nextRunAtMs`.
|
||||
- Use a **single `setTimeout`** to wake at the earliest next run.
|
||||
- On wake:
|
||||
- compute all due jobs (now >= nextRunAtMs),
|
||||
- mark them “in flight” (in memory),
|
||||
- persist updated `state` (at least bump `nextRunAtMs` / `lastRunAtMs`) before starting execution to minimize duplicate runs on crash,
|
||||
- execute jobs (with concurrency limits),
|
||||
- persist final `lastStatus/lastError/lastDurationMs`,
|
||||
- re-arm timer for the next earliest run.
|
||||
|
||||
### Schedule parsing
|
||||
|
||||
V1 can ship with `at` + `every` without extra deps.
|
||||
|
||||
If we add `"kind":"cron"`:
|
||||
- Use a well-maintained parser (we use `croner`) and support:
|
||||
- 5-field cron (`min hour dom mon dow`) at minimum
|
||||
- optional `tz`
|
||||
- Store `nextRunAtMs` computed by the parser; re-compute after each run.
|
||||
|
||||
## Execution semantics
|
||||
|
||||
### Main session jobs
|
||||
|
||||
Main session jobs do not run the agent directly by default.
|
||||
|
||||
When due:
|
||||
1) `enqueueSystemEvent(job.payload.text)` (or a derived message)
|
||||
2) If `wakeMode:"now"`, trigger an immediate heartbeat run (see “Heartbeat wake hook”).
|
||||
3) Otherwise do nothing else (the next scheduled heartbeat will pick up the system event).
|
||||
|
||||
Why: This keeps the main session’s “proactive” behavior centralized in the heartbeat rules and avoids ad-hoc agent turns that might fight with inbound message processing.
|
||||
|
||||
### Isolated session jobs
|
||||
|
||||
Isolated jobs run an agent turn in a dedicated session key, intended to be separate from main.
|
||||
|
||||
When due:
|
||||
- Build a message body that includes schedule metadata, e.g.:
|
||||
- `"[cron:<jobId>] <job.name>: <payload.message>"`
|
||||
- Execute via the same agent runner path as other command-mode runs, but pinned to:
|
||||
- `sessionKey = cron:<jobId>`
|
||||
- `sessionId = store[sessionKey].sessionId` (create if missing)
|
||||
- Optionally deliver output (`payload.deliver === true`) to the configured channel/to.
|
||||
- Isolated jobs always enqueue a summary system event to the main session when they finish (derived from the last agent text output).
|
||||
- Prefix defaults to `Cron`, and can be customized via `isolation.postToMainPrefix`.
|
||||
- If `deliver` is omitted/false, nothing is sent to external providers; you still get the main-session summary and can inspect the full isolated transcript in `cron:<jobId>`.
|
||||
|
||||
### “Run in parallel to main”
|
||||
|
||||
Clawdbot currently serializes command execution through a global in-process queue (`src/process/command-queue.ts`) to avoid collisions.
|
||||
|
||||
To support isolated cron jobs running “in parallel”, we should introduce **lanes** (keyed queues) plus a global concurrency cap:
|
||||
- Lane `"main"`: inbound auto-replies + main heartbeat.
|
||||
- Lane `"cron"` (or `cron:<jobId>`): isolated jobs.
|
||||
- Configurable `cron.maxConcurrentRuns` (default 1 or 2).
|
||||
|
||||
This yields:
|
||||
- isolated jobs can overlap with the main lane (up to cap),
|
||||
- each lane still preserves ordering for its own work (optional),
|
||||
- we retain safety knobs to prevent runaway resource contention.
|
||||
|
||||
## Heartbeat wake hook (immediate vs next heartbeat)
|
||||
|
||||
We need a way for the Gateway (or the scheduler) to request an immediate heartbeat without duplicating heartbeat logic.
|
||||
|
||||
Design:
|
||||
- `startHeartbeatRunner` owns the real heartbeat execution and installs a wake handler.
|
||||
- Wake hook lives in `src/infra/heartbeat-wake.ts`:
|
||||
- `setHeartbeatWakeHandler(fn | null)` installed by the heartbeat runner
|
||||
- `requestHeartbeatNow({ reason, coalesceMs? })`
|
||||
- If the handler is absent, the request is stored as “pending”; the next time the handler is installed, it runs once.
|
||||
- Coalesce rapid calls and respect the “skip when queue busy” behavior (retry soon vs dropping).
|
||||
|
||||
## Run history log (JSONL)
|
||||
|
||||
In addition to normal structured logs, the Gateway writes an append-only run history “ledger” (JSONL) whenever a job finishes. This is intended for quick debugging (“did the job run, when, and what happened?”).
|
||||
|
||||
Path rules:
|
||||
- Run logs are stored per job next to the store: `.../runs/<jobId>.jsonl`.
|
||||
|
||||
Retention:
|
||||
- Best-effort pruning when the file grows beyond ~2MB; keep the newest ~2000 lines.
|
||||
|
||||
Each log line includes (at minimum) job id, status/error, timing, and a `summary` string (systemEvent text for main jobs, and the last agent text output for isolated jobs).
|
||||
|
||||
## Gateway API
|
||||
|
||||
New methods (names can be bikeshed; `cron.*` is suggested):
|
||||
|
||||
- `wake`
|
||||
- params: `{ mode: "now" | "next-heartbeat", text: string }`
|
||||
- effect: `enqueueSystemEvent(text)`, plus optional immediate heartbeat trigger
|
||||
|
||||
- `cron.list`
|
||||
- params: optional `{ includeDisabled?: boolean }`
|
||||
- returns: `{ jobs: CronJob[] }`
|
||||
|
||||
- `cron.add`
|
||||
- params: job payload without `id/state` (server generates and returns created job)
|
||||
|
||||
- `cron.update`
|
||||
- params: `{ id: string, patch: Partial<CronJobWritableFields> }`
|
||||
|
||||
- `cron.remove`
|
||||
- params: `{ id: string }`
|
||||
|
||||
- `cron.run`
|
||||
- params: `{ id: string, mode?: "due" | "force" }` (debugging; does not change schedule unless `force` requires it)
|
||||
|
||||
- `cron.runs`
|
||||
- params: `{ id: string, limit?: number }`
|
||||
- returns: `{ entries: CronRunLogEntry[] }`
|
||||
- note: `id` is required (runs are stored per-job).
|
||||
|
||||
The Gateway should broadcast a `cron` event for UI/debug:
|
||||
- event: `cron`
|
||||
- payload: `{ jobId, action: "added"|"updated"|"removed"|"started"|"finished", status?, error?, nextRunAtMs? }`
|
||||
|
||||
## CLI surface
|
||||
|
||||
Add a `cron` command group (all commands should also support `--json` where sensible):
|
||||
|
||||
- `clawdbot cron list [--json] [--all]`
|
||||
- `clawdbot cron add ...`
|
||||
- schedule flags:
|
||||
- `--at <iso8601|ms|relative>` (one-shot)
|
||||
- `--every <duration>` (e.g. `10m`, `1h`)
|
||||
- `--cron "<expr>" [--tz "<tz>"]`
|
||||
- target flags:
|
||||
- `--session main|isolated`
|
||||
- `--wake now|next-heartbeat`
|
||||
- payload flags (choose one):
|
||||
- `--system-event "<text>"`
|
||||
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord|signal|imessage] [--to <dest>]`
|
||||
|
||||
- `clawdbot cron edit <id> ...` (patch-by-flags, non-interactive)
|
||||
- `clawdbot cron rm <id>`
|
||||
- `clawdbot cron enable <id>` / `clawdbot cron disable <id>`
|
||||
- `clawdbot cron run <id> [--force]` (debug)
|
||||
- `clawdbot cron runs --id <id> [--limit <n>]` (run history)
|
||||
- `clawdbot cron status` (scheduler enabled + next wake)
|
||||
|
||||
Additionally:
|
||||
- `clawdbot wake --mode now|next-heartbeat --text "<text>"` as a thin wrapper around `wake` for agents to call.
|
||||
|
||||
## Examples
|
||||
|
||||
### Run once at a specific time
|
||||
|
||||
One-shot reminder that targets the main session and triggers a heartbeat immediately at the scheduled time:
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--at "2025-12-14T07:00:00-08:00" \
|
||||
--session main \
|
||||
--wake now \
|
||||
--system-event "Alarm: wake up (meeting in 30 minutes)."
|
||||
```
|
||||
|
||||
### Run daily (calendar-accurate)
|
||||
|
||||
Daily at 07:00 in a specific timezone (preferred over “every 24h” to avoid DST drift):
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--wake now \
|
||||
--message "Daily check: scan calendar + inbox; deliver only if urgent." \
|
||||
--deliver \
|
||||
--channel last
|
||||
```
|
||||
|
||||
### Run weekly (every Wednesday)
|
||||
|
||||
Every Wednesday at 09:00:
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--cron "0 9 * * 3" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--wake now \
|
||||
--message "Weekly: summarize status and remind me of goals." \
|
||||
--deliver \
|
||||
--channel last
|
||||
```
|
||||
|
||||
### “Next heartbeat”
|
||||
|
||||
Enqueue a note for the main session but let the existing heartbeat cadence pick it up:
|
||||
|
||||
```bash
|
||||
clawdbot wake --mode next-heartbeat --text "Next heartbeat: check battery + upcoming meetings."
|
||||
```
|
||||
|
||||
## Logging & observability
|
||||
|
||||
Logging requirements:
|
||||
- Use `getChildLogger({ module: "cron", jobId, runId, name })` for every run.
|
||||
- Log lifecycle:
|
||||
- store load/save (debug; include job count)
|
||||
- schedule recompute (debug; include nextRunAt)
|
||||
- job start/end (info)
|
||||
- job skipped (info; include reason)
|
||||
- job error (warn; include error + stack where available)
|
||||
- Emit a concise user-facing line to stdout when running in CLI mode (similar to heartbeat logs).
|
||||
|
||||
Suggested log events:
|
||||
- `cron: scheduler started` (jobCount, nextWakeAt)
|
||||
- `cron: job started` (jobId, scheduleKind, sessionTarget, wakeMode)
|
||||
- `cron: job finished` (status, durationMs, nextRunAtMs)
|
||||
- When `cron.enabled` is false, the Gateway logs `cron: disabled` and jobs will not run automatically (the CLI warns on `cron add`/`cron edit`).
|
||||
- Use `clawdbot cron status` to confirm the scheduler is enabled and see the next wake time.
|
||||
|
||||
## Safety & security
|
||||
|
||||
- Respect existing allowlists/routing rules: delivery defaults should not send to arbitrary destinations unless explicitly configured.
|
||||
- Provide a global “kill switch”:
|
||||
- `cron.enabled: boolean` (default `true`).
|
||||
- `gateway method set-heartbeats` already exists; cron should have similar.
|
||||
- Avoid persistence of sensitive payloads unless requested; job text may contain private content.
|
||||
|
||||
## Testing plan (v1)
|
||||
|
||||
- Unit tests:
|
||||
- schedule computation for `at` and `every`
|
||||
- job store read/write + migration behavior
|
||||
- lane concurrency: main vs cron overlap is bounded
|
||||
- “wake now” coalescing and pending behavior when provider not ready
|
||||
- Integration tests:
|
||||
- start Gateway with `CLAWDBOT_SKIP_PROVIDERS=1`, add jobs, list/edit/remove
|
||||
- simulate due jobs and assert `enqueueSystemEvent` called + cron events broadcast
|
||||
|
||||
## Rollout plan
|
||||
|
||||
1) Add the `wake` primitive + heartbeat wake hook (no persistent jobs yet).
|
||||
2) Add `cron.*` API and CLI wrappers with `at` + `every`.
|
||||
3) Add optional cron expression parsing (`kind:"cron"`) if needed.
|
||||
4) Add UI surfacing in WebChat/macOS app (optional).
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
summary: "Gateway dashboard (Control UI) access and auth"
|
||||
read_when:
|
||||
- Changing dashboard authentication or exposure modes
|
||||
---
|
||||
# Dashboard (Control UI)
|
||||
|
||||
The Gateway dashboard is the browser Control UI served at `/` by default
|
||||
(override with `gateway.controlUi.basePath`).
|
||||
|
||||
Key references:
|
||||
- `docs/control-ui.md` for usage and UI capabilities.
|
||||
- `docs/tailscale.md` for Serve/Funnel automation.
|
||||
- `docs/web.md` for bind modes and security notes.
|
||||
|
||||
Authentication is enforced at the WebSocket handshake via `connect.params.auth`
|
||||
(token or password). See `gateway.auth` in `docs/configuration.md`.
|
||||
File renamed without changes.
Loaded 100 of 673 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user