Compare commits

..
Author SHA1 Message Date
Peter Steinberger fdf49a38b4 docs: add PR number to changelog 2026-01-03 03:49:22 +01:00
Peter Steinberger 2eff100434 style: format lint fixes 2026-01-03 03:49:08 +01:00
Peter Steinberger dd1cf54c54 test: cover gmail watcher restart 2026-01-03 03:48:23 +01:00
Peter Steinberger 0f95f32642 docs: thank @jverdi in changelog 2026-01-03 03:34:54 +01:00
Peter Steinberger f4b488e8bd docs: note gmail watcher auto-start 2026-01-03 03:30:36 +01:00
Peter Steinberger efd10c49db fix: harden gmail watcher restart 2026-01-03 03:30:27 +01:00
Jared Verdi aae54b4af2 Gmail watcher: start when gateway (re)starts 2026-01-01 23:09:44 -05:00
Peter Steinberger 35582cfe8a docs: fix broken clawd.md link in index 2026-01-02 02:45:01 +00:00
Peter Steinberger 76e24653e9 fix(media): preserve GIF animation, skip JPEG optimization
- Skip JPEG optimization for image/gif content type (both local and URL)
- Preserves animation in uploaded GIFs to Discord/other providers
- Added tests for GIF preservation from local files and URLs
- Updated changelog
2026-01-02 00:56:04 +00:00
Peter Steinberger 4c2812b429 fix: refine HEARTBEAT_OK handling 2026-01-02 01:42:27 +01:00
Peter Steinberger c31070db24 style: apply biome formatting 2026-01-02 01:29:05 +01:00
Peter Steinberger 336048441c docs: add imessage rpc and groups docs 2026-01-02 01:19:40 +01:00
Peter Steinberger cbac34347b feat: add imessage rpc adapter 2026-01-02 01:19:40 +01:00
Peter Steinberger 3ee27a00c7 docs(changelog): note log prefix cleanup 2026-01-02 00:15:03 +00:00
Peter Steinberger 4ec020a86d fix(logging): trim provider log prefixes 2026-01-02 00:15:01 +00:00
Peter Steinberger 464dabdc16 docs: default discord reactions to on 2026-01-02 01:11:04 +01:00
Peter Steinberger c0976ec099 fix(gateway): stream chat events for agent runs 2026-01-02 01:04:59 +01:00
Peter Steinberger 7f3113b8d4 feat: add discord reaction tool 2026-01-02 00:29:32 +01:00
Peter Steinberger 9180cbe821 fix: keep chat scrolled to bottom on session switch 2026-01-02 00:21:48 +01:00
Peter Steinberger c5daa754ff chore: refresh a2ui bundle hash 2026-01-02 00:17:59 +01:00
Peter Steinberger 23a29216d3 fix: allow remote gateway password config 2026-01-02 00:17:54 +01:00
Peter Steinberger 8a2168ecf6 style: fix swiftlint warnings 2026-01-02 00:17:49 +01:00
Peter Steinberger 38d8a669b4 fix: add discord mention context history 2026-01-01 23:58:35 +01:00
Peter Steinberger 06e379a239 fix: suppress stray HEARTBEAT_OK replies 2026-01-01 23:53:29 +01:00
Peter Steinberger 7c0379ce05 feat: add recent session switchers 2026-01-01 23:50:26 +01:00
Peter Steinberger c7c13f2d5e fix(gateway): read CLAWDIS_GATEWAY_PASSWORD from env
The CLI client (callGateway) now reads password from:
1. opts.password (explicit parameter)
2. CLAWDIS_GATEWAY_PASSWORD env var (NEW)
3. remote.password from config

This allows CLI commands like doctor/health to authenticate
without needing a --password flag when the env var is set.

Fixes auth issues for users with password-protected gateways.
2026-01-01 22:40:36 +00:00
Peter Steinberger 6df9b3f38c docs: update changelog 2026-01-01 23:30:02 +01:00
Peter Steinberger ca81d94b90 feat(cli): hint gateway reachability for local/remote 2026-01-01 23:30:02 +01:00
Peter Steinberger a39ef7181d feat(cli): add provider setup primers 2026-01-01 23:22:52 +01:00
Peter Steinberger 93b7e3431b docs: update changelog 2026-01-01 23:22:52 +01:00
Peter Steinberger dd02cc0747 docs: update changelog 2026-01-01 23:19:30 +01:00
Peter Steinberger 867883453e fix(cli): allow skipping skill dependency installs 2026-01-01 23:19:26 +01:00
Peter Steinberger a68784c319 docs: update changelog 2026-01-01 23:16:42 +01:00
Peter Steinberger 46c763410f fix(cli): colorize provider status note 2026-01-01 23:16:36 +01:00
Peter Steinberger 815d4572f6 feat(cli): explain Tailscale exposure options 2026-01-01 23:16:28 +01:00
Peter Steinberger 279a191b86 fix(macos): colorize provider status subtitles 2026-01-01 23:16:22 +01:00
Peter Steinberger f0da42917b feat(macos): verify Claude OAuth in onboarding 2026-01-01 23:16:15 +01:00
Peter Steinberger 6e87fd2d4c docs: update changelog 2026-01-01 22:55:25 +01:00
Peter Steinberger fbf5efb570 feat(process): support env overrides in exec 2026-01-01 22:55:21 +01:00
Peter Steinberger 1a3323a261 fix(cli): improve skill install failure output 2026-01-01 22:55:15 +01:00
Peter Steinberger b858fdd755 feat(macos): show skills in onboarding 2026-01-01 22:55:10 +01:00
Peter Steinberger 0aff827414 fix: preserve webchat run ordering 2026-01-01 22:46:43 +01:00
Peter Steinberger bd8a0a9f8f feat: add remote CDP browser support 2026-01-01 22:44:52 +01:00
Peter Steinberger 73d0e2cb81 fix: gate skills by OS 2026-01-01 22:25:37 +01:00
Peter Steinberger 47f816696c fix: refine A2UI status HUD styling 2026-01-01 20:47:51 +00:00
Peter Steinberger 1cf455e91c fix: use brew installer for imsg skill 2026-01-01 21:41:39 +01:00
Peter Steinberger 952c8c2d64 fix: improve canvas debug status in remote mode 2026-01-01 20:41:13 +00:00
Peter Steinberger dce3bf01fd build: refresh a2ui bundle hash 2026-01-01 20:41:09 +00:00
Peter Steinberger 7b1687d7e5 fix: resolve macOS config store concurrency 2026-01-01 21:31:37 +01:00
Peter Steinberger 9ad6863567 docs: trim changelog 2026-01-01 21:31:13 +01:00
Peter Steinberger 4c1424bb83 chore: fix lint warnings 2026-01-01 21:25:29 +01:00
Peter Steinberger c7364de2f0 fix: align telegram token resolution 2026-01-01 21:22:59 +01:00
Peter Steinberger e0043906be docs: add Discord badge 2026-01-01 21:22:00 +01:00
Peter Steinberger eda9fb5522 feat(skills): add things-mac 2026-01-01 21:12:37 +01:00
Peter Steinberger 8a775144bf docs: update changelog 2026-01-01 21:09:36 +01:00
Peter Steinberger 9b65534561 test: harden wizard e2e flow 2026-01-01 21:09:32 +01:00
Peter Steinberger f6c0618596 fix: improve web chat scroll and text 2026-01-01 21:09:28 +01:00
Peter Steinberger 15fd030fa4 docs: refresh onboarding wizard docs 2026-01-01 21:09:24 +01:00
Peter Steinberger 693be03dcc test: cover remote config routing 2026-01-01 20:29:53 +01:00
Peter Steinberger 6e3cb34024 chore: pin ElevenLabsKit + wizard note 2026-01-01 20:19:00 +01:00
Peter Steinberger bd7cd33b02 feat: add remote gateway client config 2026-01-01 20:10:50 +01:00
Peter Steinberger a72fdf7c26 feat: expand wizard setup flow 2026-01-01 19:14:14 +01:00
Peter Steinberger 850cbfe369 fix: route macOS remote config via gateway 2026-01-01 18:58:41 +01:00
Peter Steinberger 351db0632d fix(signal): map stderr INFO to log 2026-01-01 17:30:51 +00:00
Peter Steinberger d642e90cdd style: format onboarding commands 2026-01-01 17:30:51 +00:00
Peter Steinberger c454f7ac0d fix: detect bun relay assets 2026-01-01 18:30:16 +01:00
Peter Steinberger b5b47d7273 docs: update changelog 2026-01-01 17:24:42 +00:00
Peter Steinberger 7c2c541729 feat: expand onboarding wizard 2026-01-01 18:23:59 +01:00
Peter Steinberger f10abc8ee0 fix: narrow onboarding prompt types 2026-01-01 17:14:02 +00:00
Peter Steinberger 8ea27968d7 style: apply biome formatting 2026-01-01 17:06:47 +00:00
Peter Steinberger 956db9c182 fix: keep pi-ai tool types for published sdk 2026-01-01 17:02:02 +00:00
Peter Steinberger 3eb3f38adf test: add onboarding e2e harness 2026-01-01 18:01:42 +01:00
Peter Steinberger 35b66e5ad1 feat: add onboarding wizard 2026-01-01 17:58:07 +01:00
Peter Steinberger d83ea305b5 fix: satisfy bun biome formatting 2026-01-01 16:54:46 +00:00
Peter Steinberger c1d8508748 fix: clean up pi-agent-core lint 2026-01-01 16:51:08 +00:00
Peter Steinberger dc8f2bda2a fix: restart via systemd on linux 2026-01-01 17:48:28 +01:00
Peter Steinberger f0f5acfa42 fix: update pi-agent-core integration 2026-01-01 16:46:40 +00:00
Peter Steinberger 4e00edf8a7 docs: update changelog for macOS rpath fix 2026-01-01 17:44:53 +01:00
Petter Blomberg 02d5c00873 macOS: move rpath configuration to build step for reliability 2026-01-01 17:44:39 +01:00
Petter Blomberg 17009d28cf build: fix hardcoded dependency path for ElevenLabsKit 2026-01-01 17:43:27 +01:00
Peter Steinberger 325a6a4e02 docs: update changelog for chat duplicate fix 2026-01-01 17:42:30 +01:00
Marc Beaupre b51b24955c fix(chat): clear input immediately after send to prevent duplicate messages
Two issues were causing the input field to retain text after sending:

1. ChatComposer's NSViewRepresentable was skipping all updates while the
   text view was first responder. Now it allows clearing (empty binding)
   even during editing, only skipping other updates to avoid cursor jumps.

2. ChatViewModel cleared input after awaiting the network response, leaving
   text visible during the round trip. Now clears immediately after capturing
   the message content, before the async send.

Together these prevent users from accidentally re-sending messages when
the input appeared unchanged after pressing Enter.
2026-01-01 17:42:05 +01:00
Peter Steinberger a954aaa507 docs: thank contributor for macOS device resource fix 2026-01-01 17:39:54 +01:00
Petter Blomberg ad475239a5 fix(macos): prioritize main bundle for device resources to prevent crash 2026-01-01 17:39:33 +01:00
Peter Steinberger 5e280674f9 docs: require Xcode 26.2+ 2026-01-01 17:38:16 +01:00
Petter Blomberg 6cdfd143b0 docs: add macOS developer setup and troubleshooting guides 2026-01-01 17:37:19 +01:00
Petter Blomberg da454fa376 build: update A2UI bundle hash 2026-01-01 17:37:19 +01:00
Peter Steinberger 358dd4f791 merge: fix/codesign-adhoc 2026-01-01 17:34:46 +01:00
William StockandClaude Opus 4.5 7366b55b14 docs: Add manual OAuth setup for remote/headless deployments
Expand "Remote mode note" section with:
- Exact oauth.json format required (access, refresh, expires)
- Note that auto-import doesn't work with Claude Code credentials
- jq script to convert Claude Code credentials to Clawdis format

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:21:27 +01:00
Peter Steinberger a248bea50f chore(browser): format CDP helpers 2026-01-01 16:19:37 +00:00
Peter Steinberger c8c84bc419 test(browser): fix chrome reachability mock 2026-01-01 16:16:55 +00:00
Peter Steinberger 5f990fb3a2 docs: note browser resiliency and reset 2026-01-01 16:15:17 +00:00
Peter Steinberger 538c1eb660 fix(browser): harden CDP readiness 2026-01-01 16:15:12 +00:00
Peter Steinberger 9f704d7aa7 docs: note macos app logging menu icon 2026-01-01 17:12:49 +01:00
Peter Steinberger a5777300d8 fix(macos): add icon to app logging menu 2026-01-01 16:48:17 +01:00
Peter Steinberger 57e1362344 docs(signal): explain bot-number setup 2026-01-01 15:37:45 +00:00
Peter Steinberger c1ccbd58f5 fix(signal): stabilize daemon + add signal delivery 2026-01-01 15:31:41 +00:00
Peter Steinberger 09a2ab420b style: biome formatting 2026-01-01 15:31:36 +00:00
Peter Steinberger 596770942a feat: add Signal provider support 2026-01-01 15:43:15 +01:00
Peter Steinberger 0a4c2f91f5 fix: add bottom padding to macos web chat 2026-01-01 13:20:27 +01:00
Peter Steinberger 5b33a7dcbe fix: polish macos web chat composer 2026-01-01 12:49:05 +01:00
Peter Steinberger c7e2b1230c fix: make composer pill full-width 2026-01-01 12:18:18 +01:00
Peter Steinberger bdf6a23de9 fix: polish web chat empty/error state 2026-01-01 11:40:11 +01:00
194 changed files with 12995 additions and 795 deletions

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
.git
.worktrees
.bun-cache
.bun
.tmp
**/.tmp
.DS_Store
**/.DS_Store
*.png
*.jpg
*.jpeg
*.webp
*.gif
*.mp4
*.mov
*.wav
*.mp3
node_modules
**/node_modules
.pnpm-store
**/.pnpm-store
.turbo
**/.turbo
.cache
**/.cache
.next
**/.next
coverage
**/coverage
*.log
tmp
**/tmp
# build artifacts
dist
**/dist
apps/macos/.build
apps/ios/build
**/*.trace
# large app trees not needed for CLI build
apps/
ui/
assets/
Peekaboo/
Swabble/
Core/
Users/
vendor/
+2
View File
@@ -9,6 +9,8 @@ coverage
.DS_Store
**/.DS_Store
ui/src/ui/__screenshots__/
ui/playwright-report/
ui/test-results/
# Bun build artifacts
*.bun-build
+56 -1
View File
@@ -2,6 +2,9 @@
## 2.0.0-beta5 — Unreleased
### Fixed
- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).
### Breaking
- Skills config schema moved under `skills.*`:
- `skillsLoad.extraDirs` → `skills.load.extraDirs`
@@ -14,22 +17,69 @@
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
- Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider.
- iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.
- Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context.
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
- CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.
- CLI: add ASCII banner header to wizard entry points.
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
- Skills: allow `bun` as a node manager for skill installs.
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
- Tests: add a Docker-based onboarding E2E harness.
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.
- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status.
### Fixes
- Gmail hooks: auto-restart watcher on gateway restart, prevent double-start, redact gog tokens (#87) — thanks @jverdi.
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
- Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks.
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
- Skills: switch imsg installer to brew tap formula.
- Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.
- Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.
- Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.
- CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.
- CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).
- CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.
- CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
- macOS remote: route settings through gateway config and avoid local config reads in remote mode.
- Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).
- Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl
- Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.
- Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
- Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.
- Browser CLI: add `clawdis browser reset-profile` to move the clawd profile to Trash when it gets wedged.
- Signal: fix daemon startup race (wait for `/api/v1/check`) and normalize JSON-RPC `version` probe parsing.
- Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.
- Docs: refresh the CLI wizard guide and highlight onboarding in the README.
- CLI: tighten onboarding prompt typing to keep bun builds green.
- 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.
- macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.
- macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).
- macOS Debug: add an icon for the App Logging submenu.
- macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.
- macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.
- macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).
- macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.
- macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.
- macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.
- WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.
- Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).
- Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.
- Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.
@@ -42,7 +92,9 @@
- Android Chat UI: use `onPrimary` for user bubble text to preserve contrast (thanks @Syhids).
- Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.
- Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.
- macOS Web Chat: fix composer layout so the connection pill and send button stay inside the input field.
- Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.
- Control UI: soften chat bubble text opacity for calmer readability.
- 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.
- macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
- iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).
@@ -59,6 +111,8 @@
- macOS menu: device list now shows connected nodes only.
- macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.
- macOS menu: split device platform/version across first and second rows for better fit.
- macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.
- Canvas A2UI: polish the debug status HUD styling.
- iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.
- iOS Talk Mode: avoid audio tap queue assertions when starting recognition.
- macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).
@@ -72,6 +126,7 @@
- macOS menu: top status line now shows pending node pairing approvals (incl. repairs).
- CLI: avoid spurious gateway close errors after successful request/response cycles.
- Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.
- Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.
- Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.
## 2.0.0-beta4 — 2025-12-27
+11 -2
View File
@@ -11,14 +11,19 @@
<p align="center">
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://github.com/steipete/clawdis/releases"><img src="https://img.shields.io/github/v/release/steipete/clawdis?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://discord.gg/qkhbAGHRBT"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
**Clawdis** is a *personal AI assistant* you run on your own devices.
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, 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.
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 private, single-user assistant that feels local, fast, and always-on, this is it.
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Discord: https://discord.gg/qkhbAGHRBT
Preferred setup: run the onboarding wizard (`clawdis onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
```
@@ -40,7 +45,7 @@ Your surfaces
## What Clawdis does
- **Personal assistant** — one user, one identity, one memory surface.
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, WebChat, macOS, iOS.
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, iMessage, WebChat, macOS, iOS. Signal support via `signal-cli` (see `docs/signal.md`). iMessage uses `imsg` (see `docs/imessage.md`).
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
- **Canvas** — a live visual workspace you can drive from the agent.
- **Automation-ready** — browser control, media handling, and tool streaming.
@@ -64,6 +69,9 @@ pnpm install
pnpm build
pnpm ui:build
# Recommended: run the onboarding wizard
pnpm clawdis onboard
# Link WhatsApp (stores creds in ~/.clawdis/credentials)
pnpm clawdis login
@@ -208,6 +216,7 @@ Browser control (optional):
- [`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)
@@ -15,7 +15,6 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ButtonDefaults
@@ -39,10 +38,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatSessionEntry
@Composable
fun ChatComposer(
sessionKey: String,
sessions: List<ChatSessionEntry>,
healthOk: Boolean,
thinkingLevel: String,
pendingRunCount: Int,
@@ -51,13 +52,16 @@ fun ChatComposer(
onPickImages: () -> Unit,
onRemoveAttachment: (id: String) -> Unit,
onSetThinkingLevel: (level: String) -> Unit,
onShowSessions: () -> Unit,
onSelectSession: (sessionKey: String) -> Unit,
onRefresh: () -> Unit,
onAbort: () -> Unit,
onSend: (text: String) -> Unit,
) {
var input by rememberSaveable { mutableStateOf("") }
var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) }
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
@@ -73,6 +77,34 @@ fun ChatComposer(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
FilledTonalButton(
onClick = { showSessionMenu = true },
contentPadding = ButtonDefaults.ContentPadding,
) {
Text("Session: $sessionKey")
}
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
for (entry in sessionOptions) {
DropdownMenuItem(
text = { Text(entry.key) },
onClick = {
onSelectSession(entry.key)
showSessionMenu = false
},
trailingIcon = {
if (entry.key == sessionKey) {
Text("✓")
} else {
Spacer(modifier = Modifier.width(10.dp))
}
},
)
}
}
}
Box {
FilledTonalButton(
onClick = { showThinkingMenu = true },
@@ -91,10 +123,6 @@ fun ChatComposer(
Spacer(modifier = Modifier.weight(1f))
FilledTonalIconButton(onClick = onShowSessions, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.FolderOpen, contentDescription = "Sessions")
}
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
@@ -14,10 +14,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -40,10 +38,9 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
var showSessions by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadChat("main")
viewModel.refreshChatSessions(limit = 200)
}
val context = LocalContext.current
@@ -87,6 +84,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
ChatComposer(
sessionKey = sessionKey,
sessions = sessions,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,
@@ -95,8 +93,11 @@ fun ChatSheetContent(viewModel: MainViewModel) {
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
onShowSessions = { showSessions = true },
onRefresh = { viewModel.refreshChat() },
onSelectSession = { key -> viewModel.switchChatSession(key) },
onRefresh = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 200)
},
onAbort = { viewModel.abortChat() },
onSend = { text ->
val outgoing =
@@ -113,19 +114,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
},
)
}
if (showSessions) {
ChatSessionsDialog(
currentSessionKey = sessionKey,
sessions = sessions,
onDismiss = { showSessions = false },
onRefresh = { viewModel.refreshChatSessions(limit = 50) },
onSelect = { key ->
viewModel.switchChatSession(key)
showSessions = false
},
)
}
}
data class PendingImageAttachment(
@@ -0,0 +1,46 @@
package com.steipete.clawdis.node.ui.chat
import com.steipete.clawdis.node.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
fun resolveSessionChoices(
currentSessionKey: String,
sessions: List<ChatSessionEntry>,
nowMs: Long = System.currentTimeMillis(),
): List<ChatSessionEntry> {
val current = currentSessionKey.trim()
val cutoff = nowMs - RECENT_WINDOW_MS
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
val recent = mutableListOf<ChatSessionEntry>()
val seen = mutableSetOf<String>()
for (entry in sorted) {
if (!seen.add(entry.key)) continue
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
recent.add(entry)
}
val result = mutableListOf<ChatSessionEntry>()
val included = mutableSetOf<String>()
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
if (mainEntry != null) {
result.add(mainEntry)
included.add(MAIN_SESSION_KEY)
} else if (current == MAIN_SESSION_KEY) {
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
included.add(MAIN_SESSION_KEY)
}
for (entry in recent) {
if (included.add(entry.key)) {
result.add(entry)
}
}
if (current.isNotEmpty() && !included.contains(current)) {
result.add(ChatSessionEntry(key = current, updatedAtMs = null))
}
return result
}
@@ -0,0 +1,35 @@
package com.steipete.clawdis.node.ui.chat
import com.steipete.clawdis.node.chat.ChatSessionEntry
import org.junit.Assert.assertEquals
import org.junit.Test
class SessionFiltersTest {
@Test
fun sessionChoicesPreferMainAndRecent() {
val now = 1_700_000_000_000L
val recent1 = now - 2 * 60 * 60 * 1000L
val recent2 = now - 5 * 60 * 60 * 1000L
val stale = now - 26 * 60 * 60 * 1000L
val sessions =
listOf(
ChatSessionEntry(key = "recent-1", updatedAtMs = recent1),
ChatSessionEntry(key = "main", updatedAtMs = stale),
ChatSessionEntry(key = "old-1", updatedAtMs = stale),
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
)
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
assertEquals(listOf("main", "recent-1", "recent-2"), result)
}
@Test
fun sessionChoicesIncludeCurrentWhenMissing() {
val now = 1_700_000_000_000L
val recent = now - 10 * 60 * 1000L
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
assertEquals(listOf("main", "custom"), result)
}
}
+4 -1
View File
@@ -17,7 +17,10 @@ struct ChatSheet: View {
var body: some View {
NavigationStack {
ClawdisChatView(viewModel: self.viewModel, userAccent: self.userAccent)
ClawdisChatView(
viewModel: self.viewModel,
showsSessionSwitcher: true,
userAccent: self.userAccent)
.navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
+10 -1
View File
@@ -1,6 +1,15 @@
{
"originHash" : "d8a19a95c479a3c7cb20aded07bd18cfeda5d85b95284983da83dbee7c941e5c",
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
"pins" : [
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0"
}
},
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
@@ -149,6 +149,49 @@ enum AnthropicOAuth {
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
}
static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
let payload: [String: Any] = [
"grant_type": "refresh_token",
"client_id": self.clientId,
"refresh_token": refreshToken,
]
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
var request = URLRequest(url: self.tokenURL)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
throw NSError(
domain: "AnthropicOAuth",
code: http.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
}
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let access = decoded?["access_token"] as? String
let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
let expiresIn = decoded?["expires_in"] as? Double
guard let access, let expiresIn else {
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected token response.",
])
}
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
+ Int64(expiresIn * 1000)
- Int64(5 * 60 * 1000)
self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
}
}
enum ClawdisOAuthStore {
@@ -279,6 +322,14 @@ enum ClawdisOAuthStore {
return .connected(expiresAtMs: expiresAtMs)
}
static func loadAnthropicOAuthRefreshToken() -> String? {
let url = self.oauthURL()
guard let storage = self.loadStorage(at: url) else { return nil }
guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
for key in keys {
if let value = dict[key] as? String { return value }
+45 -5
View File
@@ -151,8 +151,22 @@ final class CanvasManager {
private func handleGatewayPush(_ push: GatewayPush) {
guard case let .snapshot(snapshot) = push else { return }
let a2uiUrl = Self.resolveA2UIHostUrl(from: snapshot.canvashosturl)
guard let controller = self.panelController else { return }
let raw = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if raw.isEmpty {
Self.logger.debug("canvas host url missing in gateway snapshot")
} else {
Self.logger.debug("canvas host url snapshot=\(raw, privacy: .public)")
}
let a2uiUrl = Self.resolveA2UIHostUrl(from: raw)
if a2uiUrl == nil, !raw.isEmpty {
Self.logger.debug("canvas host url invalid; cannot resolve A2UI")
}
guard let controller = self.panelController else {
if a2uiUrl != nil {
Self.logger.debug("canvas panel not visible; skipping auto-nav")
}
return
}
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
}
@@ -169,7 +183,12 @@ final class CanvasManager {
private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
guard let a2uiUrl else { return }
guard controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) else { return }
let shouldNavigate = controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl)
guard shouldNavigate else {
Self.logger.debug("canvas auto-nav skipped; target unchanged")
return
}
Self.logger.debug("canvas auto-nav -> \(a2uiUrl, privacy: .public)")
controller.load(target: a2uiUrl)
self.lastAutoA2UIUrl = a2uiUrl
}
@@ -182,8 +201,29 @@ final class CanvasManager {
func refreshDebugStatus() {
guard let controller = self.panelController else { return }
let enabled = AppStateStore.shared.debugPaneEnabled
let title = GatewayProcessManager.shared.status.label
let subtitle = AppStateStore.shared.connectionMode.rawValue
let mode = AppStateStore.shared.connectionMode
let title: String?
let subtitle: String?
switch mode {
case .remote:
title = "Remote control"
switch ControlChannel.shared.state {
case .connected:
subtitle = "Connected"
case .connecting:
subtitle = "Connecting…"
case .disconnected:
subtitle = "Disconnected"
case let .degraded(message):
subtitle = message.isEmpty ? "Degraded" : message
}
case .local:
title = GatewayProcessManager.shared.status.label
subtitle = mode.rawValue
case .unconfigured:
title = "Unconfigured"
subtitle = mode.rawValue
}
controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle)
}
@@ -184,7 +184,11 @@ enum CommandResolver {
static func hasAnyClawdisInvoker(searchPaths: [String]? = nil) -> Bool {
if self.clawdisExecutable(searchPaths: searchPaths) != nil { return true }
if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true }
if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, self.nodeCliPath() != nil { return true }
if self.findExecutable(named: "node", searchPaths: searchPaths) != nil,
self.nodeCliPath() != nil
{
return true
}
return false
}
@@ -244,7 +248,11 @@ enum CommandResolver {
defaults: UserDefaults = .standard,
searchPaths: [String]? = nil) -> [String]
{
self.clawdisNodeCommand(subcommand: subcommand, extraArgs: extraArgs, defaults: defaults, searchPaths: searchPaths)
self.clawdisNodeCommand(
subcommand: subcommand,
extraArgs: extraArgs,
defaults: defaults,
searchPaths: searchPaths)
}
// MARK: - SSH helpers
+12 -10
View File
@@ -49,7 +49,7 @@ struct ConfigSettings: View {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
self.loadConfig()
await self.loadConfig()
await self.loadModels()
await self.refreshGatewayTalkApiKey()
self.allowAutosave = true
@@ -332,7 +332,9 @@ struct ConfigSettings: View {
Text("Using ELEVENLABS_API_KEY from the environment.")
.font(.footnote)
.foregroundStyle(.secondary)
} else if self.gatewayApiKeyFound && self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
} else if self.gatewayApiKeyFound
&& self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
Text("Using API key from the gateway profile.")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -369,8 +371,8 @@ struct ConfigSettings: View {
.padding(.top, 2)
}
private func loadConfig() {
let parsed = self.loadConfigDict()
private func loadConfig() async {
let parsed = await ConfigStore.load()
let agent = parsed["agent"] as? [String: Any]
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
let heartbeatBody = agent?["heartbeatBody"] as? String
@@ -429,7 +431,7 @@ struct ConfigSettings: View {
self.configSaving = true
defer { self.configSaving = false }
var root = self.loadConfigDict()
var root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:]
@@ -473,11 +475,11 @@ struct ConfigSettings: View {
talk["interruptOnSpeech"] = self.talkInterruptOnSpeech
root["talk"] = talk
ClawdisConfigFile.saveDict(root)
}
private func loadConfigDict() -> [String: Any] {
ClawdisConfigFile.loadDict()
do {
try await ConfigStore.save(root)
} catch {
self.modelError = error.localizedDescription
}
}
private var browserColor: Color {
@@ -0,0 +1,100 @@
import Foundation
enum ConfigStore {
struct Overrides: Sendable {
var isRemoteMode: (@Sendable () async -> Bool)?
var loadLocal: (@MainActor @Sendable () -> [String: Any])?
var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)?
var loadRemote: (@MainActor @Sendable () async -> [String: Any])?
var saveRemote: (@MainActor @Sendable ([String: Any]) async throws -> Void)?
}
private actor OverrideStore {
var overrides = Overrides()
func setOverride(_ overrides: Overrides) {
self.overrides = overrides
}
}
private static let overrideStore = OverrideStore()
private static func isRemoteMode() async -> Bool {
let overrides = await self.overrideStore.overrides
if let override = overrides.isRemoteMode {
return await override()
}
return await MainActor.run { AppStateStore.shared.connectionMode == .remote }
}
@MainActor
static func load() async -> [String: Any] {
let overrides = await self.overrideStore.overrides
if await self.isRemoteMode() {
if let override = overrides.loadRemote {
return await override()
}
return await self.loadFromGateway()
}
if let override = overrides.loadLocal {
return override()
}
return ClawdisConfigFile.loadDict()
}
@MainActor
static func save(_ root: [String: Any]) async throws {
let overrides = await self.overrideStore.overrides
if await self.isRemoteMode() {
if let override = overrides.saveRemote {
try await override(root)
} else {
try await self.saveToGateway(root)
}
} else {
if let override = overrides.saveLocal {
override(root)
} else {
ClawdisConfigFile.saveDict(root)
}
}
}
@MainActor
private static func loadFromGateway() async -> [String: Any] {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 8000)
return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch {
return [:]
}
}
@MainActor
private static func saveToGateway(_ root: [String: Any]) async throws {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config."
])
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
params: params,
timeoutMs: 10000)
}
#if DEBUG
static func _testSetOverrides(_ overrides: Overrides) async {
await self.overrideStore.setOverride(overrides)
}
static func _testClearOverrides() async {
await self.overrideStore.setOverride(.init())
}
#endif
}
@@ -321,26 +321,30 @@ struct ConnectionsSettings: View {
private var whatsAppTint: Color {
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
if !status.configured { return .secondary }
if !status.linked { return .red }
if status.connected { return .green }
if status.lastError != nil { return .orange }
return .green
if status.connected { return .green }
if status.running { return .orange }
return .orange
}
private var telegramTint: Color {
guard let status = self.store.snapshot?.telegram else { return .secondary }
if !status.configured { return .secondary }
if status.running { return .green }
if status.lastError != nil { return .orange }
return .secondary
if status.probe?.ok == false { return .orange }
if status.running { return .green }
return .orange
}
private var discordTint: Color {
guard let status = self.store.snapshot?.discord else { return .secondary }
if !status.configured { return .secondary }
if status.running { return .green }
if status.lastError != nil { return .orange }
return .secondary
if status.probe?.ok == false { return .orange }
if status.running { return .green }
return .orange
}
private var whatsAppSummary: String {
@@ -472,7 +476,7 @@ struct ConnectionsSettings: View {
.font(.headline)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.foregroundStyle(color)
}
Spacer()
}
@@ -52,7 +52,23 @@ final class ControlChannel {
case degraded(String)
}
private(set) var state: ConnectionState = .disconnected
private(set) var state: ConnectionState = .disconnected {
didSet {
CanvasManager.shared.refreshDebugStatus()
guard oldValue != state else { return }
switch state {
case .connected:
self.logger.info("control channel state -> connected")
case .connecting:
self.logger.info("control channel state -> connecting")
case .disconnected:
self.logger.info("control channel state -> disconnected")
case let .degraded(message):
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
self.logger.info("control channel state -> \(detail, privacy: .public)")
}
}
}
private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
@@ -45,6 +45,13 @@ enum DebugActions {
@MainActor
static func openSessionStore() {
if AppStateStore.shared.connectionMode == .remote {
let alert = NSAlert()
alert.messageText = "Remote mode"
alert.informativeText = "Session store lives on the gateway host in remote mode."
alert.runModal()
return
}
let path = self.resolveSessionStorePath()
let url = URL(fileURLWithPath: path)
if FileManager.default.fileExists(atPath: path) {
@@ -122,12 +122,13 @@ enum DeviceModelCatalog {
}
private static func locateResourceBundle() -> Bundle? {
// Prefer module bundle (SwiftPM/tests), then main app bundle (packaged app).
if let bundle = self.bundleIfContainsDeviceModels(Bundle.module) {
// Prefer main bundle (packaged app), then module bundle (SwiftPM/tests).
// Accessing Bundle.module in the packaged app can crash if the bundle isn't where SwiftPM expects it.
if let bundle = self.bundleIfContainsDeviceModels(Bundle.main) {
return bundle
}
if let bundle = self.bundleIfContainsDeviceModels(Bundle.main) {
if let bundle = self.bundleIfContainsDeviceModels(Bundle.module) {
return bundle
}
return nil
@@ -219,6 +219,9 @@ enum GatewayEnvironment {
}
private static func preferredGatewayBind() -> String? {
if CommandResolver.connectionModeIsRemote() {
return nil
}
if let env = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_BIND"] {
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
@@ -257,12 +260,33 @@ enum GatewayEnvironment {
}
statusHandler("Installing clawdis@\(target) via \(label)…")
let response = await ShellExecutor.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
if response.ok {
func summarize(_ text: String) -> String? {
let lines = text
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last else { return nil }
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
}
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
if response.success {
statusHandler("Installed clawdis@\(target)")
} else {
let detail = response.message ?? "install failed"
statusHandler("Install failed: \(detail)")
if response.timedOut {
statusHandler("Install failed: timed out. Check your internet connection and try again.")
return
}
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
let detail = summarize(response.stderr) ?? summarize(response.stdout)
if let detail {
statusHandler("Install failed (\(exit)): \(detail)")
} else {
statusHandler("Install failed (\(exit))")
}
}
}
@@ -117,6 +117,9 @@ enum GatewayLaunchAgentManager {
}
private static func preferredGatewayBind() -> String? {
if CommandResolver.connectionModeIsRemote() {
return nil
}
if let env = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_BIND"] {
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
@@ -114,6 +114,7 @@ struct ClawdisOSLogHandler: LogHandler {
set { self.metadata[key] = newValue }
}
// swiftlint:disable:next function_parameter_count
func log(
level: Logger.Level,
message: Logger.Message,
@@ -188,6 +189,7 @@ struct ClawdisFileLogHandler: LogHandler {
set { self.metadata[key] = newValue }
}
// swiftlint:disable:next function_parameter_count
func log(
level: Logger.Level,
message: Logger.Message,
@@ -59,7 +59,7 @@ struct MenuContent: View {
get: { self.browserControlEnabled },
set: { enabled in
self.browserControlEnabled = enabled
ClawdisConfigFile.setBrowserControlEnabled(enabled)
Task { await self.saveBrowserControlEnabled(enabled) }
})) {
Label("Browser Control", systemImage: "globe")
}
@@ -77,8 +77,8 @@ struct MenuContent: View {
Toggle(isOn: self.voiceWakeBinding) {
Label("Voice Wake", systemImage: "mic.fill")
}
.disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5)
.disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5)
if self.showVoiceWakeMicPicker {
self.voiceWakeMicMenu
}
@@ -140,8 +140,8 @@ struct MenuContent: View {
.onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled)
}
.onAppear {
self.browserControlEnabled = ClawdisConfigFile.browserControlEnabled()
.task(id: self.state.connectionMode) {
await self.loadBrowserControlEnabled()
}
}
@@ -156,6 +156,25 @@ struct MenuContent: View {
}
}
private func loadBrowserControlEnabled() async {
let root = await ConfigStore.load()
let browser = root["browser"] as? [String: Any]
let enabled = browser?["enabled"] as? Bool ?? true
await MainActor.run { self.browserControlEnabled = enabled }
}
private func saveBrowserControlEnabled(_ enabled: Bool) async {
var root = await ConfigStore.load()
var browser = root["browser"] as? [String: Any] ?? [:]
browser["enabled"] = enabled
root["browser"] = browser
do {
try await ConfigStore.save(root)
} catch {
await self.loadBrowserControlEnabled()
}
}
@ViewBuilder
private var debugMenu: some View {
if self.state.debugPaneEnabled {
@@ -184,7 +203,7 @@ struct MenuContent: View {
: "Verbose Logging (Main): Off",
systemImage: "text.alignleft")
}
Menu("App Logging") {
Menu {
Picker("Verbosity", selection: self.$appLogLevelRaw) {
ForEach(AppLogLevel.allCases) { level in
Text(level.title).tag(level.rawValue)
@@ -197,6 +216,8 @@ struct MenuContent: View {
: "File Logging: Off",
systemImage: "doc.text.magnifyingglass")
}
} label: {
Label("App Logging", systemImage: "doc.text")
}
Button {
DebugActions.openSessionStore()
@@ -207,17 +207,25 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
if let error = self.nodesStore.lastError?.nonEmpty {
menu.insertItem(self.makeMessageItem(text: "Error: \(error)", symbolName: "exclamationmark.triangle",
width: width), at: cursor)
menu.insertItem(
self.makeMessageItem(
text: "Error: \(error)",
symbolName: "exclamationmark.triangle",
width: width),
at: cursor)
cursor += 1
} else if let status = self.nodesStore.statusMessage?.nonEmpty {
menu.insertItem(self.makeMessageItem(text: status, symbolName: "info.circle", width: width), at: cursor)
menu.insertItem(
self.makeMessageItem(text: status, symbolName: "info.circle", width: width),
at: cursor)
cursor += 1
}
if entries.isEmpty {
let title = self.nodesStore.isLoading ? "Loading devices..." : "No devices yet"
menu.insertItem(self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), at: cursor)
menu.insertItem(
self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width),
at: cursor)
cursor += 1
} else {
for entry in entries.prefix(8) {
@@ -17,29 +17,35 @@ actor MacNodeBridgePairingClient {
let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.macos.bridge-client")
defer { connection.cancel() }
try await AsyncTimeout.withTimeout(seconds: 8, onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "connect timed out",
])
}) {
try await self.startAndWaitForReady(connection, queue: queue)
}
try await AsyncTimeout.withTimeout(
seconds: 8,
onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "connect timed out",
])
},
operation: {
try await self.startAndWaitForReady(connection, queue: queue)
})
onStatus?("Authenticating…")
try await self.send(hello, over: connection)
let first = try await AsyncTimeout.withTimeout(seconds: 10, onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "hello timed out",
])
}) { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection) else {
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
let first = try await AsyncTimeout.withTimeout(
seconds: 10,
onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "hello timed out",
])
}
return frame
}
},
operation: { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection) else {
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
])
}
return frame
})
switch first.base.type {
case "hello-ok":
@@ -68,28 +74,31 @@ actor MacNodeBridgePairingClient {
over: connection)
onStatus?("Waiting for approval…")
let ok = try await AsyncTimeout.withTimeout(seconds: 60, onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "pairing approval timed out",
])
}) {
while let next = try await self.receiveFrame(over: connection) {
switch next.base.type {
case "pair-ok":
return try self.decoder.decode(BridgePairOk.self, from: next.data)
case "error":
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
default:
continue
let ok = try await AsyncTimeout.withTimeout(
seconds: 60,
onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "pairing approval timed out",
])
},
operation: {
while let next = try await self.receiveFrame(over: connection) {
switch next.base.type {
case "pair-ok":
return try self.decoder.decode(BridgePairOk.self, from: next.data)
case "error":
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
default:
continue
}
}
}
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
])
}
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
])
})
return ok.token
@@ -48,17 +48,23 @@ actor MacNodeBridgeSession {
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
try await AsyncTimeout.withTimeout(seconds: 6, onTimeout: {
TimeoutError(message: "operation timed out")
}) {
try await self.send(hello)
}
try await AsyncTimeout.withTimeout(
seconds: 6,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
try await self.send(hello)
})
guard let line = try await AsyncTimeout.withTimeout(seconds: 6, onTimeout: {
TimeoutError(message: "operation timed out")
}, operation: {
try await self.receiveLine()
}),
guard let line = try await AsyncTimeout.withTimeout(
seconds: 6,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
try await self.receiveLine()
}),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
@@ -294,28 +300,31 @@ actor MacNodeBridgeSession {
_ stream: AsyncStream<NWConnection.State>,
timeoutSeconds: Double) async throws
{
try await AsyncTimeout.withTimeout(seconds: timeoutSeconds, onTimeout: {
TimeoutError(message: "operation timed out")
}) {
for await state in stream {
switch state {
case .ready:
return
case let .failed(err):
throw err
case .cancelled:
throw NSError(domain: "Bridge", code: 20, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
])
default:
continue
try await AsyncTimeout.withTimeout(
seconds: timeoutSeconds,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
for await state in stream {
switch state {
case .ready:
return
case let .failed(err):
throw err
case .cancelled:
throw NSError(domain: "Bridge", code: 20, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
])
default:
continue
}
}
}
throw NSError(domain: "Bridge", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Connection closed",
])
}
throw NSError(domain: "Bridge", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Connection closed",
])
})
}
}
@@ -228,29 +228,32 @@ final class MacNodeModeCoordinator {
_ stream: AsyncStream<NWConnection.State>,
timeoutSeconds: Double) async throws
{
try await AsyncTimeout.withTimeout(seconds: timeoutSeconds, onTimeout: {
NSError(domain: "Bridge", code: 22, userInfo: [
NSLocalizedDescriptionKey: "operation timed out",
])
}) {
for await state in stream {
switch state {
case .ready:
return
case let .failed(err):
throw err
case .cancelled:
throw NSError(domain: "Bridge", code: 20, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
])
default:
continue
try await AsyncTimeout.withTimeout(
seconds: timeoutSeconds,
onTimeout: {
NSError(domain: "Bridge", code: 22, userInfo: [
NSLocalizedDescriptionKey: "operation timed out",
])
},
operation: {
for await state in stream {
switch state {
case .ready:
return
case let .failed(err):
throw err
case .cancelled:
throw NSError(domain: "Bridge", code: 20, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
])
default:
continue
}
}
}
throw NSError(domain: "Bridge", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Connection closed",
])
}
throw NSError(domain: "Bridge", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Connection closed",
])
})
}
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
@@ -46,7 +46,8 @@ struct NotificationManager {
if Self.hasTimeSensitiveEntitlement {
content.interruptionLevel = .timeSensitive
} else {
self.logger.debug("time-sensitive notification requested without entitlement; falling back to active")
self.logger.debug(
"time-sensitive notification requested without entitlement; falling back to active")
content.interruptionLevel = .active
}
}
@@ -68,6 +68,11 @@ struct OnboardingView: View {
@State var anthropicAuthStatus: String?
@State var anthropicAuthBusy = false
@State var anthropicAuthConnected = false
@State var anthropicAuthVerifying = false
@State var anthropicAuthVerified = false
@State var anthropicAuthVerificationAttempted = false
@State var anthropicAuthVerificationFailed = false
@State var anthropicAuthVerifiedAt: Date?
@State var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile
@State var anthropicAuthAutoDetectClipboard = true
@State var anthropicAuthAutoConnectClipboard = true
@@ -80,6 +85,8 @@ struct OnboardingView: View {
@State var preferredGatewayID: String?
@State var gatewayDiscovery: GatewayDiscoveryModel
@State var onboardingChatModel: ClawdisChatViewModel
@State var onboardingSkillsModel = SkillsSettingsModel()
@State var didLoadOnboardingSkills = false
@State var localGatewayProbe: LocalGatewayProbe?
@Bindable var state: AppState
var permissionMonitor: PermissionMonitor
@@ -54,8 +54,8 @@ extension OnboardingView {
.task {
await self.refreshPerms()
self.refreshCLIStatus()
self.loadWorkspaceDefaults()
self.ensureDefaultWorkspace()
await self.loadWorkspaceDefaults()
await self.ensureDefaultWorkspace()
self.refreshAnthropicOAuthStatus()
self.refreshBootstrapStatus()
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
@@ -128,8 +128,51 @@ extension OnboardingView {
func refreshAnthropicOAuthStatus() {
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
let previous = self.anthropicAuthDetectedStatus
let status = ClawdisOAuthStore.anthropicOAuthStatus()
self.anthropicAuthDetectedStatus = status
self.anthropicAuthConnected = status.isConnected
if previous != status {
self.anthropicAuthVerified = false
self.anthropicAuthVerificationAttempted = false
self.anthropicAuthVerificationFailed = false
self.anthropicAuthVerifiedAt = nil
}
}
@MainActor
func verifyAnthropicOAuthIfNeeded(force: Bool = false) async {
guard self.state.connectionMode == .local else { return }
guard self.anthropicAuthDetectedStatus.isConnected else { return }
if self.anthropicAuthVerified, !force { return }
if self.anthropicAuthVerifying { return }
if self.anthropicAuthVerificationAttempted, !force { return }
self.anthropicAuthVerificationAttempted = true
self.anthropicAuthVerifying = true
self.anthropicAuthVerificationFailed = false
defer { self.anthropicAuthVerifying = false }
guard let refresh = ClawdisOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else {
self.anthropicAuthStatus = "OAuth verification failed: missing refresh token."
self.anthropicAuthVerificationFailed = true
return
}
do {
let updated = try await AnthropicOAuth.refresh(refreshToken: refresh)
try ClawdisOAuthStore.saveAnthropicOAuth(updated)
self.refreshAnthropicOAuthStatus()
self.anthropicAuthVerified = true
self.anthropicAuthVerifiedAt = Date()
self.anthropicAuthVerificationFailed = false
self.anthropicAuthStatus = "OAuth detected and verified."
} catch {
self.anthropicAuthVerified = false
self.anthropicAuthVerifiedAt = nil
self.anthropicAuthVerificationFailed = true
self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)"
}
}
}
@@ -297,23 +297,38 @@ extension OnboardingView {
self.onboardingCard(spacing: 12, padding: 16) {
HStack(alignment: .center, spacing: 10) {
Circle()
.fill(self.anthropicAuthConnected ? Color.green : Color.orange)
.fill(self.anthropicAuthVerified ? Color.green : Color.orange)
.frame(width: 10, height: 10)
Text(self.anthropicAuthConnected ? "Claude connected (OAuth)" : "Not connected yet")
Text(
self.anthropicAuthConnected
? (self.anthropicAuthVerified
? "Claude connected (OAuth) — verified"
: "Claude connected (OAuth)")
: "Not connected yet")
.font(.headline)
Spacer()
}
if !self.anthropicAuthConnected {
if self.anthropicAuthConnected, self.anthropicAuthVerifying {
Text("Verifying OAuth…")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else if !self.anthropicAuthConnected {
Text(self.anthropicAuthDetectedStatus.shortDescription)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt {
Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Text(
"This lets Clawdis use Claude immediately. Credentials are stored at " +
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
"`~/.clawdis/credentials/oauth.json` (owner-only).")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -341,20 +356,38 @@ extension OnboardingView {
Divider().padding(.vertical, 2)
HStack(spacing: 12) {
Button {
self.startAnthropicOAuth()
} label: {
if self.anthropicAuthBusy {
ProgressView()
if !self.anthropicAuthVerified {
if self.anthropicAuthConnected {
Button("Verify") {
Task { await self.verifyAnthropicOAuthIfNeeded(force: true) }
}
.buttonStyle(.borderedProminent)
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
if self.anthropicAuthVerificationFailed {
Button("Re-auth (OAuth)") {
self.startAnthropicOAuth()
}
.buttonStyle(.bordered)
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
}
} else {
Text("Open Claude sign-in (OAuth)")
Button {
self.startAnthropicOAuth()
} label: {
if self.anthropicAuthBusy {
ProgressView()
} else {
Text("Open Claude sign-in (OAuth)")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.anthropicAuthBusy)
}
}
.buttonStyle(.borderedProminent)
.disabled(self.anthropicAuthBusy)
}
if self.anthropicAuthPKCE != nil {
if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil {
VStack(alignment: .leading, spacing: 8) {
Text("Paste the `code#state` value")
.font(.headline)
@@ -405,6 +438,7 @@ extension OnboardingView {
}
}
}
.task { await self.verifyAnthropicOAuthIfNeeded() }
}
func permissionsPage() -> some View {
@@ -564,9 +598,14 @@ extension OnboardingView {
.disabled(self.workspaceApplying)
Button("Save in config") {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url))
self.workspaceStatus = "Saved to ~/.clawdis/clawdis.json (agent.workspace)"
Task {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url))
if saved {
self.workspaceStatus =
"Saved to ~/.clawdis/clawdis.json (agent.workspace)"
}
}
}
.buttonStyle(.bordered)
.disabled(self.workspaceApplying)
@@ -666,11 +705,89 @@ extension OnboardingView {
{
self.openSettings(tab: .skills)
}
self.skillsOverview
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
.onChange(of: self.state.launchAtLogin) { _, newValue in
AppStateStore.updateLaunchAtLogin(enabled: newValue)
}
}
}
.task { await self.maybeLoadOnboardingSkills() }
}
private func maybeLoadOnboardingSkills() async {
guard !self.didLoadOnboardingSkills else { return }
self.didLoadOnboardingSkills = true
await self.onboardingSkillsModel.refresh()
}
private var skillsOverview: some View {
VStack(alignment: .leading, spacing: 8) {
Divider()
.padding(.vertical, 6)
HStack(spacing: 10) {
Text("Skills included")
.font(.headline)
Spacer(minLength: 0)
if self.onboardingSkillsModel.isLoading {
ProgressView()
.controlSize(.small)
} else {
Button("Refresh") {
Task { await self.onboardingSkillsModel.refresh() }
}
.buttonStyle(.link)
}
}
if let error = self.onboardingSkillsModel.error {
VStack(alignment: .leading, spacing: 4) {
Text("Couldn’t load skills from the Gateway.")
.font(.footnote.weight(.semibold))
.foregroundStyle(.orange)
Text(
"Make sure the Gateway is running and connected, " +
"then hit Refresh (or open Settings → Skills).")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text("Details: \(error)")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} else if self.onboardingSkillsModel.skills.isEmpty {
Text("No skills reported yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(self.onboardingSkillsModel.skills) { skill in
HStack(alignment: .top, spacing: 10) {
Text(skill.emoji ?? "✨")
.font(.callout)
.frame(width: 22, alignment: .leading)
VStack(alignment: .leading, spacing: 2) {
Text(skill.name)
.font(.callout.weight(.semibold))
Text(skill.description)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(NSColor.windowBackgroundColor)))
}
.frame(maxHeight: 160)
}
}
}
}
@@ -1,24 +1,24 @@
import Foundation
extension OnboardingView {
func loadWorkspaceDefaults() {
func loadWorkspaceDefaults() async {
guard self.workspacePath.isEmpty else { return }
let configured = ClawdisConfigFile.agentWorkspace()
let configured = await self.loadAgentWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
self.workspacePath = AgentWorkspace.displayPath(for: url)
self.refreshBootstrapStatus()
}
func ensureDefaultWorkspace() {
func ensureDefaultWorkspace() async {
guard self.state.connectionMode == .local else { return }
let configured = ClawdisConfigFile.agentWorkspace()
let configured = await self.loadAgentWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
switch AgentWorkspace.bootstrapSafety(for: url) {
case .safe:
do {
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url))
await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url))
}
} catch {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
@@ -66,4 +66,34 @@ extension OnboardingView {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
}
}
private func loadAgentWorkspace() async -> String? {
let root = await ConfigStore.load()
let agent = root["agent"] as? [String: Any]
return agent?["workspace"] as? String
}
@discardableResult
func saveAgentWorkspace(_ workspace: String?) async -> Bool {
var root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
agent.removeValue(forKey: "workspace")
} else {
agent["workspace"] = trimmed
}
if agent.isEmpty {
root.removeValue(forKey: "agent")
} else {
root["agent"] = agent
}
do {
try await ConfigStore.save(root)
return true
} catch {
self.workspaceStatus = "Failed to save config: \(error.localizedDescription)"
return false
}
}
}
@@ -76,7 +76,10 @@ final class PeekabooBridgeHostCoordinator {
}
var infoCF: CFDictionary?
guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess,
guard SecCodeCopySigningInformation(
staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation),
&infoCF) == errSecSuccess,
let info = infoCF as? [String: Any]
else {
return nil
@@ -6,6 +6,7 @@ struct SettingsRootView: View {
private let permissionMonitor = PermissionMonitor.shared
@State private var monitoringPermissions = false
@State private var selectedTab: SettingsTab = .general
@State private var snapshotPaths: (configPath: String?, stateDir: String?) = (nil, nil)
let updater: UpdaterProviding?
private let isPreview = ProcessInfo.processInfo.isPreview
private let isNixMode = ProcessInfo.processInfo.isNixMode
@@ -102,13 +103,16 @@ struct SettingsRootView: View {
guard !self.isPreview else { return }
await self.refreshPerms()
}
.task(id: self.state.connectionMode) {
guard !self.isPreview else { return }
await self.refreshSnapshotPaths()
}
}
private var nixManagedBanner: some View {
// Prefer gateway-resolved paths; fall back to local env defaults if disconnected.
let snapshotPaths = GatewayConnection.shared.snapshotPaths()
let configPath = snapshotPaths.configPath ?? ClawdisPaths.configURL.path
let stateDir = snapshotPaths.stateDir ?? ClawdisPaths.stateDirURL.path
let configPath = self.snapshotPaths.configPath ?? ClawdisPaths.configURL.path
let stateDir = self.snapshotPaths.stateDir ?? ClawdisPaths.stateDirURL.path
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
@@ -140,6 +144,12 @@ struct SettingsRootView: View {
return requested
}
@MainActor
private func refreshSnapshotPaths() async {
let paths = await GatewayConnection.shared.snapshotPaths()
self.snapshotPaths = paths
}
@MainActor
private func refreshPerms() async {
guard !self.isPreview else { return }
@@ -104,7 +104,7 @@ struct TailscaleIntegrationSection: View {
.disabled(self.connectionMode != .local)
.task {
guard !self.hasLoaded else { return }
self.loadConfig()
await self.loadConfig()
self.hasLoaded = true
await self.effectiveService.checkTailscaleStatus()
self.startStatusTimer()
@@ -113,10 +113,10 @@ struct TailscaleIntegrationSection: View {
self.stopStatusTimer()
}
.onChange(of: self.tailscaleMode) { _, _ in
self.applySettings()
Task { await self.applySettings() }
}
.onChange(of: self.requireCredentialsForServe) { _, _ in
self.applySettings()
Task { await self.applySettings() }
}
}
@@ -233,17 +233,18 @@ struct TailscaleIntegrationSection: View {
SecureField("Password", text: self.$password)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { self.applySettings() }
.onSubmit { Task { await self.applySettings() } }
Text("Stored in ~/.clawdis/clawdis.json. Prefer CLAWDIS_GATEWAY_PASSWORD for production.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Update password") { self.applySettings() }
Button("Update password") { Task { await self.applySettings() } }
.buttonStyle(.bordered)
.controlSize(.small)
}
private func loadConfig() {
let gateway = ClawdisConfigFile.loadGatewayDict()
private func loadConfig() async {
let root = await ConfigStore.load()
let gateway = root["gateway"] as? [String: Any] ?? [:]
let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
let modeRaw = (tailscale["mode"] as? String) ?? "serve"
self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
@@ -266,7 +267,7 @@ struct TailscaleIntegrationSection: View {
}
}
private func applySettings() {
private func applySettings() async {
guard self.hasLoaded else { return }
self.validationMessage = nil
self.statusMessage = nil
@@ -279,18 +280,20 @@ struct TailscaleIntegrationSection: View {
return
}
ClawdisConfigFile.updateGatewayDict { gateway in
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
tailscale["mode"] = self.tailscaleMode.rawValue
gateway["tailscale"] = tailscale
var root = await ConfigStore.load()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
tailscale["mode"] = self.tailscaleMode.rawValue
gateway["tailscale"] = tailscale
if self.tailscaleMode != .off {
gateway["bind"] = "loopback"
}
if self.tailscaleMode != .off {
gateway["bind"] = "loopback"
}
guard self.tailscaleMode != .off else { return }
if self.tailscaleMode == .off {
gateway.removeValue(forKey: "auth")
} else {
var auth = gateway["auth"] as? [String: Any] ?? [:]
if self.tailscaleMode == .serve, !self.requireCredentialsForServe {
auth["allowTailscale"] = true
auth.removeValue(forKey: "mode")
@@ -308,6 +311,19 @@ struct TailscaleIntegrationSection: View {
}
}
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
do {
try await ConfigStore.save(root)
} catch {
self.statusMessage = error.localizedDescription
return
}
if self.connectionMode == .local, !self.isPaused {
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…"
} else {
@@ -24,7 +24,11 @@ final class TalkModeController {
self.phase = phase
TalkOverlayController.shared.updatePhase(phase)
let effectivePhase = self.isPaused ? "paused" : phase.rawValue
Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: effectivePhase) }
Task {
await GatewayConnection.shared.talkMode(
enabled: AppStateStore.shared.talkEnabled,
phase: effectivePhase)
}
}
func updateLevel(_ level: Double) {
@@ -37,7 +41,11 @@ final class TalkModeController {
self.isPaused = paused
TalkOverlayController.shared.updatePaused(paused)
let effectivePhase = paused ? "paused" : self.phase.rawValue
Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: effectivePhase) }
Task {
await GatewayConnection.shared.talkMode(
enabled: AppStateStore.shared.talkEnabled,
phase: effectivePhase)
}
Task { await TalkModeRuntime.shared.setPaused(paused) }
}
@@ -337,7 +337,9 @@ actor TalkModeRuntime {
let runId = UUID().uuidString
let startedAt = Date().timeIntervalSince1970
self.logger.info(
"talk send start runId=\(runId, privacy: .public) session=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
"talk send start runId=\(runId, privacy: .public) " +
"session=\(sessionKey, privacy: .public) " +
"chars=\(prompt.count, privacy: .public)")
do {
let response = try await GatewayConnection.shared.chatSend(
@@ -348,7 +350,8 @@ actor TalkModeRuntime {
attachments: [])
guard self.isCurrent(gen) else { return }
self.logger.info(
"talk chat.send ok runId=\(response.runId, privacy: .public) session=\(sessionKey, privacy: .public)")
"talk chat.send ok runId=\(response.runId, privacy: .public) " +
"session=\(sessionKey, privacy: .public)")
guard let assistantText = await self.waitForAssistantText(
sessionKey: sessionKey,
@@ -433,6 +436,7 @@ actor TalkModeRuntime {
}
}
// swiftlint:disable:next cyclomatic_complexity function_body_length
private func playAssistant(text: String) async {
let gen = self.lifecycleGeneration
let parse = TalkDirectiveParser.parse(text)
@@ -443,7 +447,9 @@ actor TalkModeRuntime {
if !parse.unknownKeys.isEmpty {
self.logger
.warning("talk directive ignored keys: \(parse.unknownKeys.joined(separator: ","), privacy: .public)")
.warning(
"talk directive ignored keys: " +
"\(parse.unknownKeys.joined(separator: ","), privacy: .public)")
}
let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -490,7 +496,9 @@ actor TalkModeRuntime {
self.ttsLogger.warning("talk missing voiceId; falling back to system voice")
} else if let voiceId {
self.ttsLogger
.info("talk TTS request voiceId=\(voiceId, privacy: .public) chars=\(cleaned.count, privacy: .public)")
.info(
"talk TTS request voiceId=\(voiceId, privacy: .public) " +
"chars=\(cleaned.count, privacy: .public)")
}
self.lastSpokenText = cleaned
@@ -503,7 +511,8 @@ actor TalkModeRuntime {
if outputFormat == nil, !desiredOutputFormat.isEmpty {
self.logger
.warning(
"talk output_format unsupported for local playback: \(desiredOutputFormat, privacy: .public)")
"talk output_format unsupported for local playback: " +
"\(desiredOutputFormat, privacy: .public)")
}
let modelId = directive?.modelId ?? self.currentModelId ?? self.defaultModelId
@@ -558,7 +567,8 @@ actor TalkModeRuntime {
}
self.ttsLogger
.info(
"talk audio result finished=\(result.finished, privacy: .public) interruptedAt=\(String(describing: result.interruptedAt), privacy: .public)")
"talk audio result finished=\(result.finished, privacy: .public) " +
"interruptedAt=\(String(describing: result.interruptedAt), privacy: .public)")
if !result.finished, result.interruptedAt == nil {
throw NSError(domain: "StreamingAudioPlayer", code: 1, userInfo: [
NSLocalizedDescriptionKey: "audio playback failed",
@@ -583,7 +593,9 @@ actor TalkModeRuntime {
}
} catch {
self.ttsLogger
.error("talk TTS failed: \(error.localizedDescription, privacy: .public); falling back to system voice")
.error(
"talk TTS failed: \(error.localizedDescription, privacy: .public); " +
"falling back to system voice")
do {
if self.interruptOnSpeech {
await self.startRecognition()
@@ -717,7 +729,10 @@ actor TalkModeRuntime {
let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none"
self.logger
.info(
"talk config voiceId=\(voiceLabel, privacy: .public) modelId=\(modelLabel, privacy: .public) apiKey=\(hasApiKey, privacy: .public) interrupt=\(cfg.interruptOnSpeech, privacy: .public)")
"talk config voiceId=\(voiceLabel, privacy: .public) " +
"modelId=\(modelLabel, privacy: .public) " +
"apiKey=\(hasApiKey, privacy: .public) " +
"interrupt=\(cfg.interruptOnSpeech, privacy: .public)")
}
private struct TalkRuntimeConfig {
@@ -156,7 +156,10 @@ final class WebChatSwiftUIWindowController {
self.presentation = presentation
let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport)
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm, userAccent: accent))
self.hosting = NSHostingController(rootView: ClawdisChatView(
viewModel: vm,
showsSessionSwitcher: true,
userAccent: accent))
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
}
@@ -0,0 +1,72 @@
import Testing
@testable import Clawdis
@Suite(.serialized)
@MainActor
struct ConfigStoreTests {
@Test func loadUsesRemoteInRemoteMode() async {
var localHit = false
var remoteHit = false
await ConfigStore._testSetOverrides(.init(
isRemoteMode: { true },
loadLocal: { localHit = true; return ["local": true] },
loadRemote: { remoteHit = true; return ["remote": true] }
))
let result = await ConfigStore.load()
await ConfigStore._testClearOverrides()
#expect(remoteHit)
#expect(!localHit)
#expect(result["remote"] as? Bool == true)
}
@Test func loadUsesLocalInLocalMode() async {
var localHit = false
var remoteHit = false
await ConfigStore._testSetOverrides(.init(
isRemoteMode: { false },
loadLocal: { localHit = true; return ["local": true] },
loadRemote: { remoteHit = true; return ["remote": true] }
))
let result = await ConfigStore.load()
await ConfigStore._testClearOverrides()
#expect(localHit)
#expect(!remoteHit)
#expect(result["local"] as? Bool == true)
}
@Test func saveRoutesToRemoteInRemoteMode() async throws {
var localHit = false
var remoteHit = false
await ConfigStore._testSetOverrides(.init(
isRemoteMode: { true },
saveLocal: { _ in localHit = true },
saveRemote: { _ in remoteHit = true }
))
try await ConfigStore.save(["remote": true])
await ConfigStore._testClearOverrides()
#expect(remoteHit)
#expect(!localHit)
}
@Test func saveRoutesToLocalInLocalMode() async throws {
var localHit = false
var remoteHit = false
await ConfigStore._testSetOverrides(.init(
isRemoteMode: { false },
saveLocal: { _ in localHit = true },
saveRemote: { _ in remoteHit = true }
))
try await ConfigStore.save(["local": true])
await ConfigStore._testClearOverrides()
#expect(localHit)
#expect(!remoteHit)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ let package = Package(
.library(name: "ClawdisChatUI", targets: ["ClawdisChatUI"]),
],
dependencies: [
.package(path: "../../../../ElevenLabsKit"),
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
],
targets: [
.target(
@@ -11,16 +11,22 @@ import UniformTypeIdentifiers
struct ClawdisChatComposer: View {
@Bindable var viewModel: ClawdisChatViewModel
let style: ClawdisChatView.Style
let showsSessionSwitcher: Bool
#if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = []
@FocusState private var isFocused: Bool
#else
@State private var shouldFocusTextView = false
#endif
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if self.showsToolbar {
HStack(spacing: 6) {
if self.showsSessionSwitcher {
self.sessionPicker
}
self.thinkingPicker
Spacer()
self.refreshButton
@@ -33,26 +39,46 @@ struct ClawdisChatComposer: View {
}
self.editor
if let error = self.viewModel.errorText, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.lineLimit(2)
}
}
.padding(self.composerPadding)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(ClawdisChatTheme.composerBackground)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6))
#if os(macOS)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
self.handleDrop(providers)
.background {
let cornerRadius: CGFloat = 18
#if os(macOS)
if self.style == .standard {
let shape = UnevenRoundedRectangle(
cornerRadii: RectangleCornerRadii(
topLeading: 0,
bottomLeading: cornerRadius,
bottomTrailing: cornerRadius,
topTrailing: 0),
style: .continuous)
shape
.fill(ClawdisChatTheme.composerBackground)
.overlay(shape.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
} else {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
shape
.fill(ClawdisChatTheme.composerBackground)
.overlay(shape.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
}
#else
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
shape
.fill(ClawdisChatTheme.composerBackground)
.overlay(shape.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
#endif
}
#if os(macOS)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
self.handleDrop(providers)
}
.onAppear {
self.shouldFocusTextView = true
}
#endif
}
@@ -69,6 +95,26 @@ struct ClawdisChatComposer: View {
.frame(maxWidth: 140, alignment: .leading)
}
private var sessionPicker: some View {
Picker(
"Session",
selection: Binding(
get: { self.viewModel.sessionKey },
set: { next in self.viewModel.switchSession(to: next) }))
{
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
Text(session.key)
.font(.system(.caption, design: .monospaced))
.tag(session.key)
}
}
.labelsHidden()
.pickerStyle(.menu)
.controlSize(.small)
.frame(maxWidth: 160, alignment: .leading)
.help("Session")
}
@ViewBuilder
private var attachmentPicker: some View {
#if os(macOS)
@@ -185,7 +231,7 @@ struct ClawdisChatComposer: View {
}
#if os(macOS)
ChatComposerTextView(text: self.$viewModel.input) {
ChatComposerTextView(text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView) {
self.viewModel.send()
}
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
@@ -336,6 +382,7 @@ import UniformTypeIdentifiers
private struct ChatComposerTextView: NSViewRepresentable {
@Binding var text: String
@Binding var shouldFocus: Bool
var onSend: () -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
@@ -382,8 +429,18 @@ private struct ChatComposerTextView: NSViewRepresentable {
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return }
if self.shouldFocus, let window = scrollView.window {
window.makeFirstResponder(textView)
self.shouldFocus = false
}
let isEditing = scrollView.window?.firstResponder == textView
if isEditing { return }
// Always allow clearing the text (e.g. after send), even while editing.
// Only skip other updates while editing to avoid cursor jumps.
let shouldClear = self.text.isEmpty && !textView.string.isEmpty
if isEditing && !shouldClear { return }
if textView.string != self.text {
context.coordinator.isProgrammaticUpdate = true
@@ -20,14 +20,16 @@ public struct ClawdisChatView: View {
#if os(macOS)
static let outerPaddingHorizontal: CGFloat = 6
static let outerPaddingVertical: CGFloat = 0
static let stackSpacing: CGFloat = 6
static let composerPaddingHorizontal: CGFloat = 0
static let stackSpacing: CGFloat = 0
static let messageSpacing: CGFloat = 6
static let messageListPaddingTop: CGFloat = 0
static let messageListPaddingBottom: CGFloat = 4
static let messageListPaddingBottom: CGFloat = 16
static let messageListPaddingHorizontal: CGFloat = 6
#else
static let outerPaddingHorizontal: CGFloat = 6
static let outerPaddingVertical: CGFloat = 6
static let composerPaddingHorizontal: CGFloat = 6
static let stackSpacing: CGFloat = 6
static let messageSpacing: CGFloat = 12
static let messageListPaddingTop: CGFloat = 4
@@ -55,9 +57,13 @@ public struct ClawdisChatView: View {
VStack(spacing: Layout.stackSpacing) {
self.messageList
ClawdisChatComposer(viewModel: self.viewModel, style: self.style)
.padding(.horizontal, Layout.outerPaddingHorizontal)
ClawdisChatComposer(
viewModel: self.viewModel,
style: self.style,
showsSessionSwitcher: self.showsSessionSwitcher)
.padding(.horizontal, Layout.composerPaddingHorizontal)
}
.padding(.horizontal, Layout.outerPaddingHorizontal)
.padding(.vertical, Layout.outerPaddingVertical)
.frame(maxWidth: .infinity)
.frame(maxHeight: .infinity, alignment: .top)
@@ -76,13 +82,33 @@ public struct ClawdisChatView: View {
private var messageList: some View {
ZStack {
ScrollView {
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
#if os(macOS)
VStack(spacing: 0) {
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
}
Color.clear
.frame(height: Layout.messageListPaddingBottom)
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
#else
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
Color.clear
.frame(height: Layout.messageListPaddingBottom + 1)
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
#endif
}
// Keep the scroll pinned to the bottom for new messages.
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
@@ -92,6 +118,8 @@ public struct ClawdisChatView: View {
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
self.messageListOverlay
}
// Ensure the message list claims vertical space on the first layout pass.
.frame(maxHeight: .infinity, alignment: .top)
@@ -101,6 +129,9 @@ public struct ClawdisChatView: View {
self.scrollPosition = self.scrollerBottomID
self.hasPerformedInitialScroll = true
}
.onChange(of: self.viewModel.sessionKey) { _, _ in
self.hasPerformedInitialScroll = false
}
.onChange(of: self.viewModel.messages.count) { _, _ in
guard self.hasPerformedInitialScroll else { return }
withAnimation(.snappy(duration: 0.22)) {
@@ -142,10 +173,6 @@ public struct ClawdisChatView: View {
ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading)
}
Color.clear
.frame(height: Layout.messageListPaddingBottom + 1)
.id(self.scrollerBottomID)
}
private var visibleMessages: [ClawdisChatMessage] {
@@ -160,6 +187,112 @@ public struct ClawdisChatView: View {
return self.mergeToolResults(in: base)
}
@ViewBuilder
private var messageListOverlay: some View {
if self.viewModel.isLoading {
EmptyView()
} else if let error = self.activeErrorText {
let presentation = self.errorPresentation(for: error)
if self.hasVisibleMessageListContent {
VStack(spacing: 0) {
ChatNoticeBanner(
systemImage: presentation.systemImage,
title: presentation.title,
message: error,
tint: presentation.tint,
dismiss: { self.viewModel.errorText = nil },
refresh: { self.viewModel.refresh() })
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.top, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} else {
ChatNoticeCard(
systemImage: presentation.systemImage,
title: presentation.title,
message: error,
tint: presentation.tint,
actionTitle: "Refresh",
action: { self.viewModel.refresh() })
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else if self.showsEmptyState {
ChatNoticeCard(
systemImage: "bubble.left.and.bubble.right.fill",
title: self.emptyStateTitle,
message: self.emptyStateMessage,
tint: .accentColor,
actionTitle: nil,
action: nil)
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var activeErrorText: String? {
guard let text = self.viewModel.errorText?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
else {
return nil
}
return text
}
private var hasVisibleMessageListContent: Bool {
if !self.visibleMessages.isEmpty {
return true
}
if let text = self.viewModel.streamingAssistantText,
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if self.viewModel.pendingRunCount > 0 {
return true
}
if !self.viewModel.pendingToolCalls.isEmpty {
return true
}
return false
}
private var showsEmptyState: Bool {
self.viewModel.messages.isEmpty &&
(self.viewModel.streamingAssistantText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
self.viewModel.pendingRunCount == 0 &&
self.viewModel.pendingToolCalls.isEmpty
}
private var emptyStateTitle: String {
#if os(macOS)
"Web Chat"
#else
"Chat"
#endif
}
private var emptyStateMessage: String {
#if os(macOS)
"Type a message below to start.\nReturn sends • Shift-Return adds a line break."
#else
"Type a message below to start."
#endif
}
private func errorPresentation(for error: String) -> (title: String, systemImage: String, tint: Color) {
let lower = error.lowercased()
if lower.contains("not connected") || lower.contains("socket") {
return ("Disconnected", "wifi.slash", .orange)
}
if lower.contains("timed out") {
return ("Timed out", "clock.badge.exclamationmark", .orange)
}
return ("Error", "exclamationmark.triangle.fill", .orange)
}
private func mergeToolResults(in messages: [ClawdisChatMessage]) -> [ClawdisChatMessage] {
var result: [ClawdisChatMessage] = []
result.reserveCapacity(messages.count)
@@ -243,3 +376,101 @@ public struct ClawdisChatView: View {
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
}
private struct ChatNoticeCard: View {
let systemImage: String
let title: String
let message: String
let tint: Color
let actionTitle: String?
let action: (() -> Void)?
var body: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(self.tint.opacity(0.16))
Image(systemName: self.systemImage)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(self.tint)
}
.frame(width: 52, height: 52)
Text(self.title)
.font(.headline)
Text(self.message)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(4)
.frame(maxWidth: 360)
if let actionTitle, let action {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(18)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(ClawdisChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
.shadow(color: .black.opacity(0.14), radius: 18, y: 8)
}
}
private struct ChatNoticeBanner: View {
let systemImage: String
let title: String
let message: String
let tint: Color
let dismiss: () -> Void
let refresh: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: self.systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(self.tint)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.caption.weight(.semibold))
Text(self.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 0)
Button(action: self.refresh) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Refresh")
Button(action: self.dismiss) {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Dismiss")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(ClawdisChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
}
}
@@ -99,6 +99,42 @@ public final class ClawdisChatViewModel {
Task { await self.performSwitchSession(to: sessionKey) }
}
public var sessionChoices: [ClawdisChatSessionEntry] {
let now = Date().timeIntervalSince1970 * 1000
let cutoff = now - (24 * 60 * 60 * 1000)
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
var seen = Set<String>()
var recent: [ClawdisChatSessionEntry] = []
for entry in sorted {
guard !seen.contains(entry.key) else { continue }
seen.insert(entry.key)
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
recent.append(entry)
}
let mainKey = "main"
var result: [ClawdisChatSessionEntry] = []
var included = Set<String>()
if let main = sorted.first(where: { $0.key == mainKey }) {
result.append(main)
included.insert(mainKey)
} else if self.sessionKey == mainKey {
result.append(self.placeholderSession(key: mainKey))
included.insert(mainKey)
}
for entry in recent where !included.contains(entry.key) {
result.append(entry)
included.insert(entry.key)
}
if !included.contains(self.sessionKey) {
result.append(self.placeholderSession(key: self.sessionKey))
}
return result
}
public func addAttachments(urls: [URL]) {
Task { await self.loadAttachments(urls: urls) }
}
@@ -243,6 +279,10 @@ public final class ClawdisChatViewModel {
content: userContent,
timestamp: Date().timeIntervalSince1970 * 1000))
// Clear input immediately for responsive UX (before network await)
self.input = ""
self.attachments = []
do {
let response = try await self.transport.sendMessage(
sessionKey: self.sessionKey,
@@ -261,8 +301,6 @@ public final class ClawdisChatViewModel {
chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
}
self.input = ""
self.attachments = []
self.isSending = false
}
@@ -299,6 +337,23 @@ public final class ClawdisChatViewModel {
await self.bootstrap()
}
private func placeholderSession(key: String) -> ClawdisChatSessionEntry {
ClawdisChatSessionEntry(
key: key,
kind: nil,
updatedAt: nil,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil)
}
private func handleTransportEvent(_ evt: ClawdisChatTransportEvent) {
switch evt {
case let .health(ok):
@@ -26,6 +26,7 @@ private func waitUntil(
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var sentRunIds: [String] = []
var abortedRunIds: [String] = []
}
@@ -33,12 +34,17 @@ private actor TestChatTransportState {
private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport {
private let state = TestChatTransportState()
private let historyResponses: [ClawdisChatHistoryPayload]
private let sessionsResponses: [ClawdisChatSessionsListResponse]
private let stream: AsyncStream<ClawdisChatTransportEvent>
private let continuation: AsyncStream<ClawdisChatTransportEvent>.Continuation
init(historyResponses: [ClawdisChatHistoryPayload]) {
init(
historyResponses: [ClawdisChatHistoryPayload],
sessionsResponses: [ClawdisChatSessionsListResponse] = [])
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
var cont: AsyncStream<ClawdisChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in
cont = c
@@ -81,7 +87,17 @@ private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport
}
func listSessions(limit _: Int?) async throws -> ClawdisChatSessionsListResponse {
ClawdisChatSessionsListResponse(ts: nil, path: nil, count: 0, defaults: nil, sessions: [])
let idx = await self.state.sessionsCallCount
await self.state.setSessionsCallCount(idx + 1)
if idx < self.sessionsResponses.count {
return self.sessionsResponses[idx]
}
return self.sessionsResponses.last ?? ClawdisChatSessionsListResponse(
ts: nil,
path: nil,
count: 0,
defaults: nil,
sessions: [])
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
@@ -107,6 +123,10 @@ private extension TestChatTransportState {
self.historyCallCount = v
}
func setSessionsCallCount(_ v: Int) {
self.sessionsCallCount = v
}
func sentRunIdsAppend(_ v: String) {
self.sentRunIds.append(v)
}
@@ -243,6 +263,132 @@ private extension TestChatTransportState {
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
}
@Test func sessionChoicesPreferMainAndRecent() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (2 * 60 * 60 * 1000)
let recentOlder = now - (5 * 60 * 60 * 1000)
let stale = now - (26 * 60 * 60 * 1000)
let history = ClawdisChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let sessions = ClawdisChatSessionsListResponse(
ts: now,
path: nil,
count: 4,
defaults: nil,
sessions: [
ClawdisChatSessionEntry(
key: "recent-1",
kind: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
ClawdisChatSessionEntry(
key: "main",
kind: nil,
updatedAt: stale,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
ClawdisChatSessionEntry(
key: "recent-2",
kind: nil,
updatedAt: recentOlder,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
ClawdisChatSessionEntry(
key: "old-1",
kind: nil,
updatedAt: stale,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
])
let transport = TestChatTransport(
historyResponses: [history],
sessionsResponses: [sessions])
let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["main", "recent-1", "recent-2"])
}
@Test func sessionChoicesIncludeCurrentWhenMissing() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (30 * 60 * 1000)
let history = ClawdisChatHistoryPayload(
sessionKey: "custom",
sessionId: "sess-custom",
messages: [],
thinkingLevel: "off")
let sessions = ClawdisChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
ClawdisChatSessionEntry(
key: "main",
kind: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
])
let transport = TestChatTransport(
historyResponses: [history],
sessionsResponses: [sessions])
let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "custom", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["main", "custom"])
}
@Test func clearsStreamingOnExternalErrorEvent() async throws {
let sessionId = "sess-main"
let history = ClawdisChatHistoryPayload(
+1 -1
View File
@@ -18,4 +18,4 @@ read_when:
- Output:
- Default: prints text (and `MEDIA:<url>` lines) to stdout.
- `--json`: prints structured payloads + meta.
- Optional: `--deliver` sends the reply back to the selected provider (requires `--to` for WhatsApp).
- Optional: `--deliver` sends the reply back to the selected provider (`whatsapp`, `telegram`, `discord`, `signal`, `imessage`).
+20 -4
View File
@@ -28,6 +28,13 @@ Add a dedicated settings section (preferably under **Skills** or its own "Browse
- Interpreted as the base URL of the local/remote browser-control server.
- If the URL host is not loopback, Clawdis must **not** attempt to launch a local
browser; it only connects.
- **CDP URL** (`default: controlUrl + 1`)
- Base URL for Chrome DevTools Protocol (e.g. `http://127.0.0.1:18792`).
- Set this to a non-loopback host to attach the local control server to a remote
Chrome/Chromium CDP endpoint (SSH/Tailscale tunnel recommended).
- If the CDP URL host is non-loopback, clawd does **not** auto-launch a local browser.
- If you tunnel a remote CDP to `localhost`, set **Attach to existing only** to
avoid accidentally launching a local browser.
- **Accent color** (`default: #FF4500`, "lobster-orange")
- Used to theme the clawd browser profile (best-effort) and to tint UI indicators
in Clawdis.
@@ -36,6 +43,8 @@ Optional (advanced, can be hidden behind Debug initially):
- **Use headless browser** (`default: off`)
- **Attach to existing only** (`default: off`) — if on, never launch; only connect if
already running.
- **Browser executable path** (override, optional)
- **No sandbox** (`default: off`) — adds `--no-sandbox` + `--disable-setuid-sandbox`
### Port convention
@@ -68,7 +77,7 @@ internal detail.
- The agent must be able to enumerate and target tabs deterministically (by
stable `targetId` or equivalent), not "last tab".
## Browser selection (macOS)
## Browser selection (macOS + Linux)
On startup (when enabled + local URL), Clawdis chooses the browser executable
in this order:
@@ -76,9 +85,14 @@ in this order:
2) **Chromium** (if installed)
3) **Google Chrome** (fallback)
Implementation detail: detection is by existence of the `.app` bundle under
`/Applications` (and optionally `~/Applications`), then using the resolved
executable path.
Linux:
- Looks for `google-chrome` / `chromium` in common system paths.
- Use **Browser executable path** to force a specific binary.
Implementation detail:
- macOS: detection is by existence of the `.app` bundle under `/Applications`
(and optionally `~/Applications`), then using the resolved executable path.
- Linux: common `/usr/bin`/`/snap/bin` paths.
Rationale:
- Canary/Chromium are easy to visually distinguish from the user's daily driver.
@@ -158,6 +172,7 @@ Basics:
- `clawdis browser status`
- `clawdis browser start`
- `clawdis browser stop`
- `clawdis browser reset-profile`
- `clawdis browser tabs`
- `clawdis browser open https://example.com`
- `clawdis browser focus abcd1234`
@@ -204,6 +219,7 @@ Notes:
- The control server must bind to loopback only by default (`127.0.0.1`) unless the
user explicitly configures a non-loopback URL.
- Never reuse or copy the user's default Chrome profile.
- Remote CDP endpoints should be tunneled or protected; CDP is highly privileged.
## Non-goals (for the first cut)
+75 -5
View File
@@ -41,6 +41,22 @@ If set, CLAWDIS derives defaults (only when you haven’t set them explicitly):
}
```
### `wizard`
Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
```json5
{
wizard: {
lastRunAt: "2026-01-01T00:00:00.000Z",
lastRunVersion: "2.0.0-beta5",
lastRunCommit: "abc1234",
lastRunCommand: "configure",
lastRunMode: "local"
}
}
```
### `logging`
- Default log file: `/tmp/clawdis/clawdis-YYYY-MM-DD.log`
@@ -72,7 +88,7 @@ Allowlist of E.164 phone numbers that may trigger auto-replies.
### `routing.groupChat`
Group messages default to **require mention** (either metadata mention or regex patterns).
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
```json5
{
@@ -98,6 +114,7 @@ Controls how inbound messages behave when an agent run is already active.
whatsapp: "interrupt",
telegram: "interrupt",
discord: "queue",
imessage: "interrupt",
webchat: "queue"
}
}
@@ -162,13 +179,39 @@ Configure the Discord bot by setting the bot token and optional gating:
users: ["987654321098765432"] // optional user allowlist (ids)
},
requireMention: true, // require @bot mentions in guilds
mediaMaxMb: 8 // clamp inbound media size
mediaMaxMb: 8, // clamp inbound media size
historyLimit: 20, // include last N guild messages as context
enableReactions: true // allow agent-triggered reactions
}
}
```
Clawdis reads `DISCORD_BOT_TOKEN` or `discord.token` to start the provider (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
### `imessage` (imsg CLI)
Clawdis spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
```json5
{
imessage: {
enabled: true,
cliPath: "imsg",
dbPath: "~/Library/Messages/chat.db",
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
includeAttachments: false,
mediaMaxMb: 16,
service: "auto",
region: "US"
}
}
```
Notes:
- Requires Full Disk Access to the Messages DB.
- The first send will prompt for Messages automation permission.
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
### `agent.workspace`
Sets the **single global workspace directory** used by the agent for file operations.
@@ -266,7 +309,7 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
- `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 channel (`last`, `whatsapp`, `telegram`, `discord`, `none`). Default: `last`.
- `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`).
@@ -446,6 +489,7 @@ Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd a
Defaults:
- enabled: `true`
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
- CDP URL: `http://127.0.0.1:18792` (control URL + 1)
- profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
@@ -454,10 +498,13 @@ Defaults:
browser: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
// cdpUrl: "http://127.0.0.1:18792", // override for remote CDP
color: "#FF4500",
// Advanced:
// headless: false,
// attachOnly: false,
// noSandbox: false,
// executablePath: "/usr/bin/chromium",
// attachOnly: false, // set true when tunneling a remote CDP to localhost
}
}
```
@@ -508,6 +555,24 @@ Auth and Tailscale:
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
```json5
{
gateway: {
mode: "remote",
remote: {
url: "ws://gateway.tailnet:18789",
token: "your-token",
password: "your-password"
}
}
}
```
### `hooks` (Gateway webhooks)
Enable a simple HTTP webhook surface on the Gateway HTTP server.
@@ -579,6 +644,11 @@ Gmail helper config (used by `clawdis hooks gmail setup` / `run`):
}
```
Gateway auto-start:
- If `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
`gog gmail watch serve` on boot and auto-renews the watch.
- Set `CLAWDIS_SKIP_GMAIL_WATCHER=1` to disable the auto-start (for manual runs).
Note: when `tailscale.mode` is on, Clawdis defaults `serve.path` to `/` so
Tailscale can proxy `/gmail-pubsub` correctly (it strips the set-path prefix).
@@ -678,7 +748,7 @@ 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) |
| `{{Surface}}` | Surface hint (whatsapp|telegram|discord|webchat|…) |
| `{{Surface}}` | Surface hint (whatsapp|telegram|discord|imessage|webchat|…) |
## Cron (Gateway scheduler)
+2 -2
View File
@@ -75,7 +75,7 @@ Each job is a JSON object with stable keys (unknown keys ignored for forward com
- 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","to"?:string,"timeoutSeconds"?:number}`
- `{"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)
@@ -264,7 +264,7 @@ Add a `cron` command group (all commands should also support `--json` where sens
- `--wake now|next-heartbeat`
- payload flags (choose one):
- `--system-event "<text>"`
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord] [--to <dest>]`
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord|signal|imessage] [--to <dest>]`
- `clawdis cron edit <id> ...` (patch-by-flags, non-interactive)
- `clawdis cron rm <id>`
+14 -1
View File
@@ -23,6 +23,8 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default; disable with `discord.requireMention = false`.
7. Optional DM allowlist: reuse `discord.allowFrom` with user ids (`1234567890` or `discord:1234567890`). Use `"*"` to allow all DMs.
8. Optional guild allowlist: set `discord.guildAllowFrom` with `guilds` and/or `users` to gate who can invoke the bot in servers.
9. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
@@ -45,7 +47,9 @@ Note: Discord does not provide a simple username → id lookup without extra gui
users: ["987654321098765432"]
},
requireMention: true,
mediaMaxMb: 8
mediaMaxMb: 8,
historyLimit: 20,
enableReactions: true
}
}
```
@@ -54,6 +58,15 @@ Note: Discord does not provide a simple username → id lookup without extra gui
- `guildAllowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids). When both are set, both must match.
- `requireMention`: when `true`, messages in guild channels must mention the bot.
- `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
## Reactions
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
- `action: "react"`
- `channelId`, `messageId`, `emoji`
Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them.
## Safety & ops
- Treat the bot token like a password; prefer the `DISCORD_BOT_TOKEN` env var on supervised hosts or lock down the config file permissions.
+6 -1
View File
@@ -56,7 +56,12 @@ Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.
Platform note: on macOS the wizard installs `gcloud`, `gogcli`, and `tailscale`
via Homebrew; on Linux install them manually first.
Run the daemon (starts `gog gmail watch serve` + auto-renew):
Gateway auto-start (recommended):
- When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
`gog gmail watch serve` on boot and auto-renews the watch.
- Set `CLAWDIS_SKIP_GMAIL_WATCHER=1` to opt out (useful if you run the daemon yourself).
Manual daemon (starts `gog gmail watch serve` + auto-renew):
```bash
clawdis hooks gmail run
+56
View File
@@ -0,0 +1,56 @@
---
summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/iMessage)"
read_when:
- Changing group chat behavior or mention gating
---
# Groups
Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage.
## Session keys
- Group sessions use `group:<id>` in `ctx.From`.
- Direct chats use the main session (or per-sender if configured).
- Heartbeats are skipped for group sessions.
## Mention gating (default)
Group messages require a mention unless overridden per group.
```json5
{
routing: {
groupChat: {
requireMention: true,
mentionPatterns: ["@clawd", "clawdbot", "\\+15555550123"],
historyLimit: 50
}
}
}
```
Notes:
- `mentionPatterns` are case-insensitive regexes.
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
## Activation (owner-only)
Group owners can toggle per-group activation:
- `/activation mention`
- `/activation always`
Owner is determined by `routing.allowFrom` (or the bot’s default identity when unset).
## Context fields
Group inbound payloads set:
- `ChatType=group`
- `GroupSubject` (if known)
- `GroupMembers` (if known)
- `WasMentioned` (mention gating result)
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` for WhatsApp-only behavior (history injection, mention handling details).
+10 -2
View File
@@ -10,10 +10,18 @@ surface anything that needs attention without spamming the user.
## Prompt contract
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
- If nothing needs attention, the model must reply **exactly** `HEARTBEAT_OK`.
- Any response containing `HEARTBEAT_OK` is treated as an ack and discarded.
- If nothing needs attention, the model should reply **exactly** `HEARTBEAT_OK`.
- During heartbeat runs, Clawdis treats `HEARTBEAT_OK` as an ack when it appears at
the **start or end** of the reply. Clawdis strips the token and discards the
reply if the remaining content is **≤ 30 characters**.
- If `HEARTBEAT_OK` is in the **middle** of a reply, it is not treated specially.
- For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text.
### Stray `HEARTBEAT_OK` outside heartbeats
If the model accidentally includes `HEARTBEAT_OK` at the start or end of a
normal (non-heartbeat) reply, Clawdis strips the token and logs a verbose
message. If the reply is only `HEARTBEAT_OK`, it is dropped.
## Config
```json5
+63
View File
@@ -0,0 +1,63 @@
---
summary: "iMessage support via imsg (JSON-RPC over stdio), setup, and chat_id routing"
read_when:
- Setting up iMessage support
- Debugging iMessage send/receive
---
# iMessage (imsg)
Status: external CLI integration. No daemon.
## Model
- Clawdis spawns `imsg rpc` as a child process.
- JSON-RPC runs over stdin/stdout (one JSON object per line).
- Gateway owns the process; no TCP port needed.
## Requirements
- macOS with Messages signed in.
- Full Disk Access for Clawdis + the `imsg` binary (Messages DB access).
- Automation permission for Messages when sending.
## Config
```json5
{
imessage: {
enabled: true,
cliPath: "imsg",
dbPath: "~/Library/Messages/chat.db",
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
includeAttachments: false,
mediaMaxMb: 16,
service: "auto",
region: "US"
}
}
```
Notes:
- `allowFrom` accepts handles (phone/email) or `chat_id:<id>` entries.
- `service` defaults to `auto` (use `imessage` or `sms` to pin).
- `region` is only used for SMS targeting.
## Addressing / targets
Prefer `chat_id` for stable routing:
- `chat_id:123` (preferred)
- `chat_guid:...` (fallback)
- `chat_identifier:...` (fallback)
- direct handles: `imessage:+1555` / `sms:+1555` / `user@example.com`
List chats:
```
imsg chats --limit 20
```
## Group chat behavior
- Group messages set `ChatType=group`, `GroupSubject`, and `GroupMembers`.
- Group activation respects `routing.groupChat.requireMention` and `mentionPatterns`.
- Replies go back to the same `chat_id` (group or direct).
## Troubleshooting
- `clawdis gateway call providers.status --params '{"probe":true}'`
- Verify `imsg` is on PATH and has access to Messages DB.
+8 -4
View File
@@ -13,17 +13,17 @@ read_when:
</p>
<p align="center">
<strong>WhatsApp + Telegram + Discord gateway for AI agents (Pi).</strong><br>
<strong>WhatsApp + Telegram + Discord + iMessage gateway for AI agents (Pi).</strong><br>
Send a message, get an agent response — from your pocket.
</p>
<p align="center">
<a href="https://github.com/steipete/clawdis">GitHub</a> ·
<a href="https://github.com/steipete/clawdis/releases">Releases</a> ·
<a href="./clawd">Clawd setup</a>
<a href="./clawd.md">Clawd setup</a>
</p>
CLAWDIS bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), and Discord (Bot API / discord.js) to coding agents like [Pi](https://github.com/badlogic/pi-mono).
CLAWDIS bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono).
It’s built for [Clawd](https://clawd.me), a space lobster who needed a TARDIS.
## How it works
@@ -61,6 +61,7 @@ Most operations flow through the **Gateway** (`clawdis gateway`), a single long-
- 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol
- ✈️ **Telegram Bot** — DMs + groups via grammY
- 🎮 **Discord Bot** — DMs + guild channels via discord.js
- 💬 **iMessage** — Local imsg CLI integration (macOS)
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
- 💬 **Sessions** — Direct chats collapse into shared `main` (default); groups are isolated
- 👥 **Group Chat Support** — Mention-based by default; owner can toggle `/activation always|mention`
@@ -121,6 +122,7 @@ Example:
- [Skills](./skills.md)
- [Skills config](./skills-config.md)
- [Workspace templates](./templates/AGENTS.md)
- [RPC adapters](./rpc.md)
- [Gateway runbook](./gateway.md)
- [Nodes (iOS/Android)](./nodes.md)
- [Web surfaces (Control UI)](./web.md)
@@ -131,7 +133,9 @@ Example:
- [Control UI (browser)](./control-ui.md)
- [Telegram](./telegram.md)
- [Discord](./discord.md)
- [Group messages](./group-messages.md)
- [iMessage](./imessage.md)
- [Groups](./groups.md)
- [WhatsApp group messages](./group-messages.md)
- [Media: images](./images.md)
- [Media: audio](./audio.md)
- Ops and safety:
+81
View File
@@ -0,0 +1,81 @@
---
summary: "Setup guide for developers working on the Clawdis macOS app"
read_when:
- Setting up the macOS development environment
---
# macOS Developer Setup
This guide covers the necessary steps to build and run the Clawdis macOS application from source.
## Prerequisites
Before building the app, ensure you have the following installed:
1. **Xcode 26.2+**: Required for Swift development.
2. **Node.js & pnpm**: Required for the gateway and CLI components.
3. **Bun**: Required to package the embedded gateway relay.
```bash
curl -fsSL https://bun.sh/install | bash
```
## 1. Initialize Submodules
Clawdis depends on several submodules (like `Peekaboo`). You must initialize these recursively:
```bash
git submodule update --init --recursive
```
## 2. Install Dependencies
Install the project-wide dependencies:
```bash
pnpm install
```
## 3. Build and Package the App
To build the macOS app and package it into `dist/Clawdis.app`, run:
```bash
./scripts/package-mac-app.sh
```
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
## 4. Install the CLI Helper
The macOS app requires a symlink named `clawdis` in `/usr/local/bin` or `/opt/homebrew/bin` to manage background tasks.
**To install it:**
1. Open the Clawdis app.
2. Go to the **General** settings tab.
3. Click **"Install CLI helper"** (requires administrator privileges).
Alternatively, you can manually link it from your Admin account:
```bash
sudo ln -sf "/Users/$(whoami)/clawdis/dist/Clawdis.app/Contents/Resources/Relay/clawdis" /usr/local/bin/clawdis
```
## Troubleshooting
### App Crashes on Permission Grant
If the app crashes when you try to allow **Speech Recognition** or **Microphone** access, it may be due to a corrupted TCC cache or signature mismatch.
**Fix:**
1. Reset the TCC permissions:
```bash
tccutil reset All com.steipete.clawdis.debug
```
2. If that fails, change the `BUNDLE_ID` temporarily in `scripts/package-mac-app.sh` to force a "clean slate" from macOS.
### Gateway "Starting..." indefinitely
If the gateway status stays on "Starting...", check if a zombie process is holding the port:
```bash
lsof -nP -i :18789
```
Kill any existing `node` or `clawdis` processes listening on that port and restart the app.
+4 -13
View File
@@ -11,7 +11,8 @@ This app is usually built from `scripts/package-mac-app.sh`, which now:
- writes the Info.plist with that bundle id (override via `BUNDLE_ID=...`)
- calls `scripts/codesign-mac-app.sh` to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). Requires a valid signing identity.
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
- injects build metadata into Info.plist: `ClawdisBuildTimestamp` (UTC) and `ClawdisGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
- inject build metadata into Info.plist: `ClawdisBuildTimestamp` (UTC) and `ClawdisGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
- **Packaging requires Bun**: The embedded gateway relay is compiled using `bun`. Ensure it is installed (`curl -fsSL https://bun.sh/install | bash`).
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert; otherwise signing falls back to ad‑hoc.
## Usage
@@ -20,20 +21,10 @@ This app is usually built from `scripts/package-mac-app.sh`, which now:
# from repo root
scripts/package-mac-app.sh # ad-hoc signing
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
# set it once in your shell profile for convenience
echo 'export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"' >> ~/.zshrc
```
If you need a different bundle id (e.g. release build):
```bash
BUNDLE_ID=com.steipete.clawdis scripts/package-mac-app.sh
```
Signing identity selection:
- If `SIGN_IDENTITY` is unset, the script auto-picks a valid identity (Developer ID → Apple Distribution → Apple Development).
- If no identities exist, the script fails with an error (no ad‑hoc fallback).
### Ad-hoc Signing Note
When signing with `SIGN_IDENTITY="-"` (ad-hoc), the script automatically disables the **Hardened Runtime** (`--options runtime`). This is necessary to prevent crashes when the app attempts to load embedded frameworks (like Sparkle) that do not share the same Team ID.
## Build metadata for About
+40 -1
View File
@@ -123,7 +123,46 @@ Daily memory lives under `memory/` in the workspace:
If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where the agent runtime runs).
For now, remote onboarding should:
- explain why OAuth isn’t shown
- explain why OAuth isn't shown
- point the user at the credential location (`~/.clawdis/credentials/oauth.json`) and the workspace location on the gateway host
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
### Manual credential setup
On the gateway host, create `~/.clawdis/credentials/oauth.json` with this exact format:
```json
{
"anthropic": {
"access": "sk-ant-oat01-...",
"refresh": "sk-ant-ort01-...",
"expires": 1767304352803
}
}
```
Set permissions: `chmod 600 ~/.clawdis/credentials/oauth.json`
**Note:** Clawdis can auto-import from legacy pi-coding-agent paths (`~/.pi/agent/oauth.json`, etc.) but this does NOT work with Claude Code credentials — different file and format.
### Using Claude Code credentials
If Claude Code is installed on the gateway host, convert its credentials:
```bash
cat ~/.claude/.credentials.json | jq '{
anthropic: {
access: .claudeAiOauth.accessToken,
refresh: .claudeAiOauth.refreshToken,
expires: .claudeAiOauth.expiresAt
}
}' > ~/.clawdis/credentials/oauth.json
chmod 600 ~/.clawdis/credentials/oauth.json
```
| Claude Code field | Clawdis field |
|-------------------|---------------|
| `accessToken` | `access` |
| `refreshToken` | `refresh` |
| `expiresAt` | `expires` |
<!-- {% endraw %} -->
+18
View File
@@ -27,6 +27,24 @@ With the tunnel up:
- `clawdis health` and `clawdis status --deep` now reach the remote gateway via `ws://127.0.0.1:18789`.
- `clawdis gateway {status,health,send,agent,call}` can also target the forwarded URL via `--url` when needed.
## CLI remote defaults
You can persist a remote target so CLI commands use it by default:
```json5
{
gateway: {
mode: "remote",
remote: {
url: "ws://127.0.0.1:18789",
token: "your-token"
}
}
}
```
When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and open the SSH tunnel first.
## Chat UI over SSH
WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects directly to the Gateway WebSocket.
+35
View File
@@ -0,0 +1,35 @@
---
summary: "RPC adapters for external CLIs (signal-cli, imsg) and gateway patterns"
read_when:
- Adding or changing external CLI integrations
- Debugging RPC adapters (signal-cli, imsg)
---
# RPC adapters
Clawdis integrates external CLIs via JSON-RPC. Two patterns are used today.
## Pattern A: HTTP daemon (signal-cli)
- `signal-cli` runs as a daemon with JSON-RPC over HTTP.
- Event stream is SSE (`/api/v1/events`).
- Health probe: `/api/v1/check`.
- Clawdis owns lifecycle when `signal.autoStart=true`.
See `docs/signal.md` for setup and endpoints.
## Pattern B: stdio child process (imsg)
- Clawdis spawns `imsg rpc` as a child process.
- JSON-RPC is line-delimited over stdin/stdout (one JSON object per line).
- No TCP port, no daemon required.
Core methods used:
- `watch.subscribe` → notifications (`method: "message"`)
- `watch.unsubscribe`
- `send`
- `chats.list` (probe/diagnostics)
See `docs/imessage.md` for setup and addressing (`chat_id` preferred).
## Adapter guidelines
- Gateway owns the process (start/stop tied to provider lifecycle).
- Keep RPC clients resilient: timeouts, restart on exit.
- Prefer stable IDs (e.g., `chat_id`) over display strings.
+116
View File
@@ -0,0 +1,116 @@
---
summary: "Setup guide: keep your Clawdis setup tailored while staying up-to-date"
read_when:
- Setting up a new machine
- You want “latest + greatest” without breaking your personal setup
---
# Setup
Last updated: 2026-01-01
## TL;DR
- **Tailoring lives outside the repo:** `~/clawd` (workspace) + `~/.clawdis/clawdis.json` (config).
- **Stable workflow:** install the macOS app; let it run the bundled Gateway.
- **Bleeding edge workflow:** run the Gateway yourself via `pnpm gateway:watch`, then point the macOS app at it using **Debug Settings → Gateway → Attach only**.
## Prereqs (from source)
- Node `>=22`
- `pnpm`
## Tailoring strategy (so updates don’t hurt)
If you want “100% tailored to me” *and* easy updates, keep your customization in:
- **Config:** `~/.clawdis/clawdis.json` (JSON/JSON5-ish)
- **Workspace:** `~/clawd` (skills, prompts, memories; make it a private git repo)
Bootstrap once:
```bash
clawdis setup
```
From inside this repo, use the local CLI entry:
```bash
pnpm clawdis setup
```
## Stable workflow (macOS app first)
1) Install + launch **Clawdis.app** (menu bar).
2) Complete the onboarding/permissions checklist (TCC prompts).
3) Ensure Gateway is **Local** and running (the app manages it).
4) Link surfaces (example: WhatsApp):
```bash
clawdis login
```
5) Sanity check:
```bash
clawdis health
```
If onboarding is still WIP/broken on your build:
- Run `clawdis setup`, then `clawdis login`, then start the Gateway manually (`clawdis gateway`).
## Bleeding edge workflow (Gateway in a terminal)
Goal: work on the TypeScript Gateway, get hot reload, keep the macOS app UI attached.
### 0) (Optional) Run the macOS app from source too
If you also want the macOS app on the bleeding edge:
```bash
./scripts/restart-mac.sh
```
### 1) Start the dev Gateway
```bash
pnpm install
pnpm gateway:watch
```
`gateway:watch` runs `src/index.ts gateway --force` and reloads on `src/**/*.ts` changes.
### 2) Point the macOS app at your running Gateway
In **Clawdis.app**:
- Connection Mode: **Local**
- Settings → **Debug Settings** → **Gateway** → enable **Attach only**
This makes the app **only connect to an already-running gateway** and **never spawn** its own.
### 3) Verify
- In-app Gateway status should read **“Using existing gateway …”**
- Or via CLI:
```bash
pnpm clawdis health
```
### Common footguns
- **Attach only enabled, but nothing is running:** app shows “Attach-only enabled; no gateway to attach”.
- **Wrong port:** Gateway WS defaults to `ws://127.0.0.1:18789`; keep app + CLI on the same port.
- **Where state lives:**
- Credentials: `~/.clawdis/credentials/`
- Sessions/logs: `~/.clawdis/sessions/`
## Updating (without wrecking your setup)
- Keep `~/clawd` and `~/.clawdis/` as “your stuff”; don’t put personal prompts/config into the `clawdis` repo.
- Updating source: `git pull` + `pnpm install` (when lockfile changed) + keep using `pnpm gateway:watch`.
## Related docs
- `docs/gateway.md` (Gateway runbook; flags, supervision, ports)
- `docs/configuration.md` (config schema + examples)
- `docs/clawd.md` (personal assistant setup)
- `docs/clawdis-mac.md` (macOS app behavior; gateway lifecycle + “Attach only”)
+95
View File
@@ -0,0 +1,95 @@
---
summary: "Signal support via signal-cli (JSON-RPC + SSE), setup, and number model"
read_when:
- Setting up Signal support
- Debugging Signal send/receive
---
# Signal (signal-cli)
Status: external CLI integration only. No libsignal embedding.
## Why
- Signal OSS stack is GPL/AGPL; not compatible with Clawdis MIT if bundled.
- signal-cli is unofficial; must stay up to date (Signal server churn).
## The “number model” (important)
- Clawdis is a **device** connected via `signal-cli`.
- If you run `signal-cli` on **your personal Signal account**, Clawdis will **not** respond to messages sent from that same account (loop protection: ignore sender==account).
- Result: you **cannot** “text yourself” to chat with the AI.
- For “I text her, she texts me back” you want a **separate Signal account/number for the bot**:
- Bot number runs `signal-cli` (linked device)
- Your personal number is in `signal.allowFrom`
- You DM the bot number; Clawdis replies back to you
You can still run Clawdis on your own Signal account if your goal is “respond to other people as me”, but not for self-chat.
## Model
- Run `signal-cli` as separate process (user-installed).
- Prefer `daemon --http=127.0.0.1:PORT` for JSON-RPC + SSE.
- Alternative: `jsonRpc` mode over stdin/stdout.
## Quickstart (bot number)
1) Install `signal-cli` (keep Java installed).
2) Link the bot account as a device:
- Run: `signal-cli link -n "Clawdis"`
- Scan QR in Signal: Settings → Linked Devices → Link New Device
- Verify: `signal-cli listAccounts` includes the bot E.164
3) Configure `~/.clawdis/clawdis.json`:
```json5
{
signal: {
enabled: true,
account: "+15551234567", // bot number (recommended)
cliPath: "signal-cli",
autoStart: true,
httpHost: "127.0.0.1",
httpPort: 8080,
// Who is allowed to talk to the bot
allowFrom: ["+15557654321"] // your personal number (or "*")
}
}
```
4) Run gateway; sanity probe:
- `clawdis gateway call providers.status --params '{"probe":true}'`
- Expect `signal.probe.ok=true` and `signal.probe.version`.
5) DM the bot number from your phone; Clawdis replies.
## Endpoints (daemon --http)
- `POST /api/v1/rpc` JSON-RPC request (single or batch).
- `GET /api/v1/events` SSE stream of `receive` notifications.
- `GET /api/v1/check` health probe (200 = up).
## Multi-account
- Start daemon without `-a`.
- Include `params.account` (E164) on JSON-RPC calls.
- SSE `?account=+E164` filters events; no param = all accounts.
## Minimal RPC surface
- `send` (recipient/groupId/username, message, attachments).
- `listGroups` (map group IDs).
- `subscribeReceive` / `unsubscribeReceive` (if manual receive).
- `startLink` / `finishLink` (optional device link flow).
## Addressing (send targets)
- Direct: `signal:+15551234567` (or plain `+15551234567`)
- Groups: `group:<groupId>`
- Usernames: `username:<name>` / `u:<name>`
## Process plan (Clawdis adapter)
1) Detect `signal-cli` binary; refuse if missing.
2) Launch daemon (HTTP preferred), store PID.
3) Poll `/api/v1/check` until ready.
4) Open SSE stream; parse `event: receive`.
5) Translate receive payload into Clawdis surface model.
6) On SSE disconnect, backoff + reconnect.
## Storage
- signal-cli data lives in `$XDG_DATA_HOME/signal-cli/data` or
`$HOME/.local/share/signal-cli/data`.
## References (local)
- `~/Projects/oss/signal-cli/README.md`
- `~/Projects/oss/signal-cli/man/signal-cli-jsonrpc.5.adoc`
- `~/Projects/oss/signal-cli/src/main/java/org/asamk/signal/http/HttpServerHandler.java`
- `~/Projects/oss/signal-cli/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java`
+2 -2
View File
@@ -20,7 +20,7 @@ All skills-related configuration lives under `skills` in `~/.clawdis/clawdis.jso
},
install: {
preferBrew: true,
nodeManager: "npm" // npm | pnpm | yarn
nodeManager: "npm" // npm | pnpm | yarn | bun
},
entries: {
"nano-banana-pro": {
@@ -43,7 +43,7 @@ All skills-related configuration lives under `skills` in `~/.clawdis/clawdis.jso
bundled skills in the list are eligible (managed/workspace skills unaffected).
- `load.extraDirs`: additional skill directories to scan (lowest precedence).
- `install.preferBrew`: prefer brew installers when available (default: true).
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn`, default: npm).
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm).
- `entries.<skillKey>`: per-skill overrides.
Per-skill fields:
+3 -1
View File
@@ -59,6 +59,7 @@ Fields under `metadata.clawdis`:
- `always: true` — always include the skill (skip other gates).
- `emoji` — optional emoji used by the macOS Skills UI.
- `homepage` — optional URL shown as “Website” in the macOS Skills UI.
- `os` — optional list of platforms (`darwin`, `linux`, `win32`). If set, the skill is only eligible on those OSes.
- `requires.bins` — list; each must exist on `PATH`.
- `requires.env` — list; env var must exist **or** be provided in config.
- `requires.config` — list of `clawdis.json` paths that must be truthy.
@@ -77,7 +78,8 @@ metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":
Notes:
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
- Node installs honor `skills.install.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn).
- Node installs honor `skills.install.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn/bun).
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
If no `metadata.clawdis` is present, the skill is always eligible (unless
disabled in config or blocked by `skills.allowBundled` for bundled skills).
+2 -2
View File
@@ -9,12 +9,12 @@ Updated: 2025-12-07
Goal: make replies deterministic per channel while keeping one shared context for direct chats.
- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `discord`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose.
- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `discord`, `imessage`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose.
- **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:** All direct chats collapse into the single `main` session by default (no config needed). Groups stay `group:<jid>`, so they remain isolated.
- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdis/sessions/<SessionId>.jsonl`.
- **WebChat:** Always attaches to `main`, loads the full session transcript so desktop reflects cross-surface history, and writes new turns back to the same session.
- **Implementation hints:**
- Set `Surface` in each ingress (WhatsApp gateway, WebChat bridge, Telegram, Discord).
- Set `Surface` in each ingress (WhatsApp gateway, WebChat bridge, Telegram, Discord, iMessage).
- Keep routing deterministic: originate → same surface. 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.
+10
View File
@@ -20,3 +20,13 @@ Usage:
Last run (2025-12-31, 20 runs):
- minimax median 1279ms (min 1114, max 2431)
- opus median 2454ms (min 1224, max 3170)
## Onboarding E2E (Docker)
Full cold-start flow in a clean Linux container:
```bash
scripts/e2e/onboard-docker.sh
```
This script drives the interactive wizard via a pseudo-tty, verifies config/workspace/session files, then starts the gateway and runs `clawdis health`.
+29
View File
@@ -134,6 +134,35 @@ CLAWDIS keeps conversation history in memory.
}
```
## macOS Specific Issues
### App Crashes when Granting Permissions (Speech/Mic)
If the app disappears or shows "Abort trap 6" when you click "Allow" on a privacy prompt:
**Fix 1: Reset TCC Cache**
```bash
tccutil reset All com.steipete.clawdis.debug
```
**Fix 2: Force New Bundle ID**
If resetting doesn't work, change the `BUNDLE_ID` in `scripts/package-mac-app.sh` (e.g., add a `.test` suffix) and rebuild. This forces macOS to treat it as a new app.
### Gateway stuck on "Starting..."
The app connects to a local gateway on port `18789`. If it stays stuck:
**Fix 1: Kill Zombie Processes**
Another process might be holding the port.
```bash
lsof -nP -i :18789
# Kill any matching PIDs
kill -9 <PID>
```
**Fix 2: Check embedded gateway**
Ensure the gateway relay was properly bundled. Run `./scripts/package-mac-app.sh` and ensure `bun` is installed.
## Debug Mode
Get verbose logging:
+151
View File
@@ -0,0 +1,151 @@
---
summary: "CLI onboarding wizard: guided setup for gateway, workspace, providers, and skills"
read_when:
- Running or configuring the onboarding wizard
- Setting up a new machine
---
# Onboarding Wizard (CLI)
The onboarding wizard is the **recommended** way to set up Clawdis on any OS.
It configures a local Gateway or a remote Gateway connection, plus providers, skills,
and workspace defaults in one guided flow.
Primary entrypoint:
```bash
clawdis onboard
```
Follow‑up reconfiguration:
```bash
clawdis configure
```
## What the wizard does
**Local mode (default)** walks you through:
- Model/auth (Anthropic OAuth recommended, API key optional, Minimax M2.1 via LM Studio)
- Workspace location + bootstrap files
- Gateway settings (port/bind/auth/tailscale)
- Providers (WhatsApp, Telegram, Discord, Signal)
- Daemon install (LaunchAgent / systemd user unit / Scheduled Task)
- Health check
- Skills (recommended)
**Remote mode** only configures the local client to connect to a Gateway elsewhere.
It does **not** install or change anything on the remote host.
## Flow details (local)
1) **Existing config detection**
- If `~/.clawdis/clawdis.json` exists, choose **Keep / Modify / Reset**.
- Reset uses `trash` (never `rm`) and offers scopes:
- Config only
- Config + credentials + sessions
- Full reset (also removes workspace)
2) **Model/Auth**
- **Anthropic OAuth (recommended)**: browser flow; paste the `code#state`.
- **API key**: stores the key for you.
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
- **Skip**: no auth configured yet.
3) **Workspace**
- Default `~/clawd` (configurable).
- Seeds the workspace files needed for the agent bootstrap ritual.
4) **Gateway**
- Port, bind, auth mode, tailscale exposure.
- Non‑loopback binds require auth.
5) **Providers**
- WhatsApp: optional QR login.
- Telegram: bot token.
- Discord: bot token.
- Signal: optional `signal-cli` install + account config.
- iMessage: local `imsg` CLI path + DB access.
6) **Daemon install**
- macOS: LaunchAgent
- Linux: systemd user unit
- Windows: Scheduled Task
7) **Health check**
- Starts the Gateway (if needed) and runs `clawdis health`.
8) **Skills (recommended)**
- Reads the available skills and checks requirements.
- Lets you choose a node manager: **npm / pnpm / bun**.
- Installs optional dependencies (some use Homebrew on macOS).
9) **Finish**
- Summary + next steps, including iOS/Android/macOS apps for extra features.
## Remote mode
Remote mode configures a local client to connect to a Gateway elsewhere.
What you’ll set:
- Remote Gateway URL (`ws://...`)
- Optional token
Notes:
- No remote installs or daemon changes are performed.
- If the Gateway is loopback‑only, use SSH tunneling or a tailnet.
- Discovery hints:
- macOS: Bonjour (`dns-sd`)
- Linux: Avahi (`avahi-browse`)
## Non‑interactive mode
Use `--non-interactive` to automate or script onboarding:
```bash
clawdis onboard --non-interactive \
--mode local \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback \
--skip-skills
```
Add `--json` for a machine‑readable summary.
## Signal setup (signal-cli)
The wizard can install `signal-cli` from GitHub releases:
- Downloads the appropriate release asset.
- Stores it under `~/.clawdis/tools/signal-cli/<version>/`.
- Writes `signal.cliPath` to your config.
Notes:
- JVM builds require **Java 21**.
- Native builds are used when available.
- Windows auto‑install is not supported yet (manual install required).
## What the wizard writes
Typical fields in `~/.clawdis/clawdis.json`:
- `agent.workspace`
- `agent.model` / `models.providers` (if Minimax chosen)
- `gateway.*` (mode, bind, auth, tailscale)
- `telegram.botToken`, `discord.token`, `signal.*`, `imessage.*`
- `skills.install.nodeManager`
- `wizard.lastRunAt`
- `wizard.lastRunVersion`
- `wizard.lastRunCommit`
- `wizard.lastRunCommand`
- `wizard.lastRunMode`
WhatsApp credentials go to `~/.clawdis/credentials/`.
Sessions are stored under `~/.clawdis/sessions/`.
## Related docs
- macOS app onboarding: `docs/onboarding.md`
- Config reference: `docs/configuration.md`
- Providers: `docs/whatsapp.md`, `docs/telegram.md`, `docs/discord.md`, `docs/signal.md`, `docs/imessage.md`
- Skills: `docs/skills.md`, `docs/skills-config.md`
+5
View File
@@ -70,6 +70,7 @@
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@grammyjs/transformer-throttler": "^1.2.1",
"@clack/prompts": "^0.11.0",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.30.2",
"@mariozechner/pi-ai": "^0.30.2",
@@ -130,6 +131,10 @@
"pnpm": {
"overrides": {
"@sinclair/typebox": "0.34.45"
},
"patchedDependencies": {
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
"@mariozechner/pi-coding-agent@0.30.2": "patches/@mariozechner__pi-coding-agent@0.30.2.patch"
}
},
"vitest": {
@@ -0,0 +1,17 @@
diff --git a/dist/config.js b/dist/config.js
index 7caa66d2676933b102431ec8d92c571eb9d6d82c..77103b9d9573e56c26014c8c7c918e1f853afcdc 100644
--- a/dist/config.js
+++ b/dist/config.js
@@ -10,8 +10,11 @@ const __dirname = dirname(__filename);
/**
* Detect if we're running as a Bun compiled binary.
* Bun binaries have import.meta.url containing "$bunfs", "~BUN", or "%7EBUN" (Bun's virtual filesystem path)
+ * Some packaging workflows keep import.meta.url as a file:// path, so fall back to execPath next to package.json.
*/
-export const isBunBinary = import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN");
+const bunBinaryByUrl = import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN");
+const bunBinaryByExecPath = existsSync(join(dirname(process.execPath), "package.json"));
+export const isBunBinary = bunBinaryByUrl || bunBinaryByExecPath;
// =============================================================================
// Package Asset Paths (shipped with executable)
// =============================================================================
+30 -2
View File
@@ -11,11 +11,17 @@ patchedDependencies:
'@mariozechner/pi-ai':
hash: bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832
path: patches/@mariozechner__pi-ai.patch
'@mariozechner/pi-coding-agent@0.30.2':
hash: d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745
path: patches/@mariozechner__pi-coding-agent@0.30.2.patch
importers:
.:
dependencies:
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
'@grammyjs/transformer-throttler':
specifier: ^1.2.1
version: 1.2.1(grammy@1.38.4)
@@ -30,7 +36,7 @@ importers:
version: 0.30.2(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.2.1)
'@mariozechner/pi-coding-agent':
specifier: ^0.30.2
version: 0.30.2(ws@8.18.3)(zod@4.2.1)
version: 0.30.2(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.2.1)
'@sinclair/typebox':
specifier: 0.34.45
version: 0.34.45
@@ -314,6 +320,12 @@ packages:
'@cacheable/utils@2.3.3':
resolution: {integrity: sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==}
'@clack/core@0.5.0':
resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==}
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@discordjs/builders@1.13.1':
resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==}
engines: {node: '>=16.11.0'}
@@ -2631,6 +2643,9 @@ packages:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
sonic-boom@4.2.0:
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
@@ -3109,6 +3124,17 @@ snapshots:
hashery: 1.3.0
keyv: 5.5.5
'@clack/core@0.5.0':
dependencies:
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/prompts@0.11.0':
dependencies:
'@clack/core': 0.5.0
picocolors: 1.1.1
sisteransi: 1.0.5
'@discordjs/builders@1.13.1':
dependencies:
'@discordjs/formatters': 0.6.2
@@ -3480,7 +3506,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.30.2(ws@8.18.3)(zod@4.2.1)':
'@mariozechner/pi-coding-agent@0.30.2(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.2.1)':
dependencies:
'@mariozechner/pi-agent-core': 0.30.2(ws@8.18.3)(zod@4.2.1)
'@mariozechner/pi-ai': 0.30.2(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.2.1)
@@ -5503,6 +5529,8 @@ snapshots:
mrmime: 2.0.1
totalist: 3.0.1
sisteransi@1.0.5: {}
sonic-boom@4.2.0:
dependencies:
atomic-sleep: 1.0.0
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-bookworm
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./
COPY patches ./patches
COPY src ./src
COPY scripts ./scripts
COPY docs ./docs
COPY skills ./skills
RUN pnpm install --frozen-lockfile
RUN pnpm build
CMD ["bash"]
+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE_NAME="clawdis-onboard-e2e"
echo "Building Docker image..."
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
echo "Running onboarding E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
export TERM=xterm-256color
send() {
local payload="$1"
local delay="${2:-0.4}"
sleep "$delay"
printf "%b" "$payload" >&3
}
run_wizard_cmd() {
local case_name="$1"
local home_dir="$2"
local command="$3"
local send_fn="$4"
echo "== Wizard case: $case_name =="
export HOME="$home_dir"
mkdir -p "$HOME"
input_fifo="$(mktemp -u "/tmp/clawdis-onboard-${case_name}.XXXXXX")"
mkfifo "$input_fifo"
script -q -c "$command" /dev/null < "$input_fifo" &
wizard_pid=$!
exec 3> "$input_fifo"
"$send_fn"
exec 3>&-
wait "$wizard_pid"
rm -f "$input_fifo"
}
run_wizard() {
local case_name="$1"
local home_dir="$2"
local send_fn="$3"
run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard" "$send_fn"
}
make_home() {
mktemp -d "/tmp/clawdis-e2e-$1.XXXXXX"
}
assert_file() {
local file_path="$1"
if [ ! -f "$file_path" ]; then
echo "Missing file: $file_path"
exit 1
fi
}
assert_dir() {
local dir_path="$1"
if [ ! -d "$dir_path" ]; then
echo "Missing dir: $dir_path"
exit 1
fi
}
send_local_basic() {
send $'"'"'\r'"'"' 1.0
send $'"'"'\r'"'"' 1.0
send "" 1.2
send $'"'"'\e[B'"'"' 0.6
send $'"'"'\e[B'"'"' 0.6
send $'"'"'\e[B'"'"' 0.6
send $'"'"'\r'"'"' 0.6
send $'"'"'\r'"'"' 0.5
send $'"'"'\r'"'"' 0.5
send $'"'"'\r'"'"' 0.5
send $'"'"'\r'"'"' 0.5
send $'"'"'n\r'"'"' 0.5
send $'"'"'n\r'"'"' 0.5
send $'"'"'n\r'"'"' 0.5
}
send_reset_config_only() {
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\r'"'"' 0.4
send $'"'"'\r'"'"' 0.4
send "" 1.2
send_local_basic
}
send_providers_flow() {
send $'"'"'\r'"'"' 0.8
send "" 0.6
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"' '"'"' 0.3
send $'"'"'\r'"'"' 0.5
send $'"'"'\r'"'"' 0.6
send $'"'"'\e[B'"'"' 0.4
send $'"'"' '"'"' 0.3
send $'"'"'\e[B'"'"' 0.4
send $'"'"' '"'"' 0.3
send $'"'"'\e[B'"'"' 0.4
send $'"'"' '"'"' 0.3
send $'"'"'\r'"'"' 0.5
send $'"'"'tg_token\r'"'"' 0.5
send $'"'"'discord_token\r'"'"' 0.5
send $'"'"'n\r'"'"' 0.5
send $'"'"'+15551234567\r'"'"' 0.5
}
send_skills_flow() {
send "" 0.6
send $'"'"'\r'"'"' 0.6
send "" 1.0
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"'\e[B'"'"' 0.4
send $'"'"' '"'"' 0.3
send $'"'"'\r'"'"' 0.4
send $'"'"'n\r'"'"' 0.4
}
run_case_local_basic() {
local home_dir
home_dir="$(make_home local-basic)"
run_wizard local-basic "$home_dir" send_local_basic
workspace_dir="$HOME/clawd"
config_path="$HOME/.clawdis/clawdis.json"
sessions_dir="$HOME/.clawdis/sessions"
assert_file "$config_path"
assert_dir "$sessions_dir"
for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do
assert_file "$workspace_dir/$file"
done
CONFIG_PATH="$config_path" WORKSPACE_DIR="$workspace_dir" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const expectedWorkspace = process.env.WORKSPACE_DIR;
const errors = [];
if (cfg?.agent?.workspace !== expectedWorkspace) {
errors.push(`agent.workspace mismatch (got ${cfg?.agent?.workspace ?? "unset"})`);
}
if (cfg?.gateway?.mode !== "local") {
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);
}
if (cfg?.gateway?.bind !== "loopback") {
errors.push(`gateway.bind mismatch (got ${cfg?.gateway?.bind ?? "unset"})`);
}
if ((cfg?.gateway?.tailscale?.mode ?? "off") !== "off") {
errors.push(
`gateway.tailscale.mode mismatch (got ${cfg?.gateway?.tailscale?.mode ?? "unset"})`,
);
}
if (!cfg?.wizard?.lastRunAt) {
errors.push("wizard.lastRunAt missing");
}
if (!cfg?.wizard?.lastRunVersion) {
errors.push("wizard.lastRunVersion missing");
}
if (cfg?.wizard?.lastRunCommand !== "onboard") {
errors.push(
`wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`,
);
}
if (cfg?.wizard?.lastRunMode !== "local") {
errors.push(
`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`,
);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
node dist/index.js gateway-daemon --port 18789 --bind loopback > /tmp/gateway.log 2>&1 &
GW_PID=$!
for _ in $(seq 1 10); do
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
break
fi
sleep 1
done
if ! grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
cat /tmp/gateway.log
exit 1
fi
node dist/index.js health --timeout 2000 || (cat /tmp/gateway.log && exit 1)
kill "$GW_PID"
wait "$GW_PID" || true
}
run_case_remote_non_interactive() {
local home_dir
home_dir="$(make_home remote-non-interactive)"
export HOME="$home_dir"
mkdir -p "$HOME"
node dist/index.js onboard --non-interactive \
--mode remote \
--remote-url ws://gateway.local:18789 \
--remote-token remote-token \
--skip-skills \
--skip-health
config_path="$HOME/.clawdis/clawdis.json"
assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const errors = [];
if (cfg?.gateway?.mode !== "remote") {
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);
}
if (cfg?.gateway?.remote?.url !== "ws://gateway.local:18789") {
errors.push(`gateway.remote.url mismatch (got ${cfg?.gateway?.remote?.url ?? "unset"})`);
}
if (cfg?.gateway?.remote?.token !== "remote-token") {
errors.push(`gateway.remote.token mismatch (got ${cfg?.gateway?.remote?.token ?? "unset"})`);
}
if (cfg?.wizard?.lastRunMode !== "remote") {
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
}
run_case_reset() {
local home_dir
home_dir="$(make_home reset-config)"
export HOME="$home_dir"
mkdir -p "$HOME/.clawdis"
cat > "$HOME/.clawdis/clawdis.json" <<'"'"'JSON'"'"'
{
"agent": { "workspace": "/root/old" },
"gateway": {
"mode": "remote",
"remote": { "url": "ws://old.example:18789", "token": "old-token" }
}
}
JSON
run_wizard reset-config "$home_dir" send_reset_config_only
config_path="$HOME/.clawdis/clawdis.json"
assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const errors = [];
if (cfg?.gateway?.mode !== "local") {
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);
}
if (cfg?.gateway?.remote?.url) {
errors.push(`gateway.remote.url should be cleared (got ${cfg?.gateway?.remote?.url})`);
}
if (cfg?.wizard?.lastRunMode !== "local") {
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
}
run_case_providers() {
local home_dir
home_dir="$(make_home providers)"
run_wizard_cmd providers "$home_dir" "node dist/index.js configure" send_providers_flow
config_path="$HOME/.clawdis/clawdis.json"
assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const errors = [];
if (cfg?.telegram?.botToken !== "tg_token") {
errors.push(`telegram.botToken mismatch (got ${cfg?.telegram?.botToken ?? "unset"})`);
}
if (cfg?.discord?.token !== "discord_token") {
errors.push(`discord.token mismatch (got ${cfg?.discord?.token ?? "unset"})`);
}
if (cfg?.signal?.account !== "+15551234567") {
errors.push(`signal.account mismatch (got ${cfg?.signal?.account ?? "unset"})`);
}
if (cfg?.signal?.cliPath !== "signal-cli") {
errors.push(`signal.cliPath mismatch (got ${cfg?.signal?.cliPath ?? "unset"})`);
}
if (cfg?.wizard?.lastRunMode !== "local") {
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
}
run_case_skills() {
local home_dir
home_dir="$(make_home skills)"
export HOME="$home_dir"
mkdir -p "$HOME/.clawdis"
cat > "$HOME/.clawdis/clawdis.json" <<'"'"'JSON'"'"'
{
"skills": {
"allowBundled": ["__none__"],
"install": { "nodeManager": "bun" }
}
}
JSON
run_wizard_cmd skills "$home_dir" "node dist/index.js configure" send_skills_flow
config_path="$HOME/.clawdis/clawdis.json"
assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const errors = [];
if (cfg?.skills?.install?.nodeManager !== "bun") {
errors.push(`skills.install.nodeManager mismatch (got ${cfg?.skills?.install?.nodeManager ?? "unset"})`);
}
if (!Array.isArray(cfg?.skills?.allowBundled) || cfg.skills.allowBundled[0] !== "__none__") {
errors.push("skills.allowBundled missing");
}
if (cfg?.wizard?.lastRunMode !== "local") {
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
}
run_case_local_basic
run_case_remote_non_interactive
run_case_reset
run_case_providers
run_case_skills
'
echo "E2E complete."
+1 -2
View File
@@ -43,7 +43,7 @@ fi
cd "$ROOT_DIR/apps/macos"
echo "🔨 Building $PRODUCT ($BUILD_CONFIG)"
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH"
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
BIN="$BUILD_PATH/$BUILD_CONFIG/$PRODUCT"
echo "pkg: binary $BIN" >&2
@@ -87,7 +87,6 @@ if [ -d "$SPARKLE_FRAMEWORK" ]; then
echo "✨ Embedding Sparkle.framework"
cp -R "$SPARKLE_FRAMEWORK" "$APP_ROOT/Contents/Frameworks/"
chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP_ROOT/Contents/MacOS/Clawdis"
fi
echo "🖼 Copying app icon"
+1 -1
View File
@@ -2,7 +2,7 @@
name: imsg
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
homepage: https://imsg.to
metadata: {"clawdis":{"emoji":"📨","requires":{"bins":["imsg"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/imsg/cmd/imsg@latest","bins":["imsg"],"label":"Install imsg (go)"}]}}
metadata: {"clawdis":{"emoji":"📨","os":["darwin"],"requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}}
---
# imsg
+1 -1
View File
@@ -2,7 +2,7 @@
name: peekaboo
description: Capture and automate macOS UI with the Peekaboo CLI.
homepage: https://peekaboo.boo
metadata: {"clawdis":{"emoji":"👀","requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}}
metadata: {"clawdis":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}}
---
# Peekaboo
+61
View File
@@ -0,0 +1,61 @@
---
name: things-mac
description: Manage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database). Use when a user asks Clawdis to add a task to Things, list inbox/today/upcoming, search tasks, or inspect projects/areas/tags.
homepage: https://github.com/ossianhempel/things3-cli
metadata: {"clawdis":{"emoji":"✅","os":["darwin"],"requires":{"bins":["things"]},"install":[{"id":"go","kind":"go","module":"github.com/ossianhempel/things3-cli/cmd/things@latest","bins":["things"],"label":"Install things3-cli (go)"}]}}
---
# Things 3 CLI
Use `things` to read your local Things database (inbox/today/search/projects/areas/tags) and to add/update todos via the Things URL scheme.
Setup
- Install (recommended, Apple Silicon): `GOBIN=/opt/homebrew/bin go install github.com/ossianhempel/things3-cli/cmd/things@latest`
- If DB reads fail: grant **Full Disk Access** to the calling app (Terminal for manual runs; `Clawdis.app` for gateway runs).
- Optional: set `THINGSDB` (or pass `--db`) to point at your `ThingsData-*` folder.
- Optional: set `THINGS_AUTH_TOKEN` to avoid passing `--auth-token` for update ops.
Read-only (DB)
- `things inbox --limit 50`
- `things today`
- `things upcoming`
- `things search "query"`
- `things projects` / `things areas` / `things tags`
Write (URL scheme)
- Prefer safe preview: `things --dry-run add "Title"`
- Add: `things add "Title" --notes "..." --when today --deadline 2026-01-02`
- Bring Things to front: `things --foreground add "Title"`
Examples: add a todo
- Basic: `things add "Buy milk"`
- With notes: `things add "Buy milk" --notes "2% + bananas"`
- Into a project/area: `things add "Book flights" --list "Travel"`
- Into a project heading: `things add "Pack charger" --list "Travel" --heading "Before"`
- With tags: `things add "Call dentist" --tags "health,phone"`
- Checklist: `things add "Trip prep" --checklist-item "Passport" --checklist-item "Tickets"`
- From STDIN (multi-line => title + notes):
- `cat <<'EOF' | things add -`
- `Title line`
- `Notes line 1`
- `Notes line 2`
- `EOF`
Examples: modify a todo (needs auth token)
- First: get the ID (UUID column): `things search "milk" --limit 5`
- Auth: set `THINGS_AUTH_TOKEN` or pass `--auth-token <TOKEN>`
- Title: `things update --id <UUID> --auth-token <TOKEN> "New title"`
- Notes replace: `things update --id <UUID> --auth-token <TOKEN> --notes "New notes"`
- Notes append/prepend: `things update --id <UUID> --auth-token <TOKEN> --append-notes "..."` / `--prepend-notes "..."`
- Move lists: `things update --id <UUID> --auth-token <TOKEN> --list "Travel" --heading "Before"`
- Tags replace/add: `things update --id <UUID> --auth-token <TOKEN> --tags "a,b"` / `things update --id <UUID> --auth-token <TOKEN> --add-tags "a,b"`
- Complete/cancel (soft-delete-ish): `things update --id <UUID> --auth-token <TOKEN> --completed` / `--canceled`
- Safe preview: `things --dry-run update --id <UUID> --auth-token <TOKEN> --completed`
Delete a todo?
- Not supported by `things3-cli` right now (no “delete/move-to-trash” write command; `things trash` is read-only listing).
- Options: use Things UI to delete/trash, or mark as `--completed` / `--canceled` via `things update`.
Notes
- macOS-only.
- `--dry-run` prints the URL and does not open Things.
+44
View File
@@ -41,6 +41,7 @@ import {
} from "../cli/nodes-screen.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { loadConfig } from "../config/config.js";
import { reactMessageDiscord } from "../discord/send.js";
import { callGateway } from "../gateway/call.js";
import { detectMime } from "../media/mime.js";
import { sanitizeToolResultImages } from "./tool-images.js";
@@ -1422,6 +1423,48 @@ const GatewayToolSchema = Type.Union([
}),
]);
const DiscordToolSchema = Type.Union([
Type.Object({
action: Type.Literal("react"),
channelId: Type.String(),
messageId: Type.String(),
emoji: Type.String(),
}),
]);
function createDiscordTool(): AnyAgentTool {
return {
label: "Clawdis Discord",
name: "clawdis_discord",
description:
"React to Discord messages. Controlled by discord.enableReactions (default: true).",
parameters: DiscordToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action !== "react") throw new Error(`Unknown action: ${action}`);
const cfg = loadConfig();
if (cfg.discord?.enableReactions === false) {
throw new Error(
"Discord reactions are disabled (set discord.enableReactions=true).",
);
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { required: true });
await reactMessageDiscord(channelId, messageId, emoji);
return jsonResult({ ok: true });
},
};
}
function createGatewayTool(): AnyAgentTool {
return {
label: "Clawdis Gateway",
@@ -1470,6 +1513,7 @@ export function createClawdisTools(): AnyAgentTool[] {
createCanvasTool(),
createNodesTool(),
createCronTool(),
createDiscordTool(),
createGatewayTool(),
];
}
+2
View File
@@ -22,8 +22,10 @@ export async function ensureSessionHeader(params: {
// create
}
await fs.mkdir(path.dirname(file), { recursive: true });
const sessionVersion = 2;
const entry = {
type: "session",
version: sessionVersion,
id: params.sessionId,
timestamp: new Date().toISOString(),
cwd: params.cwd,
+1 -4
View File
@@ -433,10 +433,7 @@ export async function runEmbeddedPiAgent(params: {
tools,
});
const sessionManager = SessionManager.open(
params.sessionFile,
agentDir,
);
const sessionManager = SessionManager.open(params.sessionFile);
const settingsManager = SettingsManager.create(
resolvedWorkspace,
agentDir,
+119 -4
View File
@@ -1,3 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdisConfig } from "../config/config.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
@@ -26,6 +29,42 @@ export type SkillInstallResult = {
code: number | null;
};
function summarizeInstallOutput(text: string): string | undefined {
const raw = text.trim();
if (!raw) return undefined;
const lines = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) return undefined;
const preferred =
lines.find((line) => /^error\b/i.test(line)) ??
lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ??
lines.at(-1);
if (!preferred) return undefined;
const normalized = preferred.replace(/\s+/g, " ").trim();
const maxLen = 200;
return normalized.length > maxLen
? `${normalized.slice(0, maxLen - 1)}…`
: normalized;
}
function formatInstallFailureMessage(result: {
code: number | null;
stdout: string;
stderr: string;
}): string {
const code =
typeof result.code === "number" ? `exit ${result.code}` : "unknown exit";
const summary =
summarizeInstallOutput(result.stderr) ??
summarizeInstallOutput(result.stdout);
if (!summary) return `Install failed (${code})`;
return `Install failed (${code}): ${summary}`;
}
function resolveInstallId(spec: SkillInstallSpec, index: number): string {
return (spec.id ?? `${spec.kind}-${index}`).trim();
}
@@ -50,6 +89,8 @@ function buildNodeInstallCommand(
return ["pnpm", "add", "-g", packageName];
case "yarn":
return ["yarn", "global", "add", packageName];
case "bun":
return ["bun", "add", "-g", packageName];
default:
return ["npm", "install", "-g", packageName];
}
@@ -86,6 +127,31 @@ function buildInstallCommand(
}
}
async function resolveBrewBinDir(
timeoutMs: number,
): Promise<string | undefined> {
if (!hasBinary("brew")) return undefined;
const prefixResult = await runCommandWithTimeout(["brew", "--prefix"], {
timeoutMs: Math.min(timeoutMs, 30_000),
});
if (prefixResult.code === 0) {
const prefix = prefixResult.stdout.trim();
if (prefix) return path.join(prefix, "bin");
}
const envPrefix = process.env.HOMEBREW_PREFIX?.trim();
if (envPrefix) return path.join(envPrefix, "bin");
for (const candidate of ["/opt/homebrew/bin", "/usr/local/bin"]) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// ignore
}
}
return undefined;
}
export async function installSkill(
params: SkillInstallRequest,
): Promise<SkillInstallResult> {
@@ -128,6 +194,15 @@ export async function installSkill(
code: null,
};
}
if (spec.kind === "brew" && !hasBinary("brew")) {
return {
ok: false,
message: "brew not installed",
stdout: "",
stderr: "",
code: null,
};
}
if (spec.kind === "uv" && !hasBinary("uv")) {
if (hasBinary("brew")) {
const brewResult = await runCommandWithTimeout(
@@ -165,20 +240,60 @@ export async function installSkill(
};
}
if (spec.kind === "go" && !hasBinary("go")) {
if (hasBinary("brew")) {
const brewResult = await runCommandWithTimeout(
["brew", "install", "go"],
{
timeoutMs,
},
);
if (brewResult.code !== 0) {
return {
ok: false,
message: "Failed to install go (brew)",
stdout: brewResult.stdout.trim(),
stderr: brewResult.stderr.trim(),
code: brewResult.code,
};
}
} else {
return {
ok: false,
message: "go not installed (install via brew)",
stdout: "",
stderr: "",
code: null,
};
}
}
let env: NodeJS.ProcessEnv | undefined;
if (spec.kind === "go" && hasBinary("brew")) {
const brewBin = await resolveBrewBinDir(timeoutMs);
if (brewBin) env = { GOBIN: brewBin };
}
const result = await (async () => {
const argv = command.argv;
if (!argv || argv.length === 0) {
return { code: null, stdout: "", stderr: "invalid install command" };
}
return runCommandWithTimeout(argv, {
timeoutMs,
});
try {
return await runCommandWithTimeout(argv, {
timeoutMs,
env,
});
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err);
return { code: null, stdout: "", stderr };
}
})();
const success = result.code === 0;
return {
ok: success,
message: success ? "Installed" : "Install failed",
message: success ? "Installed" : formatInstallFailureMessage(result),
stdout: result.stdout.trim(),
stderr: result.stderr.trim(),
code: result.code,
+17 -3
View File
@@ -47,11 +47,13 @@ export type SkillStatusEntry = {
bins: string[];
env: string[];
config: string[];
os: string[];
};
missing: {
bins: string[];
env: string[];
config: string[];
os: string[];
};
configChecks: SkillStatusConfigCheck[];
install: SkillInstallOption[];
@@ -149,8 +151,13 @@ function buildSkillStatus(
const requiredBins = entry.clawdis?.requires?.bins ?? [];
const requiredEnv = entry.clawdis?.requires?.env ?? [];
const requiredConfig = entry.clawdis?.requires?.config ?? [];
const requiredOs = entry.clawdis?.os ?? [];
const missingBins = requiredBins.filter((bin) => !hasBinary(bin));
const missingOs =
requiredOs.length > 0 && !requiredOs.includes(process.platform)
? requiredOs
: [];
const missingEnv: string[] = [];
for (const envName of requiredEnv) {
@@ -174,15 +181,21 @@ function buildSkillStatus(
.map((check) => check.path);
const missing = always
? { bins: [], env: [], config: [] }
: { bins: missingBins, env: missingEnv, config: missingConfig };
? { bins: [], env: [], config: [], os: [] }
: {
bins: missingBins,
env: missingEnv,
config: missingConfig,
os: missingOs,
};
const eligible =
!disabled &&
!blockedByAllowlist &&
(always ||
(missing.bins.length === 0 &&
missing.env.length === 0 &&
missing.config.length === 0));
missing.config.length === 0 &&
missing.os.length === 0));
return {
name: entry.skill.name,
@@ -202,6 +215,7 @@ function buildSkillStatus(
bins: requiredBins,
env: requiredEnv,
config: requiredConfig,
os: requiredOs,
},
missing,
configChecks,
+26
View File
@@ -369,6 +369,32 @@ describe("buildWorkspaceSkillStatus", () => {
expect(skill?.install[0]?.id).toBe("brew");
});
it("respects OS-gated skills", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
const skillDir = path.join(workspaceDir, "skills", "os-skill");
await writeSkill({
dir: skillDir,
name: "os-skill",
description: "Darwin only",
metadata: '{"clawdis":{"os":["darwin"]}}',
});
const report = buildWorkspaceSkillStatus(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
});
const skill = report.skills.find((entry) => entry.name === "os-skill");
expect(skill).toBeDefined();
if (process.platform === "darwin") {
expect(skill?.eligible).toBe(true);
expect(skill?.missing.os).toEqual([]);
} else {
expect(skill?.eligible).toBe(false);
expect(skill?.missing.os).toEqual(["darwin"]);
}
});
it("marks bundled skills blocked by allowlist", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
const bundledDir = path.join(workspaceDir, ".bundled");
+16 -2
View File
@@ -27,6 +27,7 @@ export type ClawdisSkillMetadata = {
primaryEnv?: string;
emoji?: string;
homepage?: string;
os?: string[];
requires?: {
bins?: string[];
env?: string[];
@@ -37,7 +38,7 @@ export type ClawdisSkillMetadata = {
export type SkillsInstallPreferences = {
preferBrew: boolean;
nodeManager: "npm" | "pnpm" | "yarn";
nodeManager: "npm" | "pnpm" | "yarn" | "bun";
};
type ParsedSkillFrontmatter = Record<string, string>;
@@ -179,12 +180,19 @@ export function resolveSkillsInstallPreferences(
typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : "";
const manager = managerRaw.toLowerCase();
const nodeManager =
manager === "pnpm" || manager === "yarn" || manager === "npm"
manager === "pnpm" ||
manager === "yarn" ||
manager === "bun" ||
manager === "npm"
? (manager as SkillsInstallPreferences["nodeManager"])
: "npm";
return { preferBrew, nodeManager };
}
export function resolveRuntimePlatform(): string {
return process.platform;
}
export function resolveConfigPath(
config: ClawdisConfig | undefined,
pathStr: string,
@@ -277,6 +285,7 @@ function resolveClawdisMetadata(
const install = installRaw
.map((entry) => parseInstallSpec(entry))
.filter((entry): entry is SkillInstallSpec => Boolean(entry));
const osRaw = normalizeStringList(clawdisObj.os);
return {
always:
typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined,
@@ -294,6 +303,7 @@ function resolveClawdisMetadata(
typeof clawdisObj.primaryEnv === "string"
? clawdisObj.primaryEnv
: undefined,
os: osRaw.length > 0 ? osRaw : undefined,
requires: requiresRaw
? {
bins: normalizeStringList(requiresRaw.bins),
@@ -320,9 +330,13 @@ function shouldIncludeSkill(params: {
const skillKey = resolveSkillKey(entry.skill, entry);
const skillConfig = resolveSkillConfig(config, skillKey);
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
const osList = entry.clawdis?.os ?? [];
if (skillConfig?.enabled === false) return false;
if (!isBundledSkillAllowed(entry, allowBundled)) return false;
if (osList.length > 0 && !osList.includes(resolveRuntimePlatform())) {
return false;
}
if (entry.clawdis?.always === true) {
return true;
}
+1 -1
View File
@@ -95,7 +95,7 @@ export function buildAgentSystemPromptAppend(params: {
"## Heartbeats",
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
"HEARTBEAT_OK",
'Any response containing "HEARTBEAT_OK" is treated as a heartbeat ack and will not be delivered.',
'Clawdis treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"",
"## Runtime",
+65 -31
View File
@@ -5,55 +5,89 @@ import { HEARTBEAT_TOKEN } from "./tokens.js";
describe("stripHeartbeatToken", () => {
it("skips empty or token-only replies", () => {
expect(stripHeartbeatToken(undefined)).toEqual({
expect(stripHeartbeatToken(undefined, { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: false,
});
expect(stripHeartbeatToken(" ")).toEqual({
expect(stripHeartbeatToken(" ", { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: false,
});
expect(stripHeartbeatToken(HEARTBEAT_TOKEN)).toEqual({
expect(stripHeartbeatToken(HEARTBEAT_TOKEN, { mode: "heartbeat" })).toEqual(
{
shouldSkip: true,
text: "",
didStrip: true,
},
);
});
it("drops heartbeats with small junk in heartbeat mode", () => {
expect(
stripHeartbeatToken("HEARTBEAT_OK 🦞", { mode: "heartbeat" }),
).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
});
expect(
stripHeartbeatToken(`🦞 ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
});
});
it("skips any reply that includes the heartbeat token", () => {
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("HEARTBEAT_OK 🦞")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("HEARTBEAT_OK_OK")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("HEARTBEAT_OK _OK")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("HEARTBEAT_OK OK")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
it("drops short remainder in heartbeat mode", () => {
expect(
stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
});
});
it("keeps non-heartbeat content", () => {
expect(stripHeartbeatToken("hello")).toEqual({
it("keeps heartbeat replies when remaining content exceeds threshold", () => {
const long = "A".repeat(31);
expect(
stripHeartbeatToken(`${long} ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
).toEqual({
shouldSkip: false,
text: long,
didStrip: true,
});
});
it("strips token at edges for normal messages", () => {
expect(
stripHeartbeatToken(`${HEARTBEAT_TOKEN} hello`, { mode: "message" }),
).toEqual({
shouldSkip: false,
text: "hello",
didStrip: true,
});
expect(
stripHeartbeatToken(`hello ${HEARTBEAT_TOKEN}`, { mode: "message" }),
).toEqual({
shouldSkip: false,
text: "hello",
didStrip: true,
});
});
it("does not touch token in the middle", () => {
expect(
stripHeartbeatToken(`hello ${HEARTBEAT_TOKEN} there`, {
mode: "message",
}),
).toEqual({
shouldSkip: false,
text: `hello ${HEARTBEAT_TOKEN} there`,
didStrip: false,
});
});
});
+64 -7
View File
@@ -2,12 +2,69 @@ import { HEARTBEAT_TOKEN } from "./tokens.js";
export const HEARTBEAT_PROMPT = "HEARTBEAT";
export function stripHeartbeatToken(raw?: string) {
if (!raw) return { shouldSkip: true, text: "" };
const trimmed = raw.trim();
if (!trimmed) return { shouldSkip: true, text: "" };
if (trimmed.includes(HEARTBEAT_TOKEN)) {
return { shouldSkip: true, text: "" };
export type StripHeartbeatMode = "heartbeat" | "message";
function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
let text = raw.trim();
if (!text) return { text: "", didStrip: false };
const token = HEARTBEAT_TOKEN;
if (!text.includes(token)) return { text, didStrip: false };
let didStrip = false;
let changed = true;
while (changed) {
changed = false;
const next = text.trim();
if (next.startsWith(token)) {
const after = next.slice(token.length).trimStart();
text = after;
didStrip = true;
changed = true;
continue;
}
if (next.endsWith(token)) {
const before = next.slice(0, Math.max(0, next.length - token.length));
text = before.trimEnd();
didStrip = true;
changed = true;
}
}
return { shouldSkip: false, text: trimmed };
const collapsed = text.replace(/\s+/g, " ").trim();
return { text: collapsed, didStrip };
}
export function stripHeartbeatToken(
raw?: string,
opts: { mode?: StripHeartbeatMode; maxAckChars?: number } = {},
) {
if (!raw) return { shouldSkip: true, text: "", didStrip: false };
const trimmed = raw.trim();
if (!trimmed) return { shouldSkip: true, text: "", didStrip: false };
const mode: StripHeartbeatMode = opts.mode ?? "message";
const maxAckChars = Math.max(0, opts.maxAckChars ?? 30);
if (!trimmed.includes(HEARTBEAT_TOKEN)) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}
const stripped = stripTokenAtEdges(trimmed);
if (!stripped.didStrip) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}
if (!stripped.text) {
return { shouldSkip: true, text: "", didStrip: true };
}
if (mode === "heartbeat") {
const rest = stripped.text.trim();
if (rest.length <= maxAckChars) {
return { shouldSkip: true, text: "", didStrip: true };
}
}
return { shouldSkip: false, text: stripped.text, didStrip: true };
}
+51
View File
@@ -13,6 +13,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
const webMocks = vi.hoisted(() => ({
webAuthExists: vi.fn().mockResolvedValue(true),
@@ -160,6 +161,56 @@ describe("trigger handling", () => {
});
});
it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: HEARTBEAT_TOKEN }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
expect(res).toBeUndefined();
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("strips HEARTBEAT_OK at edges outside heartbeat runs", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: `${HEARTBEAT_TOKEN} hello` }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("hello");
});
});
it("updates group activation when the owner sends /activation", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
+64 -12
View File
@@ -37,6 +37,7 @@ import {
saveSessionStore,
} from "../config/sessions.js";
import { logVerbose } from "../globals.js";
import { registerAgentRunContext } from "../infra/agent-events.js";
import { buildProviderSummary } from "../infra/provider-summary.js";
import { triggerClawdisRestart } from "../infra/restart.js";
import {
@@ -52,6 +53,7 @@ import {
normalizeGroupActivation,
parseActivationCommand,
} from "./group-activation.js";
import { stripHeartbeatToken } from "./heartbeat.js";
import { extractModelDirective } from "./model.js";
import { buildStatusMessage } from "./status.js";
import type { MsgContext, TemplateContext } from "./templating.js";
@@ -886,10 +888,10 @@ export async function getReplyFromConfig(
cleanupTyping();
return undefined;
}
triggerClawdisRestart();
const restartMethod = triggerClawdisRestart();
cleanupTyping();
return {
text: "⚙️ Restarting clawdis via launchctl; give me a few seconds to come back online.",
text: `⚙️ Restarting clawdis via ${restartMethod}; give me a few seconds to come back online.`,
};
}
@@ -1190,11 +1192,15 @@ export async function getReplyFromConfig(
return undefined;
}
let didLogHeartbeatStrip = false;
try {
if (shouldEagerType) {
await startTypingLoop();
}
const runId = crypto.randomUUID();
if (sessionKey) {
registerAgentRunContext(runId, { sessionKey });
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
try {
runResult = await runEmbeddedPiAgent({
@@ -1216,9 +1222,24 @@ export async function getReplyFromConfig(
runId,
onPartialReply: opts?.onPartialReply
? async (payload) => {
await startTypingOnText(payload.text);
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
await startTypingOnText(text);
await opts.onPartialReply?.({
text: payload.text,
text,
mediaUrls: payload.mediaUrls,
});
}
@@ -1226,11 +1247,23 @@ export async function getReplyFromConfig(
shouldEmitToolResult,
onToolResult: opts?.onToolResult
? async (payload) => {
await startTypingOnText(payload.text);
await opts.onToolResult?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
await startTypingOnText(text);
await opts.onToolResult?.({ text, mediaUrls: payload.mediaUrls });
}
: undefined,
});
@@ -1261,7 +1294,26 @@ export async function getReplyFromConfig(
const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return undefined;
const shouldSignalTyping = payloadArray.some((payload) => {
const sanitizedPayloads = opts?.isHeartbeat
? payloadArray
: payloadArray.flatMap((payload) => {
const text = payload.text;
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return [];
return [{ ...payload, text: stripped.text }];
});
if (sanitizedPayloads.length === 0) return undefined;
const shouldSignalTyping = sanitizedPayloads.some((payload) => {
const trimmed = payload.text?.trim();
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
if (payload.mediaUrl) return true;
@@ -1316,11 +1368,11 @@ export async function getReplyFromConfig(
}
// If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = payloadArray;
let finalPayloads = sanitizedPayloads;
if (resolvedVerboseLevel === "on" && isNewSession) {
finalPayloads = [
{ text: `🧭 New session: ${sessionIdFinal}` },
...payloadArray,
...finalPayloads,
];
}
+15 -2
View File
@@ -3,7 +3,12 @@ import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { createTargetViaCdp, evaluateJavaScript, snapshotAria } from "./cdp.js";
import {
createTargetViaCdp,
evaluateJavaScript,
normalizeCdpWsUrl,
snapshotAria,
} from "./cdp.js";
describe("cdp", () => {
let httpServer: ReturnType<typeof createServer> | null = null;
@@ -64,7 +69,7 @@ describe("cdp", () => {
const httpPort = (httpServer.address() as { port: number }).port;
const created = await createTargetViaCdp({
cdpPort: httpPort,
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://example.com",
});
@@ -159,4 +164,12 @@ describe("cdp", () => {
expect(snap.nodes[1]?.backendDOMNodeId).toBe(42);
expect(snap.nodes[1]?.depth).toBe(1);
});
it("normalizes loopback websocket URLs for remote CDP hosts", () => {
const normalized = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"http://example.com:9222",
);
expect(normalized).toBe("ws://example.com:9222/devtools/browser/ABC");
});
});
+30 -3
View File
@@ -18,6 +18,31 @@ type CdpSendFn = (
params?: Record<string, unknown>,
) => Promise<unknown>;
function isLoopbackHost(host: string) {
const h = host.trim().toLowerCase();
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "0.0.0.0" ||
h === "[::1]" ||
h === "::1" ||
h === "[::]" ||
h === "::"
);
}
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
const ws = new URL(wsUrl);
const cdp = new URL(cdpUrl);
if (isLoopbackHost(ws.hostname) && !isLoopbackHost(cdp.hostname)) {
ws.hostname = cdp.hostname;
const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80");
if (cdpPort) ws.port = cdpPort;
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
}
return ws.toString();
}
function createCdpSender(ws: WebSocket) {
let nextId = 1;
const pending = new Map<number, Pending>();
@@ -165,14 +190,16 @@ export async function captureScreenshot(opts: {
}
export async function createTargetViaCdp(opts: {
cdpPort: number;
cdpUrl: string;
url: string;
}): Promise<{ targetId: string }> {
const base = opts.cdpUrl.replace(/\/$/, "");
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
`http://127.0.0.1:${opts.cdpPort}/json/version`,
`${base}/json/version`,
1500,
);
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
return await withCdpSocket(wsUrl, async (send) => {
+17 -5
View File
@@ -158,18 +158,30 @@ describe("browser chrome helpers", () => {
it("reports reachability based on /json/version", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: true } as unknown as Response),
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
} as unknown as Response),
);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(
true,
);
await expect(isChromeReachable(12345, 50)).resolves.toBe(true);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false } as unknown as Response),
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
} as unknown as Response),
);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(
false,
);
await expect(isChromeReachable(12345, 50)).resolves.toBe(false);
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
await expect(isChromeReachable(12345, 50)).resolves.toBe(false);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(
false,
);
});
it("stopClawdChrome no-ops when process is already killed", async () => {
+128 -10
View File
@@ -2,10 +2,12 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import WebSocket from "ws";
import { ensurePortAvailable } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { CONFIG_DIR } from "../utils.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
@@ -15,7 +17,7 @@ import {
const log = createSubsystemLogger("browser").child("chrome");
export type BrowserExecutable = {
kind: "canary" | "chromium" | "chrome";
kind: "canary" | "chromium" | "chrome" | "custom";
path: string;
};
@@ -80,6 +82,40 @@ export function findChromeExecutableMac(): BrowserExecutable | null {
return null;
}
export function findChromeExecutableLinux(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{ kind: "chrome", path: "/usr/bin/google-chrome" },
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
{ kind: "chromium", path: "/usr/bin/chromium" },
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
{ kind: "chromium", path: "/snap/bin/chromium" },
{ kind: "chrome", path: "/usr/bin/chrome" },
];
for (const candidate of candidates) {
if (exists(candidate.path)) return candidate;
}
return null;
}
function resolveBrowserExecutable(
resolved: ResolvedBrowserConfig,
): BrowserExecutable | null {
if (resolved.executablePath) {
if (!exists(resolved.executablePath)) {
throw new Error(
`browser.executablePath not found: ${resolved.executablePath}`,
);
}
return { kind: "custom", path: resolved.executablePath };
}
if (process.platform === "darwin") return findChromeExecutableMac();
if (process.platform === "linux") return findChromeExecutableLinux();
return null;
}
export function resolveClawdUserDataDir() {
return path.join(
CONFIG_DIR,
@@ -111,6 +147,10 @@ function safeWriteJson(filePath: string, data: Record<string, unknown>) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
function cdpUrlForPort(cdpPort: number) {
return `http://127.0.0.1:${cdpPort}`;
}
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
let node: Record<string, unknown> = obj;
for (const key of keys.slice(0, -1)) {
@@ -303,32 +343,103 @@ export function decorateClawdProfile(
}
export async function isChromeReachable(
cdpPort: number,
cdpUrl: string,
timeoutMs = 500,
): Promise<boolean> {
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
return Boolean(version);
}
type ChromeVersion = {
webSocketDebuggerUrl?: string;
Browser?: string;
"User-Agent"?: string;
};
async function fetchChromeVersion(
cdpUrl: string,
timeoutMs = 500,
): Promise<ChromeVersion | null> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
const base = cdpUrl.replace(/\/$/, "");
const res = await fetch(`${base}/json/version`, {
signal: ctrl.signal,
});
return res.ok;
if (!res.ok) return null;
const data = (await res.json()) as ChromeVersion;
if (!data || typeof data !== "object") return null;
return data;
} catch {
return false;
return null;
} finally {
clearTimeout(t);
}
}
export async function getChromeWebSocketUrl(
cdpUrl: string,
timeoutMs = 500,
): Promise<string | null> {
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
if (!wsUrl) return null;
return normalizeCdpWsUrl(wsUrl, cdpUrl);
}
async function canOpenWebSocket(
wsUrl: string,
timeoutMs = 800,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs });
const timer = setTimeout(
() => {
try {
ws.terminate();
} catch {
// ignore
}
resolve(false);
},
Math.max(50, timeoutMs + 25),
);
ws.once("open", () => {
clearTimeout(timer);
try {
ws.close();
} catch {
// ignore
}
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
}
export async function isChromeCdpReady(
cdpUrl: string,
timeoutMs = 500,
handshakeTimeoutMs = 800,
): Promise<boolean> {
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs);
if (!wsUrl) return false;
return await canOpenWebSocket(wsUrl, handshakeTimeoutMs);
}
export async function launchClawdChrome(
resolved: ResolvedBrowserConfig,
): Promise<RunningChrome> {
await ensurePortAvailable(resolved.cdpPort);
const exe = process.platform === "darwin" ? findChromeExecutableMac() : null;
const exe = resolveBrowserExecutable(resolved);
if (!exe) {
throw new Error(
"No supported browser found (Chrome Canary/Chromium/Chrome on macOS).",
"No supported browser found (Chrome/Chromium on macOS or Linux).",
);
}
@@ -360,6 +471,13 @@ export async function launchClawdChrome(
args.push("--headless=new");
args.push("--disable-gpu");
}
if (resolved.noSandbox) {
args.push("--no-sandbox");
args.push("--disable-setuid-sandbox");
}
if (process.platform === "linux") {
args.push("--disable-dev-shm-usage");
}
// Always open a blank tab to ensure a target exists.
args.push("about:blank");
@@ -414,11 +532,11 @@ export async function launchClawdChrome(
// Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(resolved.cdpPort, 500)) break;
if (await isChromeReachable(resolved.cdpUrl, 500)) break;
await new Promise((r) => setTimeout(r, 200));
}
if (!(await isChromeReachable(resolved.cdpPort, 500))) {
if (!(await isChromeReachable(resolved.cdpUrl, 500))) {
try {
proc.kill("SIGKILL");
} catch {
@@ -457,7 +575,7 @@ export async function stopClawdChrome(
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!proc.exitCode && proc.killed) break;
if (!(await isChromeReachable(running.cdpPort, 200))) return;
if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), 200))) return;
await new Promise((r) => setTimeout(r, 100));
}
+3
View File
@@ -169,10 +169,13 @@ describe("browser client", () => {
running: true,
pid: 1,
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
chosenBrowser: "chrome",
userDataDir: "/tmp",
color: "#FF4500",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: false,
}),
} as unknown as Response;
+24
View File
@@ -6,15 +6,27 @@ export type BrowserStatus = {
enabled: boolean;
controlUrl: string;
running: boolean;
cdpReady?: boolean;
cdpHttp?: boolean;
pid: number | null;
cdpPort: number;
cdpUrl?: string;
chosenBrowser: string | null;
userDataDir: string | null;
color: string;
headless: boolean;
noSandbox?: boolean;
executablePath?: string | null;
attachOnly: boolean;
};
export type BrowserResetProfileResult = {
ok: true;
moved: boolean;
from: string;
to?: string;
};
export type BrowserTab = {
targetId: string;
title: string;
@@ -75,6 +87,18 @@ export async function browserStop(baseUrl: string): Promise<void> {
});
}
export async function browserResetProfile(
baseUrl: string,
): Promise<BrowserResetProfileResult> {
return await fetchBrowserJson<BrowserResetProfileResult>(
`${baseUrl}/reset-profile`,
{
method: "POST",
timeoutMs: 20000,
},
);
}
export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
`${baseUrl}/tabs`,
+12
View File
@@ -10,6 +10,7 @@ describe("browser config", () => {
expect(resolved.enabled).toBe(true);
expect(resolved.controlPort).toBe(18791);
expect(resolved.cdpPort).toBe(18792);
expect(resolved.cdpUrl).toBe("http://127.0.0.1:18792");
expect(resolved.controlHost).toBe("127.0.0.1");
expect(resolved.color).toBe("#FF4500");
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
@@ -44,6 +45,17 @@ describe("browser config", () => {
});
expect(resolved.controlPort).toBe(19000);
expect(resolved.cdpPort).toBe(19001);
expect(resolved.cdpUrl).toBe("http://127.0.0.1:19001");
});
it("supports explicit CDP URLs", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
cdpUrl: "http://example.com:9222",
});
expect(resolved.cdpPort).toBe(9222);
expect(resolved.cdpUrl).toBe("http://example.com:9222");
expect(resolved.cdpIsLoopback).toBe(false);
});
it("rejects unsupported protocols", () => {
+71 -24
View File
@@ -10,15 +10,28 @@ export type ResolvedBrowserConfig = {
controlUrl: string;
controlHost: string;
controlPort: number;
cdpUrl: string;
cdpHost: string;
cdpPort: number;
cdpIsLoopback: boolean;
color: string;
executablePath?: string;
headless: boolean;
noSandbox: boolean;
attachOnly: boolean;
};
function isLoopbackHost(host: string) {
const h = host.trim().toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1";
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "0.0.0.0" ||
h === "[::1]" ||
h === "::1" ||
h === "[::]" ||
h === "::"
);
}
function normalizeHexColor(raw: string | undefined) {
@@ -29,17 +42,12 @@ function normalizeHexColor(raw: string | undefined) {
return normalized.toUpperCase();
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
const controlUrl = (
cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL
).trim();
const parsed = new URL(controlUrl);
function parseHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`browser.controlUrl must be http(s), got: ${parsed.protocol.replace(":", "")}`,
`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`,
);
}
@@ -51,32 +59,71 @@ export function resolveBrowserConfig(
: 80;
if (Number.isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`browser.controlUrl has invalid port: ${parsed.port}`);
throw new Error(`${label} has invalid port: ${parsed.port}`);
}
const cdpPort = port + 1;
if (cdpPort > 65535) {
throw new Error(
`browser.controlUrl port (${port}) is too high; cannot derive CDP port (${cdpPort})`,
);
}
if (port === cdpPort) {
throw new Error(
`browser.controlUrl port (${port}) must not equal CDP port (${cdpPort})`,
);
return {
parsed,
port,
normalized: parsed.toString().replace(/\/$/, ""),
};
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
const controlInfo = parseHttpUrl(
cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL,
"browser.controlUrl",
);
const controlPort = controlInfo.port;
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
let cdpInfo:
| {
parsed: URL;
port: number;
normalized: string;
}
| undefined;
if (rawCdpUrl) {
cdpInfo = parseHttpUrl(rawCdpUrl, "browser.cdpUrl");
} else {
const derivedPort = controlPort + 1;
if (derivedPort > 65535) {
throw new Error(
`browser.controlUrl port (${controlPort}) is too high; cannot derive CDP port (${derivedPort})`,
);
}
const derived = new URL(controlInfo.normalized);
derived.port = String(derivedPort);
cdpInfo = {
parsed: derived,
port: derivedPort,
normalized: derived.toString().replace(/\/$/, ""),
};
}
const cdpPort = cdpInfo.port;
const headless = cfg?.headless === true;
const noSandbox = cfg?.noSandbox === true;
const attachOnly = cfg?.attachOnly === true;
const executablePath = cfg?.executablePath?.trim() || undefined;
return {
enabled,
controlUrl: parsed.toString().replace(/\/$/, ""),
controlHost: parsed.hostname,
controlPort: port,
controlUrl: controlInfo.normalized,
controlHost: controlInfo.parsed.hostname,
controlPort,
cdpUrl: cdpInfo.normalized,
cdpHost: cdpInfo.parsed.hostname,
cdpPort,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
color: normalizeHexColor(cfg?.color),
executablePath,
headless,
noSandbox,
attachOnly,
};
}
+11 -5
View File
@@ -83,7 +83,7 @@ describe("pw-ai", () => {
const mod = await importModule();
const res = await mod.snapshotAiViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
targetId: "T2",
});
@@ -102,7 +102,7 @@ describe("pw-ai", () => {
const mod = await importModule();
await mod.clickViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "76",
});
@@ -121,7 +121,10 @@ describe("pw-ai", () => {
const mod = await importModule();
await expect(
mod.snapshotAiViaPlaywright({ cdpPort: 18792, targetId: "T1" }),
mod.snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
}),
).rejects.toThrow(/_snapshotForAI/i);
});
@@ -133,9 +136,12 @@ describe("pw-ai", () => {
connect.mockResolvedValue(browser);
const mod = await importModule();
await mod.snapshotAiViaPlaywright({ cdpPort: 18792, targetId: "T1" });
await mod.snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
});
await mod.clickViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
});
+45 -22
View File
@@ -5,6 +5,8 @@ import type {
Page,
} from "playwright-core";
import { chromium } from "playwright-core";
import { formatErrorMessage } from "../infra/errors.js";
import { getChromeWebSocketUrl } from "./chrome.js";
export type BrowserConsoleMessage = {
type: string;
@@ -30,7 +32,7 @@ type TargetInfoResponse = {
type ConnectedBrowser = {
browser: Browser;
endpoint: string;
cdpUrl: string;
};
type PageState = {
@@ -48,8 +50,8 @@ const MAX_CONSOLE_MESSAGES = 500;
let cached: ConnectedBrowser | null = null;
let connecting: Promise<ConnectedBrowser> | null = null;
function endpointForCdpPort(cdpPort: number) {
return `http://127.0.0.1:${cdpPort}`;
function normalizeCdpUrl(raw: string) {
return raw.replace(/\/$/, "");
}
export function ensurePageState(page: Page): PageState {
@@ -96,24 +98,46 @@ function observeBrowser(browser: Browser) {
for (const context of browser.contexts()) observeContext(context);
}
async function connectBrowser(endpoint: string): Promise<ConnectedBrowser> {
if (cached?.endpoint === endpoint) return cached;
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
const normalized = normalizeCdpUrl(cdpUrl);
if (cached?.cdpUrl === normalized) return cached;
if (connecting) return await connecting;
connecting = chromium
.connectOverCDP(endpoint, { timeout: 5000 })
.then((browser) => {
const connected: ConnectedBrowser = { browser, endpoint };
cached = connected;
observeBrowser(browser);
browser.on("disconnected", () => {
if (cached?.browser === browser) cached = null;
});
return connected;
})
.finally(() => {
connecting = null;
});
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
let lastErr: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const timeout = 5000 + attempt * 2000;
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(
() => null,
);
const endpoint = wsUrl ?? normalized;
const browser = await chromium.connectOverCDP(endpoint, { timeout });
const connected: ConnectedBrowser = { browser, cdpUrl: normalized };
cached = connected;
observeBrowser(browser);
browser.on("disconnected", () => {
if (cached?.browser === browser) cached = null;
});
return connected;
} catch (err) {
lastErr = err;
const delay = 250 + attempt * 250;
await new Promise((r) => setTimeout(r, delay));
}
}
if (lastErr instanceof Error) {
throw lastErr;
}
const message = lastErr
? formatErrorMessage(lastErr)
: "CDP connect failed";
throw new Error(message);
};
connecting = connectWithRetry().finally(() => {
connecting = null;
});
return await connecting;
}
@@ -150,11 +174,10 @@ async function findPageByTargetId(
}
export async function getPageForTargetId(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
}): Promise<Page> {
const endpoint = endpointForCdpPort(opts.cdpPort);
const { browser } = await connectBrowser(endpoint);
const { browser } = await connectBrowser(opts.cdpUrl);
const pages = await getAllPages(browser);
if (!pages.length)
throw new Error("No pages available in the connected browser.");
+19 -10
View File
@@ -41,7 +41,7 @@ describe("pw-tools-core", () => {
const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
element: "#main",
type: "png",
@@ -65,7 +65,7 @@ describe("pw-tools-core", () => {
const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "76",
type: "jpeg",
@@ -89,7 +89,7 @@ describe("pw-tools-core", () => {
await expect(
mod.takeScreenshotViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
element: "#x",
fullPage: true,
@@ -98,7 +98,7 @@ describe("pw-tools-core", () => {
await expect(
mod.takeScreenshotViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
fullPage: true,
@@ -118,7 +118,7 @@ describe("pw-tools-core", () => {
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
paths: ["/tmp/a.txt"],
});
@@ -142,7 +142,10 @@ describe("pw-tools-core", () => {
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: [] });
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: [],
});
await Promise.resolve();
expect(fileChooser.setFiles).not.toHaveBeenCalled();
@@ -177,8 +180,14 @@ describe("pw-tools-core", () => {
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/1"] });
await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/2"] });
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: ["/tmp/1"],
});
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: ["/tmp/2"],
});
resolve1?.(fc1);
resolve2?.(fc2);
@@ -199,7 +208,7 @@ describe("pw-tools-core", () => {
const mod = await importModule();
await mod.armDialogViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
accept: true,
promptText: "x",
});
@@ -214,7 +223,7 @@ describe("pw-tools-core", () => {
waitForEvent.mockClear();
await mod.armDialogViaPlaywright({
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
accept: false,
});
await Promise.resolve();
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more