Compare commits

..
Author SHA1 Message Date
Peter Steinberger 1528a88ec3 style: format gemini schema cleanup 2026-01-03 13:57:14 +01:00
Peter Steinberger 85bccff3fb docs: update changelog for #126 2026-01-03 13:56:40 +01:00
Peter Steinberger b30127c2ba fix: restore discord surface helpers 2026-01-03 13:56:15 +01:00
Peter Steinberger 133889a2f6 fix: scope gemini tool schemas 2026-01-03 13:54:44 +01:00
Jake d42f31c2a5 Agents: Fix Gemini schema compatibility and robust model discovery 2026-01-03 20:43:32 +13: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
165 changed files with 10062 additions and 677 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
+48 -1
View File
@@ -14,22 +14,64 @@
- 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.
- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.
- 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
- Agents: clean Gemini tool schemas and avoid caching empty model discovery results (#126) — thanks @mcinteerj
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
- Auto-reply: suppress stray `HEARTBEAT_OK` acks so they never get delivered as messages.
- 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 +84,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 +103,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 +118,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
+10 -1
View File
@@ -11,6 +11,7 @@
<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>
@@ -19,6 +20,10 @@ It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, Web
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, WebChat, macOS, iOS. Signal support via `signal-cli` (see `docs/signal.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)
@@ -142,10 +170,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 +184,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 +373,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(
+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)
+41 -2
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`
@@ -162,7 +178,8 @@ 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
}
}
```
@@ -446,6 +463,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 +472,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 +529,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.
+4 -1
View File
@@ -23,6 +23,7 @@ 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.
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 +46,8 @@ Note: Discord does not provide a simple username → id lookup without extra gui
users: ["987654321098765432"]
},
requireMention: true,
mediaMaxMb: 8
mediaMaxMb: 8,
historyLimit: 20
}
}
```
@@ -54,6 +56,7 @@ 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).
## 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.
+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.
+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).
+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:
+150
View File
@@ -0,0 +1,150 @@
---
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.
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.*`
- `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`
- 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.
+11 -1
View File
@@ -58,8 +58,18 @@ export async function loadModelCatalog(params?: {
: undefined;
models.push({ id, name, provider, contextWindow });
}
if (models.length === 0) {
// If we found nothing, don't cache this result so we can try again.
modelCatalogPromise = null;
}
if (models.length === 0) {
// If we found nothing, don't cache this result so we can try again.
modelCatalogPromise = null;
}
} catch {
// Leave models empty on discovery errors.
// Leave models empty on discovery errors and don't cache.
modelCatalogPromise = null;
}
return models.sort((a, b) => {
+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,
+3 -4
View File
@@ -408,6 +408,8 @@ export async function runEmbeddedPiAgent(params: {
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdisCodingTools({
bash: params.config?.agent?.bash,
provider,
surface: params.surface,
});
const machineName = await getMachineDisplayName();
const runtimeInfo = {
@@ -433,10 +435,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,
+44
View File
@@ -72,6 +72,50 @@ describe("createClawdisCodingTools", () => {
}
});
it("keeps anyOf variants for non-Gemini providers", () => {
const tools = createClawdisCodingTools({ provider: "openai" });
const browser = tools.find((tool) => tool.name === "clawdis_browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: Array<{ properties?: Record<string, unknown> }>;
};
expect(Array.isArray(parameters.anyOf)).toBe(true);
expect(parameters.anyOf?.length ?? 0).toBeGreaterThan(0);
const hasConst = parameters.anyOf?.some((variant) => {
const action = variant.properties?.action as
| { const?: unknown }
| undefined;
return typeof action?.const === "string";
});
expect(hasConst).toBe(true);
});
it("strips anyOf for google-gemini-cli tools", () => {
const tools = createClawdisCodingTools({ provider: "google-gemini-cli" });
const browser = tools.find((tool) => tool.name === "clawdis_browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: unknown;
properties?: Record<string, unknown>;
required?: string[];
};
expect(parameters.anyOf).toBeUndefined();
expect(parameters.properties?.action).toBeDefined();
expect(parameters.required ?? []).toContain("action");
});
it("strips anyOf for google-antigravity tools", () => {
const tools = createClawdisCodingTools({ provider: "google-antigravity" });
const browser = tools.find((tool) => tool.name === "clawdis_browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: unknown;
properties?: Record<string, unknown>;
};
expect(parameters.anyOf).toBeUndefined();
expect(parameters.properties?.action).toBeDefined();
});
it("includes bash and process tools", () => {
const tools = createClawdisCodingTools();
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
+111 -16
View File
@@ -141,13 +141,80 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
return existing;
}
function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
// Gemini tool schemas accept an OpenAPI-ish subset; strip unsupported bits.
function cleanSchemaForGemini(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
const obj = schema as Record<string, unknown>;
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (key === "patternProperties") {
continue;
}
if (key === "const") {
cleaned.enum = [value];
continue;
}
if (key === "type" && hasAnyOf) {
continue;
}
if (key === "properties" && value && typeof value === "object") {
const props = value as Record<string, unknown>;
cleaned[key] = Object.fromEntries(
Object.entries(props).map(([k, v]) => [k, cleanSchemaForGemini(v)]),
);
} else if (key === "items" && value && typeof value === "object") {
cleaned[key] = cleanSchemaForGemini(value);
} else if (key === "anyOf" && Array.isArray(value)) {
cleaned[key] = value.map((v) => cleanSchemaForGemini(v));
} else if (key === "oneOf" && Array.isArray(value)) {
cleaned[key] = value.map((v) => cleanSchemaForGemini(v));
} else if (key === "allOf" && Array.isArray(value)) {
cleaned[key] = value.map((v) => cleanSchemaForGemini(v));
} else if (
key === "additionalProperties" &&
value &&
typeof value === "object"
) {
cleaned[key] = cleanSchemaForGemini(value);
} else {
cleaned[key] = value;
}
}
return cleaned;
}
// Only Gemini providers need schema cleanup; other providers can keep richer JSON Schema.
function shouldCleanSchemaForGemini(provider?: string): boolean {
return provider === "google-gemini-cli" || provider === "google-antigravity";
}
function normalizeToolParameters(
tool: AnyAgentTool,
options?: { cleanForGemini?: boolean },
): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: undefined;
if (!schema) return tool;
if ("type" in schema && "properties" in schema) return tool;
const cleanForGemini = options?.cleanForGemini === true;
if (
"type" in schema &&
"properties" in schema &&
!Array.isArray(schema.anyOf)
) {
return cleanForGemini
? { ...tool, parameters: cleanSchemaForGemini(schema) }
: tool;
}
if (!Array.isArray(schema.anyOf)) return tool;
const mergedProperties: Record<string, unknown> = {};
const requiredCounts = new Map<string, number>();
@@ -191,21 +258,29 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
.map(([key]) => key)
: undefined;
const mergedSchema = {
...schema,
type: "object",
properties:
Object.keys(mergedProperties).length > 0
? mergedProperties
: (schema.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0
? { required: mergedRequired }
: {}),
additionalProperties:
"additionalProperties" in schema ? schema.additionalProperties : true,
};
// Preserve anyOf for non-Gemini providers; Gemini rejects top-level anyOf.
return {
...tool,
parameters: {
...schema,
type: "object",
properties:
Object.keys(mergedProperties).length > 0
? mergedProperties
: (schema.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0
? { required: mergedRequired }
: {}),
additionalProperties:
"additionalProperties" in schema ? schema.additionalProperties : true,
},
parameters: cleanForGemini
? (() => {
const { anyOf: _unusedAnyOf, ...rest } = mergedSchema;
return cleanSchemaForGemini(rest);
})()
: mergedSchema,
};
}
@@ -294,8 +369,21 @@ function createClawdisReadTool(base: AnyAgentTool): AnyAgentTool {
};
}
function normalizeSurface(surface?: string): string | undefined {
const trimmed = surface?.trim().toLowerCase();
return trimmed ? trimmed : undefined;
}
function shouldIncludeDiscordTool(surface?: string): boolean {
const normalized = normalizeSurface(surface);
if (!normalized) return false;
return normalized === "discord" || normalized.startsWith("discord:");
}
export function createClawdisCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
surface?: string;
provider?: string;
}): AnyAgentTool[] {
const bashToolName = "bash";
const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => {
@@ -314,5 +402,12 @@ export function createClawdisCodingTools(options?: {
createWhatsAppLoginTool(),
...createClawdisTools(),
];
return tools.map(normalizeToolParameters);
const allowDiscord = shouldIncludeDiscordTool(options?.surface);
const filtered = allowDiscord
? tools
: tools.filter((tool) => tool.name !== "discord");
const cleanForGemini = shouldCleanSchemaForGemini(options?.provider);
return filtered.map((tool) =>
normalizeToolParameters(tool, { cleanForGemini }),
);
}
+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;
}
+26
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,31 @@ 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("updates group activation when the owner sends /activation", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
+40 -4
View File
@@ -61,7 +61,7 @@ import {
type ThinkLevel,
type VerboseLevel,
} from "./thinking.js";
import { SILENT_REPLY_TOKEN } from "./tokens.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "./tokens.js";
import { isAudio, transcribeInboundAudio } from "./transcription.js";
import type { GetReplyOptions, ReplyPayload } from "./types.js";
@@ -886,10 +886,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,6 +1190,7 @@ export async function getReplyFromConfig(
return undefined;
}
let suppressedByHeartbeatAck = false;
try {
if (shouldEagerType) {
await startTypingLoop();
@@ -1216,6 +1217,17 @@ export async function getReplyFromConfig(
runId,
onPartialReply: opts?.onPartialReply
? async (payload) => {
if (
!opts?.isHeartbeat &&
payload.text?.includes(HEARTBEAT_TOKEN)
) {
suppressedByHeartbeatAck = true;
logVerbose(
"Suppressing partial reply: detected HEARTBEAT_OK token",
);
return;
}
if (suppressedByHeartbeatAck) return;
await startTypingOnText(payload.text);
await opts.onPartialReply?.({
text: payload.text,
@@ -1226,6 +1238,17 @@ export async function getReplyFromConfig(
shouldEmitToolResult,
onToolResult: opts?.onToolResult
? async (payload) => {
if (
!opts?.isHeartbeat &&
payload.text?.includes(HEARTBEAT_TOKEN)
) {
suppressedByHeartbeatAck = true;
logVerbose(
"Suppressing tool result: detected HEARTBEAT_OK token",
);
return;
}
if (suppressedByHeartbeatAck) return;
await startTypingOnText(payload.text);
await opts.onToolResult?.({
text: payload.text,
@@ -1261,9 +1284,22 @@ export async function getReplyFromConfig(
const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return undefined;
if (
suppressedByHeartbeatAck ||
(!opts?.isHeartbeat &&
payloadArray.some((payload) => payload.text?.includes(HEARTBEAT_TOKEN)))
) {
logVerbose("Suppressing reply: detected HEARTBEAT_OK token");
return undefined;
}
const shouldSignalTyping = payloadArray.some((payload) => {
const trimmed = payload.text?.trim();
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
if (
trimmed &&
trimmed !== SILENT_REPLY_TOKEN &&
!trimmed.includes(HEARTBEAT_TOKEN)
)
return true;
if (payload.mediaUrl) return true;
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
return false;
+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();
+22 -22
View File
@@ -17,12 +17,12 @@ function requireRef(value: unknown): string {
}
export async function snapshotAiViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
timeoutMs?: number;
}): Promise<{ snapshot: string }> {
const page = await getPageForTargetId({
cdpPort: opts.cdpPort,
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
@@ -45,7 +45,7 @@ export async function snapshotAiViaPlaywright(opts: {
}
export async function clickViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
ref: string;
doubleClick?: boolean;
@@ -54,7 +54,7 @@ export async function clickViaPlaywright(opts: {
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId({
cdpPort: opts.cdpPort,
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
@@ -79,7 +79,7 @@ export async function clickViaPlaywright(opts: {
}
export async function hoverViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
ref: string;
timeoutMs?: number;
@@ -94,7 +94,7 @@ export async function hoverViaPlaywright(opts: {
}
export async function dragViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
startRef: string;
endRef: string;
@@ -111,7 +111,7 @@ export async function dragViaPlaywright(opts: {
}
export async function selectOptionViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
ref: string;
values: string[];
@@ -128,7 +128,7 @@ export async function selectOptionViaPlaywright(opts: {
}
export async function pressKeyViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
key: string;
delayMs?: number;
@@ -143,7 +143,7 @@ export async function pressKeyViaPlaywright(opts: {
}
export async function typeViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
ref: string;
text: string;
@@ -168,7 +168,7 @@ export async function typeViaPlaywright(opts: {
}
export async function fillFormViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
fields: BrowserFormField[];
}): Promise<void> {
@@ -200,7 +200,7 @@ export async function fillFormViaPlaywright(opts: {
}
export async function evaluateViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
fn: string;
ref?: string;
@@ -257,7 +257,7 @@ export async function evaluateViaPlaywright(opts: {
}
export async function armFileUploadViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
paths?: string[];
timeoutMs?: number;
@@ -286,7 +286,7 @@ export async function armFileUploadViaPlaywright(opts: {
try {
const input =
typeof fileChooser.element === "function"
? await fileChooser.element()
? await Promise.resolve(fileChooser.element())
: null;
if (input) {
await input.evaluate((el) => {
@@ -304,7 +304,7 @@ export async function armFileUploadViaPlaywright(opts: {
}
export async function setInputFilesViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
inputRef?: string;
element?: string;
@@ -342,7 +342,7 @@ export async function setInputFilesViaPlaywright(opts: {
}
export async function armDialogViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
accept: boolean;
promptText?: string;
@@ -368,7 +368,7 @@ export async function armDialogViaPlaywright(opts: {
}
export async function navigateViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
url: string;
timeoutMs?: number;
@@ -384,7 +384,7 @@ export async function navigateViaPlaywright(opts: {
}
export async function waitForViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
timeMs?: number;
text?: string;
@@ -417,7 +417,7 @@ export async function waitForViaPlaywright(opts: {
}
export async function takeScreenshotViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
ref?: string;
element?: string;
@@ -449,7 +449,7 @@ export async function takeScreenshotViaPlaywright(opts: {
}
export async function resizeViewportViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
width: number;
height: number;
@@ -463,7 +463,7 @@ export async function resizeViewportViaPlaywright(opts: {
}
export async function closePageViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
@@ -472,7 +472,7 @@ export async function closePageViaPlaywright(opts: {
}
export async function pdfViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
}): Promise<{ buffer: Buffer }> {
const page = await getPageForTargetId(opts);
@@ -498,7 +498,7 @@ function consolePriority(level: string) {
}
export async function getConsoleMessagesViaPlaywright(opts: {
cdpPort: number;
cdpUrl: string;
targetId?: string;
level?: string;
}): Promise<BrowserConsoleMessage[]> {
+21 -21
View File
@@ -109,7 +109,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "navigate");
if (!pw) return;
const result = await pw.navigateViaPlaywright({
cdpPort: ctx.state().cdpPort,
cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId,
url,
});
@@ -145,7 +145,7 @@ export function registerBrowserAgentRoutes(
try {
const tab = await ctx.ensureTabAvailable(targetId);
const cdpPort = ctx.state().cdpPort;
const cdpUrl = ctx.state().resolved.cdpUrl;
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) return;
@@ -180,7 +180,7 @@ export function registerBrowserAgentRoutes(
? (modifiersRaw as ClickModifier[])
: undefined;
const clickRequest: Parameters<typeof pw.clickViaPlaywright>[0] = {
cdpPort,
cdpUrl,
targetId: tab.targetId,
ref,
doubleClick,
@@ -199,7 +199,7 @@ export function registerBrowserAgentRoutes(
const submit = toBoolean(body.submit) ?? false;
const slowly = toBoolean(body.slowly) ?? false;
const typeRequest: Parameters<typeof pw.typeViaPlaywright>[0] = {
cdpPort,
cdpUrl,
targetId: tab.targetId,
ref,
text,
@@ -213,7 +213,7 @@ export function registerBrowserAgentRoutes(
const key = toStringOrEmpty(body.key);
if (!key) return jsonError(res, 400, "key is required");
await pw.pressKeyViaPlaywright({
cdpPort,
cdpUrl,
targetId: tab.targetId,
key,
});
@@ -222,7 +222,7 @@ export function registerBrowserAgentRoutes(
case "hover": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
await pw.hoverViaPlaywright({ cdpPort, targetId: tab.targetId, ref });
await pw.hoverViaPlaywright({ cdpUrl, targetId: tab.targetId, ref });
return res.json({ ok: true, targetId: tab.targetId });
}
case "drag": {
@@ -231,7 +231,7 @@ export function registerBrowserAgentRoutes(
if (!startRef || !endRef)
return jsonError(res, 400, "startRef and endRef are required");
await pw.dragViaPlaywright({
cdpPort,
cdpUrl,
targetId: tab.targetId,
startRef,
endRef,
@@ -244,7 +244,7 @@ export function registerBrowserAgentRoutes(
if (!ref || !values?.length)
return jsonError(res, 400, "ref and values are required");
await pw.selectOptionViaPlaywright({
cdpPort,
cdpUrl,
targetId: tab.targetId,
ref,
values,
@@ -273,7 +273,7 @@ export function registerBrowserAgentRoutes(
.filter((field): field is BrowserFormField => field !== null);
if (!fields.length) return jsonError(res, 400, "fields are required");
await pw.fillFormViaPlaywright({
cdpPort,
cdpUrl,
targetId: tab.targetId,
fields,
});
@@ -285,7 +285,7 @@ export function registerBrowserAgentRoutes(
if (!width || !height)
return jsonError(res, 400, "width and height are required");
await pw.resizeViewportViaPlaywright({
cdpPort,
cdpUrl,
targetId: tab.targetId,
width,
height,
@@ -297,7 +297,7 @@ export function registerBrowserAgentRoutes(
const text = toStringOrEmpty(body.text) || undefined;
const textGone = toStringOrEmpty(body.textGone) || undefined;
await pw.waitForViaPlaywright({
cdpPort,
cdpUrl,
targetId: tab.targetId,
timeMs,
text,
@@ -310,7 +310,7 @@ export function registerBrowserAgentRoutes(
if (!fn) return jsonError(res, 400, "fn is required");
const ref = toStringOrEmpty(body.ref) || undefined;
const result = await pw.evaluateViaPlaywright({
cdpPort,
cdpUrl,
targetId: tab.targetId,
fn,
ref,
@@ -323,7 +323,7 @@ export function registerBrowserAgentRoutes(
});
}
case "close": {
await pw.closePageViaPlaywright({ cdpPort, targetId: tab.targetId });
await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId });
return res.json({ ok: true, targetId: tab.targetId });
}
default: {
@@ -357,7 +357,7 @@ export function registerBrowserAgentRoutes(
);
}
await pw.setInputFilesViaPlaywright({
cdpPort: ctx.state().cdpPort,
cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId,
inputRef,
element,
@@ -365,14 +365,14 @@ export function registerBrowserAgentRoutes(
});
} else {
await pw.armFileUploadViaPlaywright({
cdpPort: ctx.state().cdpPort,
cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId,
paths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
cdpPort: ctx.state().cdpPort,
cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId,
ref,
});
@@ -396,7 +396,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "dialog hook");
if (!pw) return;
await pw.armDialogViaPlaywright({
cdpPort: ctx.state().cdpPort,
cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId,
accept,
promptText,
@@ -418,7 +418,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "console messages");
if (!pw) return;
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpPort: ctx.state().cdpPort,
cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId,
level: level.trim() || undefined,
});
@@ -436,7 +436,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "pdf");
if (!pw) return;
const pdf = await pw.pdfViaPlaywright({
cdpPort: ctx.state().cdpPort,
cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId,
});
await ensureMediaDir();
@@ -480,7 +480,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "element/ref screenshot");
if (!pw) return;
const snap = await pw.takeScreenshotViaPlaywright({
cdpPort: ctx.state().cdpPort,
cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId,
ref,
element,
@@ -539,7 +539,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) return;
const snap = await pw.snapshotAiViaPlaywright({
cdpPort: ctx.state().cdpPort,
cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId,
});
return res.json({
+19 -2
View File
@@ -15,17 +15,25 @@ export function registerBrowserBasicRoutes(
return jsonError(res, 503, "browser server not started");
}
const reachable = await ctx.isReachable(300);
const [cdpHttp, cdpReady] = await Promise.all([
ctx.isHttpReachable(300),
ctx.isReachable(600),
]);
res.json({
enabled: current.resolved.enabled,
controlUrl: current.resolved.controlUrl,
running: reachable,
running: cdpReady,
cdpReady,
cdpHttp,
pid: current.running?.pid ?? null,
cdpPort: current.cdpPort,
cdpUrl: current.resolved.cdpUrl,
chosenBrowser: current.running?.exe.kind ?? null,
userDataDir: current.running?.userDataDir ?? null,
color: current.resolved.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: current.resolved.attachOnly,
});
});
@@ -47,4 +55,13 @@ export function registerBrowserBasicRoutes(
jsonError(res, 500, String(err));
}
});
app.post("/reset-profile", async (_req, res) => {
try {
const result = await ctx.resetProfile();
res.json({ ok: true, ...result });
} catch (err) {
jsonError(res, 500, String(err));
}
});
}
+147 -23
View File
@@ -1,10 +1,16 @@
import fs from "node:fs";
import type { Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { createTargetViaCdp } from "./cdp.js";
import { runExec } from "../process/exec.js";
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import {
isChromeCdpReady,
isChromeReachable,
launchClawdChrome,
type RunningChrome,
resolveClawdUserDataDir,
stopClawdChrome,
} from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
@@ -30,12 +36,18 @@ export type BrowserRouteContext = {
state: () => BrowserServerState;
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
resetProfile: () => Promise<{
moved: boolean;
from: string;
to?: string;
}>;
mapTabError: (err: unknown) => { status: number; message: string } | null;
};
@@ -86,6 +98,15 @@ export function createBrowserRouteContext(
const listTabs = async (): Promise<BrowserTab[]> => {
const current = state();
const base = current.resolved.cdpUrl;
const normalizeWsUrl = (raw?: string) => {
if (!raw) return undefined;
try {
return normalizeCdpWsUrl(raw, base);
} catch {
return raw;
}
};
const raw = await fetchJson<
Array<{
id?: string;
@@ -94,13 +115,13 @@ export function createBrowserRouteContext(
webSocketDebuggerUrl?: string;
type?: string;
}>
>(`http://127.0.0.1:${current.cdpPort}/json/list`);
>(`${base.replace(/\/$/, "")}/json/list`);
return raw
.map((t) => ({
targetId: t.id ?? "",
title: t.title ?? "",
url: t.url ?? "",
wsUrl: t.webSocketDebuggerUrl,
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl),
type: t.type,
}))
.filter((t) => Boolean(t.targetId));
@@ -109,7 +130,7 @@ export function createBrowserRouteContext(
const openTab = async (url: string): Promise<BrowserTab> => {
const current = state();
const createdViaCdp = await createTargetViaCdp({
cdpPort: current.cdpPort,
cdpUrl: current.resolved.cdpUrl,
url,
})
.then((r) => r.targetId)
@@ -136,7 +157,16 @@ export function createBrowserRouteContext(
type?: string;
};
const endpoint = `http://127.0.0.1:${current.cdpPort}/json/new?${encoded}`;
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const normalizeWsUrl = (raw?: string) => {
if (!raw) return undefined;
try {
return normalizeCdpWsUrl(raw, base);
} catch {
return raw;
}
};
const endpoint = `${base}/json/new?${encoded}`;
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
method: "PUT",
}).catch(async (err) => {
@@ -151,33 +181,77 @@ export function createBrowserRouteContext(
targetId: created.id,
title: created.title ?? "",
url: created.url ?? url,
wsUrl: created.webSocketDebuggerUrl,
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl),
type: created.type,
};
};
const isReachable = async (timeoutMs = 300) => {
const current = state();
return await isChromeReachable(current.cdpPort, timeoutMs);
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
return await isChromeCdpReady(
current.resolved.cdpUrl,
timeoutMs,
wsTimeout,
);
};
const isHttpReachable = async (timeoutMs = 300) => {
const current = state();
return await isChromeReachable(current.resolved.cdpUrl, timeoutMs);
};
const attachRunning = (running: RunningChrome) => {
opts.setRunning(running);
running.proc.on("exit", () => {
const live = opts.getState();
if (live?.running?.pid === running.pid) {
opts.setRunning(null);
}
});
};
const ensureBrowserAvailable = async (): Promise<void> => {
const current = state();
const remoteCdp = !current.resolved.cdpIsLoopback;
const httpReachable = await isHttpReachable();
if (!httpReachable) {
if (current.resolved.attachOnly || remoteCdp) {
throw new Error(
remoteCdp
? "Remote CDP is not reachable. Check browser.cdpUrl."
: "Browser attachOnly is enabled and no browser is running.",
);
}
const launched = await launchClawdChrome(current.resolved);
attachRunning(launched);
}
if (await isReachable()) return;
if (current.resolved.attachOnly) {
if (current.resolved.attachOnly || remoteCdp) {
throw new Error(
"Browser attachOnly is enabled and no browser is running.",
remoteCdp
? "Remote CDP websocket is not reachable. Check browser.cdpUrl."
: "Browser attachOnly is enabled and CDP websocket is not reachable.",
);
}
const launched = await launchClawdChrome(current.resolved);
opts.setRunning(launched);
launched.proc.on("exit", () => {
const live = opts.getState();
if (live?.running?.pid === launched.pid) {
opts.setRunning(null);
}
});
if (!current.running) {
throw new Error(
"CDP port responds but websocket handshake failed. Ensure the clawd browser owns the port or stop the conflicting process.",
);
}
await stopClawdChrome(current.running);
opts.setRunning(null);
const relaunched = await launchClawdChrome(current.resolved);
attachRunning(relaunched);
if (!(await isReachable(600))) {
throw new Error("Chrome CDP websocket is not reachable after restart.");
}
};
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
@@ -208,6 +282,7 @@ export function createBrowserRouteContext(
const focusTab = async (targetId: string): Promise<void> => {
const current = state();
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
@@ -216,13 +291,12 @@ export function createBrowserRouteContext(
}
throw new Error("tab not found");
}
await fetchOk(
`http://127.0.0.1:${current.cdpPort}/json/activate/${resolved.targetId}`,
);
await fetchOk(`${base}/json/activate/${resolved.targetId}`);
};
const closeTab = async (targetId: string): Promise<void> => {
const current = state();
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
@@ -231,9 +305,7 @@ export function createBrowserRouteContext(
}
throw new Error("tab not found");
}
await fetchOk(
`http://127.0.0.1:${current.cdpPort}/json/close/${resolved.targetId}`,
);
await fetchOk(`${base}/json/close/${resolved.targetId}`);
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
@@ -244,6 +316,39 @@ export function createBrowserRouteContext(
return { stopped: true };
};
const resetProfile = async () => {
const current = state();
if (!current.resolved.cdpIsLoopback) {
throw new Error("reset-profile is only supported for local browsers.");
}
const userDataDir = resolveClawdUserDataDir();
const httpReachable = await isHttpReachable(300);
if (httpReachable && !current.running) {
throw new Error(
"Browser appears to be running but is not owned by clawd. Stop it before resetting the profile.",
);
}
if (current.running) {
await stopRunningBrowser();
}
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
if (!fs.existsSync(userDataDir)) {
return { moved: false, from: userDataDir };
}
const moved = await movePathToTrash(userDataDir);
return { moved: true, from: userDataDir, to: moved };
};
const mapTabError = (err: unknown) => {
const msg = String(err);
if (msg.includes("ambiguous target id prefix")) {
@@ -259,12 +364,31 @@ export function createBrowserRouteContext(
state,
ensureBrowserAvailable,
ensureTabAvailable,
isHttpReachable,
isReachable,
listTabs,
openTab,
focusTab,
closeTab,
stopRunningBrowser,
resetProfile,
mapTabError,
};
}
async function movePathToTrash(targetPath: string): Promise<string> {
try {
await runExec("trash", [targetPath], { timeoutMs: 10_000 });
return targetPath;
} catch {
const trashDir = path.join(os.homedir(), ".Trash");
fs.mkdirSync(trashDir, { recursive: true });
const base = path.basename(targetPath);
let dest = path.join(trashDir, `${base}-${Date.now()}`);
if (fs.existsSync(dest)) {
dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`);
}
fs.renameSync(targetPath, dest);
return dest;
}
}
+26 -21
View File
@@ -4,6 +4,7 @@ import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let testPort = 0;
let cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
@@ -77,6 +78,7 @@ vi.mock("../config/config.js", () => ({
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchClawdChrome: vi.fn(async (resolved: { cdpPort: number }) => {
launchCalls.push({ port: resolved.cdpPort });
@@ -90,6 +92,7 @@ vi.mock("./chrome.js", () => ({
proc,
};
}),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {
reachable = false;
}),
@@ -97,6 +100,7 @@ vi.mock("./chrome.js", () => ({
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
}));
@@ -157,6 +161,7 @@ describe("browser control server", () => {
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
@@ -291,7 +296,7 @@ describe("browser control server", () => {
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
});
@@ -303,7 +308,7 @@ describe("browser control server", () => {
expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
url: "https://example.com",
});
@@ -320,7 +325,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
doubleClick: false,
@@ -349,7 +354,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
@@ -364,7 +369,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
});
@@ -376,7 +381,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
});
@@ -388,7 +393,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
@@ -401,7 +406,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(select.ok).toBe(true);
expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "5",
values: ["a", "b"],
@@ -417,7 +422,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(fill.ok).toBe(true);
expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
fields: [{ ref: "6", type: "textbox", value: "hello" }],
});
@@ -429,7 +434,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(resize.ok).toBe(true);
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
width: 800,
height: 600,
@@ -442,7 +447,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(wait.ok).toBe(true);
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
timeMs: 5,
text: undefined,
@@ -457,7 +462,7 @@ describe("browser control server", () => {
expect(evalRes.ok).toBe(true);
expect(evalRes.result).toBe("ok");
expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
fn: "() => 1",
ref: undefined,
@@ -470,7 +475,7 @@ describe("browser control server", () => {
}).then((r) => r.json());
expect(upload).toMatchObject({ ok: true });
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
paths: ["/tmp/a.txt"],
timeoutMs: 1234,
@@ -483,13 +488,13 @@ describe("browser control server", () => {
}).then((r) => r.json());
expect(uploadWithRef).toMatchObject({ ok: true });
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
paths: ["/tmp/b.txt"],
timeoutMs: undefined,
});
expect(pwMocks.clickViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "e12",
});
@@ -501,7 +506,7 @@ describe("browser control server", () => {
}).then((r) => r.json());
expect(uploadWithInputRef).toMatchObject({ ok: true });
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
inputRef: "e99",
element: undefined,
@@ -518,7 +523,7 @@ describe("browser control server", () => {
}).then((r) => r.json());
expect(uploadWithElement).toMatchObject({ ok: true });
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
inputRef: undefined,
element: "input[type=file]",
@@ -532,7 +537,7 @@ describe("browser control server", () => {
}).then((r) => r.json());
expect(dialog).toMatchObject({ ok: true });
expect(pwMocks.armDialogViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
accept: true,
promptText: undefined,
@@ -545,7 +550,7 @@ describe("browser control server", () => {
expect(consoleRes.ok).toBe(true);
expect(Array.isArray(consoleRes.messages)).toBe(true);
expect(pwMocks.getConsoleMessagesViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
level: "error",
});
@@ -566,7 +571,7 @@ describe("browser control server", () => {
expect(shot.ok).toBe(true);
expect(typeof shot.path).toBe("string");
expect(pwMocks.takeScreenshotViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: undefined,
element: "body",
@@ -581,7 +586,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean };
expect(close.ok).toBe(true);
expect(pwMocks.closePageViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
});
+1 -1
View File
@@ -1 +1 @@
c611d556d551748bf0bdae1d8a3f7d6055c061d92ef24060d89a430bfeeff6f8
13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36
+19 -15
View File
@@ -123,25 +123,29 @@
z-index: 3;
}
#clawdis-status .card {
text-align: center;
padding: 16px 18px;
border-radius: 14px;
background: rgba(18, 18, 22, 0.42);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
width: min(560px, 88vw);
text-align: left;
padding: 14px 16px 12px;
border-radius: 16px;
background:
linear-gradient(140deg, rgba(23, 24, 35, 0.78), rgba(18, 19, 28, 0.55));
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 16px 46px rgba(0,0,0,0.52), inset 0 1px 0 rgba(255,255,255,0.06);
-webkit-backdrop-filter: blur(18px) saturate(140%);
backdrop-filter: blur(18px) saturate(140%);
}
#clawdis-status .title {
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
letter-spacing: 0.2px;
color: rgba(255,255,255,0.92);
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
font: 600 12px/1.2 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
letter-spacing: 0.45px;
text-transform: uppercase;
color: rgba(255,255,255,0.7);
}
#clawdis-status .subtitle {
margin-top: 6px;
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: rgba(255,255,255,0.58);
margin-top: 8px;
font: 500 13px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: rgba(255,255,255,0.9);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
clawdis-a2ui-host {
display: block;
+28
View File
@@ -4,6 +4,7 @@ import {
browserCloseTab,
browserFocusTab,
browserOpenTab,
browserResetProfile,
browserStart,
browserStatus,
browserStop,
@@ -37,6 +38,7 @@ export function registerBrowserManageCommands(
`running: ${status.running}`,
`controlUrl: ${status.controlUrl}`,
`cdpPort: ${status.cdpPort}`,
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
`browser: ${status.chosenBrowser ?? "unknown"}`,
`profileColor: ${status.color}`,
].join("\n"),
@@ -87,6 +89,32 @@ export function registerBrowserManageCommands(
}
});
browser
.command("reset-profile")
.description("Reset clawd browser profile (moves it to Trash)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const result = await browserResetProfile(baseUrl);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
if (!result.moved) {
defaultRuntime.log(info("🦞 clawd browser profile already missing."));
return;
}
const dest = result.to ?? result.from;
defaultRuntime.log(
info(`🦞 clawd browser profile moved to Trash (${dest})`),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("tabs")
.description("List open tabs")
+4 -1
View File
@@ -67,7 +67,10 @@ type A2UIVersion = "v0.8" | "v0.9";
const canvasCallOpts = (cmd: Command) =>
cmd
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
.option(
"--url <url>",
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
)
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--json", "Output JSON", false);
+2 -2
View File
@@ -155,7 +155,7 @@ export function registerCronCli(program: Command) {
.option("--deliver", "Deliver agent output", false)
.option(
"--channel <channel>",
"Delivery channel (last|whatsapp|telegram|discord)",
"Delivery channel (last|whatsapp|telegram|discord|signal)",
"last",
)
.option(
@@ -414,7 +414,7 @@ export function registerCronCli(program: Command) {
.option("--deliver", "Deliver agent output", false)
.option(
"--channel <channel>",
"Delivery channel (last|whatsapp|telegram|discord)",
"Delivery channel (last|whatsapp|telegram|discord|signal)",
)
.option(
"--to <dest>",
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more