Merge remote-tracking branch 'origin/main' into fix/dockerfile-install-bun

This commit is contained in:
Peter Steinberger 2026-01-06 15:00:54 +01:00
commit 9c13819f39
38 changed files with 981 additions and 225 deletions

View File

@ -16,6 +16,7 @@
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. - Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure. - Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins. - Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins.
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`). - Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings. - Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp. - Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp.
@ -51,12 +52,14 @@
- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`. - Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`.
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`).
- Control UI: show a reading indicator bubble while the assistant is responding. - Control UI: show a reading indicator bubble while the assistant is responding.
- Control UI: animate reading indicator dots (honors reduced-motion). - Control UI: animate reading indicator dots (honors reduced-motion).
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). - Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268. - Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274. - Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
- Control UI: add Chat focus mode toggle to collapse header + sidebar. - Control UI: add Chat focus mode toggle to collapse header + sidebar.
- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported).
- Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show runtime (docker/direct) and move shortcuts to `/help`.
- Status: show model auth source (api-key/oauth). - Status: show model auth source (api-key/oauth).
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split. - Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
@ -72,6 +75,8 @@
- Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235. - Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235.
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249. - Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
- Auth: rotate across multiple OAuth profiles with cooldown tracking and email-based profile IDs. Thanks @mukhtharcm for PR #269. - Auth: rotate across multiple OAuth profiles with cooldown tracking and email-based profile IDs. Thanks @mukhtharcm for PR #269.
- Auth: fix multi-account OAuth rotation so round-robin alternates instead of pinning to lastGood. Thanks @mukhtharcm for PR #281.
- Configure: stop auto-writing `auth.order` for newly added auth profiles (round-robin default unless explicitly pinned).
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242. - Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242.
- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241. - Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241.
- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220. - Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220.
@ -133,7 +138,7 @@
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`. - Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env). - Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env).
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). - Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
- Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`). - Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). - Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. - Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. - Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.

200
README.md
View File

@ -20,7 +20,7 @@ It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Disco
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
Website: [https://clawdbot.com](https://clawdbot.com) · Docs: [https://docs.clawdbot.com](https://docs.clawdbot.com/) · Showcase: [https://docs.clawdbot.com/showcase](https://docs.clawdbot.com/showcase) · FAQ: [https://docs.clawdbot.com/faq](https://docs.clawdbot.com/faq) · Wizard: [https://docs.clawdbot.com/wizard](https://docs.clawdbot.com/wizard) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://docs.clawdbot.com/docker](https://docs.clawdbot.com/docker) · Discord: [https://discord.gg/clawd](https://discord.gg/clawd) [Website](https://clawdbot.com) · [Docs](https://github.com/clawdbot/clawdbot/blob/main/docs/index.md) · Showcase: [https://github.com/clawdbot/clawdbot/blob/main/docs/showcase.md](https://github.com/clawdbot/clawdbot/blob/main/docs/showcase.md) · FAQ: [https://github.com/clawdbot/clawdbot/blob/main/docs/faq.md](https://github.com/clawdbot/clawdbot/blob/main/docs/faq.md) · Wizard: [https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md](https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md) · Discord: [https://discord.gg/clawd](https://discord.gg/clawd)
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**. Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
Works with npm, pnpm, or bun. Works with npm, pnpm, or bun.
@ -29,7 +29,7 @@ Works with npm, pnpm, or bun.
- **Anthropic** (Claude Pro/Max) - **Anthropic** (Claude Pro/Max)
- **OpenAI** (ChatGPT/Codex) - **OpenAI** (ChatGPT/Codex)
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for longcontext strength and better promptinjection resistance. See [Onboarding](https://docs.clawdbot.com/onboarding). Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for longcontext strength and better promptinjection resistance. See [Onboarding](https://github.com/clawdbot/clawdbot/blob/main/docs/onboarding.md).
## Recommended setup (from source) ## Recommended setup (from source)
@ -40,10 +40,10 @@ Do **not** download prebuilt binaries. Build from source.
git clone https://github.com/clawdbot/clawdbot.git git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot cd clawdbot
pnpm install bun install
pnpm build bun run build
pnpm ui:build bun run ui:build
pnpm clawdbot onboard bun run clawdbot onboard
``` ```
## Quick start (from source) ## Quick start (from source)
@ -88,45 +88,45 @@ If you run from source, prefer `bun run clawdbot …` or `pnpm clawdbot …` (no
## Highlights ## Highlights
- **[Local-first Gateway](https://docs.clawdbot.com/gateway)** — single control plane for sessions, providers, tools, and events. - **[Local-first Gateway](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md)** — single control plane for sessions, providers, tools, and events.
- **[Multi-surface inbox](https://docs.clawdbot.com/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android. - **[Multi-surface inbox](https://github.com/clawdbot/clawdbot/blob/main/docs/surface.md)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs. - **[Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md) + [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawdbot.com/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui). - **[Live Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md)** — agent-driven visual workspace with [A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui).
- **[First-class tools](https://docs.clawdbot.com/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. - **[First-class tools](https://github.com/clawdbot/clawdbot/blob/main/docs/tools.md)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
- **[Companion apps](https://docs.clawdbot.com/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawdbot.com/nodes). - **[Companion apps](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md)** — macOS menu bar app + iOS/Android [nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md).
- **[Onboarding](https://docs.clawdbot.com/wizard) + [skills](https://docs.clawdbot.com/skills)** — wizard-driven setup with bundled/managed/workspace skills. - **[Onboarding](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md) + [skills](https://github.com/clawdbot/clawdbot/blob/main/docs/skills.md)** — wizard-driven setup with bundled/managed/workspace skills.
## Everything we built so far ## Everything we built so far
### Core platform ### Core platform
- [Gateway WS control plane](https://docs.clawdbot.com/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawdbot.com/web), and [Canvas host](https://docs.clawdbot.com/refactor/canvas-a2ui). - [Gateway WS control plane](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md) with sessions, presence, config, cron, webhooks, [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md), and [Canvas host](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui).
- [CLI surface](https://docs.clawdbot.com/agent-send): gateway, agent, send, [wizard](https://docs.clawdbot.com/wizard), and [doctor](https://docs.clawdbot.com/doctor). - [CLI surface](https://github.com/clawdbot/clawdbot/blob/main/docs/agent-send.md): gateway, agent, send, [wizard](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md), and [doctor](https://github.com/clawdbot/clawdbot/blob/main/docs/doctor.md).
- [Pi agent runtime](https://docs.clawdbot.com/agent) in RPC mode with tool streaming and block streaming. - [Pi agent runtime](https://github.com/clawdbot/clawdbot/blob/main/docs/agent.md) in RPC mode with tool streaming and block streaming.
- [Session model](https://docs.clawdbot.com/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawdbot.com/groups). - [Session model](https://github.com/clawdbot/clawdbot/blob/main/docs/session.md): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://github.com/clawdbot/clawdbot/blob/main/docs/groups.md).
- [Media pipeline](https://docs.clawdbot.com/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawdbot.com/audio). - [Media pipeline](https://github.com/clawdbot/clawdbot/blob/main/docs/images.md): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://github.com/clawdbot/clawdbot/blob/main/docs/audio.md).
### Surfaces + providers ### Surfaces + providers
- [Providers](https://docs.clawdbot.com/surface): [WhatsApp](https://docs.clawdbot.com/whatsapp) (Baileys), [Telegram](https://docs.clawdbot.com/telegram) (grammY), [Slack](https://docs.clawdbot.com/slack) (Bolt), [Discord](https://docs.clawdbot.com/discord) (discord.js), [Signal](https://docs.clawdbot.com/signal) (signal-cli), [iMessage](https://docs.clawdbot.com/imessage) (imsg), [WebChat](https://docs.clawdbot.com/webchat). - [Providers](https://github.com/clawdbot/clawdbot/blob/main/docs/surface.md): [WhatsApp](https://github.com/clawdbot/clawdbot/blob/main/docs/whatsapp.md) (Baileys), [Telegram](https://github.com/clawdbot/clawdbot/blob/main/docs/telegram.md) (grammY), [Slack](https://github.com/clawdbot/clawdbot/blob/main/docs/slack.md) (Bolt), [Discord](https://github.com/clawdbot/clawdbot/blob/main/docs/discord.md) (discord.js), [Signal](https://github.com/clawdbot/clawdbot/blob/main/docs/signal.md) (signal-cli), [iMessage](https://github.com/clawdbot/clawdbot/blob/main/docs/imessage.md) (imsg), [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md).
- [Group routing](https://docs.clawdbot.com/group-messages): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://docs.clawdbot.com/surface). - [Group routing](https://github.com/clawdbot/clawdbot/blob/main/docs/group-messages.md): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://github.com/clawdbot/clawdbot/blob/main/docs/surface.md).
### Apps + nodes ### Apps + nodes
- [macOS app](https://docs.clawdbot.com/macos): menu bar control plane, [Voice Wake](https://docs.clawdbot.com/voicewake)/PTT, [Talk Mode](https://docs.clawdbot.com/talk) overlay, [WebChat](https://docs.clawdbot.com/webchat), debug tools, [remote gateway](https://docs.clawdbot.com/remote) control. - [macOS app](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md): menu bar control plane, [Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md)/PTT, [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md) overlay, [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md), debug tools, [remote gateway](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md) control.
- [iOS node](https://docs.clawdbot.com/ios): [Canvas](https://docs.clawdbot.com/mac/canvas), [Voice Wake](https://docs.clawdbot.com/voicewake), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, Bonjour pairing. - [iOS node](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md): [Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md), [Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md), [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md), camera, screen recording, Bonjour pairing.
- [Android node](https://docs.clawdbot.com/android): [Canvas](https://docs.clawdbot.com/mac/canvas), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, optional SMS. - [Android node](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md): [Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md), [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md), camera, screen recording, optional SMS.
- [macOS node mode](https://docs.clawdbot.com/nodes): system.run/notify + canvas/camera exposure. - [macOS node mode](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md): system.run/notify + canvas/camera exposure.
### Tools + automation ### Tools + automation
- [Browser control](https://docs.clawdbot.com/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles. - [Browser control](https://github.com/clawdbot/clawdbot/blob/main/docs/browser.md): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
- [Canvas](https://docs.clawdbot.com/mac/canvas): [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui) push/reset, eval, snapshot. - [Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md): [A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui) push/reset, eval, snapshot.
- [Nodes](https://docs.clawdbot.com/nodes): camera snap/clip, screen record, [location.get](https://docs.clawdbot.com/location-command), notifications. - [Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md): camera snap/clip, screen record, [location.get](https://github.com/clawdbot/clawdbot/blob/main/docs/location-command.md), notifications.
- [Cron + wakeups](https://docs.clawdbot.com/cron); [webhooks](https://docs.clawdbot.com/webhook); [Gmail Pub/Sub](https://docs.clawdbot.com/gmail-pubsub). - [Cron + wakeups](https://github.com/clawdbot/clawdbot/blob/main/docs/cron.md); [webhooks](https://github.com/clawdbot/clawdbot/blob/main/docs/webhook.md); [Gmail Pub/Sub](https://github.com/clawdbot/clawdbot/blob/main/docs/gmail-pubsub.md).
- [Skills platform](https://docs.clawdbot.com/skills): bundled, managed, and workspace skills with install gating + UI. - [Skills platform](https://github.com/clawdbot/clawdbot/blob/main/docs/skills.md): bundled, managed, and workspace skills with install gating + UI.
### Ops + packaging ### Ops + packaging
- [Control UI](https://docs.clawdbot.com/web) + [WebChat](https://docs.clawdbot.com/webchat) served directly from the Gateway. - [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md) + [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md) served directly from the Gateway.
- [Tailscale Serve/Funnel](https://docs.clawdbot.com/tailscale) or [SSH tunnels](https://docs.clawdbot.com/remote) with token/password auth. - [Tailscale Serve/Funnel](https://github.com/clawdbot/clawdbot/blob/main/docs/tailscale.md) or [SSH tunnels](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md) with token/password auth.
- [Nix mode](https://docs.clawdbot.com/nix) for declarative config; [Docker](https://docs.clawdbot.com/docker)-based installs. - [Nix mode](https://github.com/clawdbot/clawdbot/blob/main/docs/nix.md) for declarative config; [Docker](https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md)-based installs.
- [Doctor](https://docs.clawdbot.com/doctor) migrations, [logging](https://docs.clawdbot.com/logging). - [Doctor](https://github.com/clawdbot/clawdbot/blob/main/docs/doctor.md) migrations, [logging](https://github.com/clawdbot/clawdbot/blob/main/docs/logging.md).
## How it works (short) ## How it works (short)
@ -148,12 +148,12 @@ WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
## Key subsystems ## Key subsystems
- **[Gateway WebSocket network](https://docs.clawdbot.com/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawdbot.com/gateway)). - **[Gateway WebSocket network](https://github.com/clawdbot/clawdbot/blob/main/docs/architecture.md)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md)).
- **[Tailscale exposure](https://docs.clawdbot.com/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawdbot.com/remote)). - **[Tailscale exposure](https://github.com/clawdbot/clawdbot/blob/main/docs/tailscale.md)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md)).
- **[Browser control](https://docs.clawdbot.com/browser)** — clawdmanaged Chrome/Chromium with CDP control. - **[Browser control](https://github.com/clawdbot/clawdbot/blob/main/docs/browser.md)** — clawdmanaged Chrome/Chromium with CDP control.
- **[Canvas + A2UI](https://docs.clawdbot.com/mac/canvas)** — agentdriven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui)). - **[Canvas + A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md)** — agentdriven visual workspace (A2UI host: [Canvas/A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui)).
- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — alwayson speech and continuous conversation. - **[Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md) + [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md)** — alwayson speech and continuous conversation.
- **[Nodes](https://docs.clawdbot.com/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOSonly `system.run`/`system.notify`. - **[Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOSonly `system.run`/`system.notify`.
## Tailscale access (Gateway dashboard) ## Tailscale access (Gateway dashboard)
@ -169,7 +169,7 @@ Notes:
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set. - Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown. - Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
Details: [Tailscale guide](https://docs.clawdbot.com/tailscale) · [Web surfaces](https://docs.clawdbot.com/web) Details: [Tailscale guide](https://github.com/clawdbot/clawdbot/blob/main/docs/tailscale.md) · [Web surfaces](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md)
## Remote Gateway (Linux is great) ## Remote Gateway (Linux is great)
@ -179,7 +179,7 @@ Its perfectly fine to run the Gateway on a small Linux instance. Clients (mac
- **Device nodes** run devicelocal actions (`system.run`, camera, screen recording, notifications) via `node.invoke`. - **Device nodes** run devicelocal actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
In short: bash runs where the Gateway lives; device actions run where the device lives. In short: bash runs where the Gateway lives; device actions run where the device lives.
Details: [Remote access](https://docs.clawdbot.com/remote) · [Nodes](https://docs.clawdbot.com/nodes) · [Security](https://docs.clawdbot.com/security) Details: [Remote access](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md) · [Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md) · [Security](https://github.com/clawdbot/clawdbot/blob/main/docs/security.md)
## macOS permissions via the Gateway protocol ## macOS permissions via the Gateway protocol
@ -194,7 +194,7 @@ Elevated bash (host permissions) is separate from macOS TCC:
- Use `/elevated on|off` to toggle persession elevated access when enabled + allowlisted. - Use `/elevated on|off` to toggle persession elevated access when enabled + allowlisted.
- Gateway persists the persession toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`. - Gateway persists the persession toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.clawdbot.com/macos) · [Gateway protocol](https://docs.clawdbot.com/architecture) Details: [Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md) · [macOS app](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md) · [Gateway protocol](https://github.com/clawdbot/clawdbot/blob/main/docs/architecture.md)
## Agent to Agent (sessions_* tools) ## Agent to Agent (sessions_* tools)
@ -203,7 +203,7 @@ Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.cl
- `sessions_history` — fetch transcript logs for a session. - `sessions_history` — fetch transcript logs for a session.
- `sessions_send` — message another session; optional replyback pingpong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`). - `sessions_send` — message another session; optional replyback pingpong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
Details: [Session tools](https://docs.clawdbot.com/session-tool) Details: [Session tools](https://github.com/clawdbot/clawdbot/blob/main/docs/session-tool.md)
## Skills registry (ClawdHub) ## Skills registry (ClawdHub)
@ -249,13 +249,13 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
- Voice trigger forwarding + Canvas surface. - Voice trigger forwarding + Canvas surface.
- Controlled via `clawdbot nodes …`. - Controlled via `clawdbot nodes …`.
Runbook: [iOS connect](https://docs.clawdbot.com/ios). Runbook: [iOS connect](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md).
### Android node (optional) ### Android node (optional)
- Pairs via the same Bridge + pairing flow as iOS. - Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands. - Exposes Canvas, Camera, and Screen capture commands.
- Runbook: [Android connect](https://docs.clawdbot.com/android). - Runbook: [Android connect](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md).
## Agent workspace + skills ## Agent workspace + skills
@ -275,7 +275,7 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
} }
``` ```
[Full configuration reference (all keys + examples).](https://docs.clawdbot.com/configuration) [Full configuration reference (all keys + examples).](https://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md)
## Security model (important) ## Security model (important)
@ -283,15 +283,15 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **nonmain sessions** (groups/channels) inside persession Docker sandboxes; bash then runs in Docker for those sessions. - **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **nonmain sessions** (groups/channels) inside persession Docker sandboxes; bash then runs in Docker for those sessions.
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`. - **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandboxing](https://docs.clawdbot.com/docker) · [Sandbox config](https://docs.clawdbot.com/configuration) Details: [Security guide](https://github.com/clawdbot/clawdbot/blob/main/docs/security.md) · [Docker + sandboxing](https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md) · [Sandbox config](https://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md)
### [WhatsApp](https://docs.clawdbot.com/whatsapp) ### [WhatsApp](https://github.com/clawdbot/clawdbot/blob/main/docs/whatsapp.md)
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`). - Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`. - Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all. - If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Telegram](https://docs.clawdbot.com/telegram) ### [Telegram](https://github.com/clawdbot/clawdbot/blob/main/docs/telegram.md)
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins). - Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed. - Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
@ -304,11 +304,11 @@ Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandb
} }
``` ```
### [Slack](https://docs.clawdbot.com/slack) ### [Slack](https://github.com/clawdbot/clawdbot/blob/main/docs/slack.md)
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`). - Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
### [Discord](https://docs.clawdbot.com/discord) ### [Discord](https://github.com/clawdbot/clawdbot/blob/main/docs/discord.md)
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). - Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed. - Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
@ -321,16 +321,16 @@ Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandb
} }
``` ```
### [Signal](https://docs.clawdbot.com/signal) ### [Signal](https://github.com/clawdbot/clawdbot/blob/main/docs/signal.md)
- Requires `signal-cli` and a `signal` config section. - Requires `signal-cli` and a `signal` config section.
### [iMessage](https://docs.clawdbot.com/imessage) ### [iMessage](https://github.com/clawdbot/clawdbot/blob/main/docs/imessage.md)
- macOS only; Messages must be signed in. - macOS only; Messages must be signed in.
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all. - If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [WebChat](https://docs.clawdbot.com/webchat) ### [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md)
- Uses the Gateway WebSocket; no separate WebChat port/config. - Uses the Gateway WebSocket; no separate WebChat port/config.
@ -349,69 +349,69 @@ Browser control (optional):
## Docs ## Docs
Use these when youre past the onboarding flow and want the deeper reference. Use these when youre past the onboarding flow and want the deeper reference.
- [Start with the docs index for navigation and “whats where.”](https://docs.clawdbot.com/) - [Start with the docs index for navigation and “whats where.”](https://github.com/clawdbot/clawdbot/blob/main/docs/index.md)
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawdbot.com/architecture) - [Read the architecture overview for the gateway + protocol model.](https://github.com/clawdbot/clawdbot/blob/main/docs/architecture.md)
- [Use the full configuration reference when you need every key and example.](https://docs.clawdbot.com/configuration) - [Use the full configuration reference when you need every key and example.](https://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md)
- [Run the Gateway by the book with the operational runbook.](https://docs.clawdbot.com/gateway) - [Run the Gateway by the book with the operational runbook.](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md)
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawdbot.com/web) - [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md)
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawdbot.com/remote) - [Understand remote access over SSH tunnels or tailnets.](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md)
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawdbot.com/wizard) - [Follow the onboarding wizard flow for a guided setup.](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md)
- [Wire external triggers via the webhook surface.](https://docs.clawdbot.com/webhook) - [Wire external triggers via the webhook surface.](https://github.com/clawdbot/clawdbot/blob/main/docs/webhook.md)
- [Set up Gmail Pub/Sub triggers.](https://docs.clawdbot.com/gmail-pubsub) - [Set up Gmail Pub/Sub triggers.](https://github.com/clawdbot/clawdbot/blob/main/docs/gmail-pubsub.md)
- [Learn the macOS menu bar companion details.](https://docs.clawdbot.com/mac/menu-bar) - [Learn the macOS menu bar companion details.](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/menu-bar.md)
- [Platform guides: Windows](https://docs.clawdbot.com/windows), [Linux](https://docs.clawdbot.com/linux), [macOS](https://docs.clawdbot.com/macos), [iOS](https://docs.clawdbot.com/ios), [Android](https://docs.clawdbot.com/android) - [Platform guides: Windows](https://github.com/clawdbot/clawdbot/blob/main/docs/windows.md), [Linux](https://github.com/clawdbot/clawdbot/blob/main/docs/linux.md), [macOS](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md), [iOS](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md), [Android](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md)
- [Debug common failures with the troubleshooting guide.](https://docs.clawdbot.com/troubleshooting) - [Debug common failures with the troubleshooting guide.](https://github.com/clawdbot/clawdbot/blob/main/docs/troubleshooting.md)
- [Review security guidance before exposing anything.](https://docs.clawdbot.com/security) - [Review security guidance before exposing anything.](https://github.com/clawdbot/clawdbot/blob/main/docs/security.md)
## Advanced docs (discovery + control) ## Advanced docs (discovery + control)
- [Discovery + transports](https://docs.clawdbot.com/discovery) - [Discovery + transports](https://github.com/clawdbot/clawdbot/blob/main/docs/discovery.md)
- [Bonjour/mDNS](https://docs.clawdbot.com/bonjour) - [Bonjour/mDNS](https://github.com/clawdbot/clawdbot/blob/main/docs/bonjour.md)
- [Gateway pairing](https://docs.clawdbot.com/gateway/pairing) - [Gateway pairing](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway/pairing.md)
- [Remote gateway README](https://docs.clawdbot.com/remote-gateway-readme) - [Remote gateway README](https://github.com/clawdbot/clawdbot/blob/main/docs/remote-gateway-readme.md)
- [Control UI](https://docs.clawdbot.com/control-ui) - [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/control-ui.md)
- [Dashboard](https://docs.clawdbot.com/dashboard) - [Dashboard](https://github.com/clawdbot/clawdbot/blob/main/docs/dashboard.md)
## Operations & troubleshooting ## Operations & troubleshooting
- [Health checks](https://docs.clawdbot.com/health) - [Health checks](https://github.com/clawdbot/clawdbot/blob/main/docs/health.md)
- [Gateway lock](https://docs.clawdbot.com/gateway-lock) - [Gateway lock](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway-lock.md)
- [Background process](https://docs.clawdbot.com/background-process) - [Background process](https://github.com/clawdbot/clawdbot/blob/main/docs/background-process.md)
- [Browser troubleshooting (Linux)](https://docs.clawdbot.com/browser-linux-troubleshooting) - [Browser troubleshooting (Linux)](https://github.com/clawdbot/clawdbot/blob/main/docs/browser-linux-troubleshooting.md)
- [Logging](https://docs.clawdbot.com/logging) - [Logging](https://github.com/clawdbot/clawdbot/blob/main/docs/logging.md)
## Deep dives ## Deep dives
- [Agent loop](https://docs.clawdbot.com/agent-loop) - [Agent loop](https://github.com/clawdbot/clawdbot/blob/main/docs/agent-loop.md)
- [Presence](https://docs.clawdbot.com/presence) - [Presence](https://github.com/clawdbot/clawdbot/blob/main/docs/presence.md)
- [TypeBox schemas](https://docs.clawdbot.com/typebox) - [TypeBox schemas](https://github.com/clawdbot/clawdbot/blob/main/docs/typebox.md)
- [RPC adapters](https://docs.clawdbot.com/rpc) - [RPC adapters](https://github.com/clawdbot/clawdbot/blob/main/docs/rpc.md)
- [Queue](https://docs.clawdbot.com/queue) - [Queue](https://github.com/clawdbot/clawdbot/blob/main/docs/queue.md)
## Workspace & skills ## Workspace & skills
- [Skills config](https://docs.clawdbot.com/skills-config) - [Skills config](https://github.com/clawdbot/clawdbot/blob/main/docs/skills-config.md)
- [Default AGENTS](https://docs.clawdbot.com/AGENTS.default) - [Default AGENTS](https://github.com/clawdbot/clawdbot/blob/main/docs/AGENTS.default.md)
- [Templates: AGENTS](https://docs.clawdbot.com/templates/AGENTS) - [Templates: AGENTS](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/AGENTS.md)
- [Templates: BOOTSTRAP](https://docs.clawdbot.com/templates/BOOTSTRAP) - [Templates: BOOTSTRAP](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/BOOTSTRAP.md)
- [Templates: IDENTITY](https://docs.clawdbot.com/templates/IDENTITY) - [Templates: IDENTITY](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/IDENTITY.md)
- [Templates: SOUL](https://docs.clawdbot.com/templates/SOUL) - [Templates: SOUL](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/SOUL.md)
- [Templates: TOOLS](https://docs.clawdbot.com/templates/TOOLS) - [Templates: TOOLS](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/TOOLS.md)
- [Templates: USER](https://docs.clawdbot.com/templates/USER) - [Templates: USER](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/USER.md)
## Platform internals ## Platform internals
- [macOS dev setup](https://docs.clawdbot.com/mac/dev-setup) - [macOS dev setup](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/dev-setup.md)
- [macOS menu bar](https://docs.clawdbot.com/mac/menu-bar) - [macOS menu bar](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/menu-bar.md)
- [macOS voice wake](https://docs.clawdbot.com/mac/voicewake) - [macOS voice wake](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/voicewake.md)
- [iOS node](https://docs.clawdbot.com/ios) - [iOS node](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md)
- [Android node](https://docs.clawdbot.com/android) - [Android node](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md)
- [Windows app](https://docs.clawdbot.com/windows) - [Windows app](https://github.com/clawdbot/clawdbot/blob/main/docs/windows.md)
- [Linux app](https://docs.clawdbot.com/linux) - [Linux app](https://github.com/clawdbot/clawdbot/blob/main/docs/linux.md)
## Email hooks (Gmail) ## Email hooks (Gmail)
[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawdbot.com/gmail-pubsub) [Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://github.com/clawdbot/clawdbot/blob/main/docs/gmail-pubsub.md)
Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you dont want auto-start. Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you dont want auto-start.
@ -442,5 +442,5 @@ Thanks to all clawtributors:
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></a>
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
<a href="https://github.com/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="Iamadig" title="Iamadig"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="Iamadig" title="Iamadig"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a>
</p> </p>

View File

@ -655,23 +655,27 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let spawnedby: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool? includeunknown: Bool?,
spawnedby: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.spawnedby = spawnedby
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case spawnedby = "spawnedBy"
} }
} }
@ -681,6 +685,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let verboselevel: AnyCodable? public let verboselevel: AnyCodable?
public let elevatedlevel: AnyCodable? public let elevatedlevel: AnyCodable?
public let model: AnyCodable? public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let sendpolicy: AnyCodable? public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable? public let groupactivation: AnyCodable?
@ -690,6 +695,7 @@ public struct SessionsPatchParams: Codable, Sendable {
verboselevel: AnyCodable?, verboselevel: AnyCodable?,
elevatedlevel: AnyCodable?, elevatedlevel: AnyCodable?,
model: AnyCodable?, model: AnyCodable?,
spawnedby: AnyCodable?,
sendpolicy: AnyCodable?, sendpolicy: AnyCodable?,
groupactivation: AnyCodable? groupactivation: AnyCodable?
) { ) {
@ -698,6 +704,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.verboselevel = verboselevel self.verboselevel = verboselevel
self.elevatedlevel = elevatedlevel self.elevatedlevel = elevatedlevel
self.model = model self.model = model
self.spawnedby = spawnedby
self.sendpolicy = sendpolicy self.sendpolicy = sendpolicy
self.groupactivation = groupactivation self.groupactivation = groupactivation
} }
@ -707,6 +714,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case verboselevel = "verboseLevel" case verboselevel = "verboseLevel"
case elevatedlevel = "elevatedLevel" case elevatedlevel = "elevatedLevel"
case model case model
case spawnedby = "spawnedBy"
case sendpolicy = "sendPolicy" case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation" case groupactivation = "groupActivation"
} }

View File

@ -63,21 +63,21 @@ Paste the token into the UI settings (sent as `connect.params.auth.token`).
The Gateway serves static files from `dist/control-ui`. Build them with: The Gateway serves static files from `dist/control-ui`. Build them with:
```bash ```bash
pnpm ui:install bun run ui:install
pnpm ui:build bun run ui:build
``` ```
Optional absolute base (when you want fixed asset URLs): Optional absolute base (when you want fixed asset URLs):
```bash ```bash
CLAWDBOT_CONTROL_UI_BASE_PATH=/clawdbot/ pnpm ui:build CLAWDBOT_CONTROL_UI_BASE_PATH=/clawdbot/ bun run ui:build
``` ```
For local development (separate dev server): For local development (separate dev server):
```bash ```bash
pnpm ui:install bun run ui:install
pnpm ui:dev bun run ui:dev
``` ```
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`). Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).

View File

@ -19,7 +19,7 @@ read_when:
<p align="center"> <p align="center">
<a href="https://github.com/clawdbot/clawdbot">GitHub</a> · <a href="https://github.com/clawdbot/clawdbot">GitHub</a> ·
<a href="https://github.com/clawdbot/clawdbot/releases">Releases</a> · <a href="https://github.com/clawdbot/clawdbot/releases">Releases</a> ·
<a href="https://docs.clawdbot.com/">Docs</a> · <a href="https://github.com/clawdbot/clawdbot/blob/main/docs/index.md">Docs</a> ·
<a href="./clawd.md">Clawd setup</a> <a href="./clawd.md">Clawd setup</a>
</p> </p>

View File

@ -35,6 +35,7 @@ Parameters:
Behavior: Behavior:
- `messageLimit > 0` fetches `chat.history` per session and includes the last N messages. - `messageLimit > 0` fetches `chat.history` per session and includes the last N messages.
- Tool results are filtered out in list output; use `sessions_history` for tool messages. - Tool results are filtered out in list output; use `sessions_history` for tool messages.
- When running in a **sandboxed** agent session, session tools default to **spawned-only visibility** (see below).
Row shape (JSON): Row shape (JSON):
- `key`: session key (string) - `key`: session key (string)
@ -131,5 +132,23 @@ Parameters:
Behavior: Behavior:
- Starts a new `subagent:<uuid>` session with `deliver: false`. - Starts a new `subagent:<uuid>` session with `deliver: false`.
- Sub-agents default to the full tool surface **minus session tools** (configurable via `agent.subagents.tools`). - Sub-agents default to the full tool surface **minus session tools** (configurable via `agent.subagents.tools`).
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
- After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat surface. - After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat surface.
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent. - Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
## Sandbox Session Visibility
Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`.
Config:
```json5
{
agent: {
sandbox: {
// default: "spawned"
sessionToolsVisibility: "spawned" // or "all"
}
}
}
```

View File

@ -13,6 +13,7 @@ Primary goals:
- Parallelize “research / long task / slow tool” work without blocking the main run. - Parallelize “research / long task / slow tool” work without blocking the main run.
- Keep sub-agents isolated by default (session separation + optional sandboxing). - Keep sub-agents isolated by default (session separation + optional sandboxing).
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default. - Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
- Avoid nested fan-out: sub-agents cannot spawn sub-agents.
## Tool ## Tool
@ -69,4 +70,3 @@ Sub-agents use a dedicated in-process queue lane:
- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost. - Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost.
- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve. - Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.

View File

@ -110,6 +110,6 @@ Open:
The Gateway serves static files from `dist/control-ui`. Build them with: The Gateway serves static files from `dist/control-ui`. Build them with:
```bash ```bash
pnpm ui:install bun run ui:install
pnpm ui:build bun run ui:build
``` ```

View File

@ -51,9 +51,9 @@
"docs:build": "cd docs && pnpm dlx mint broken-links", "docs:build": "cd docs && pnpm dlx mint broken-links",
"build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts", "build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts",
"release:check": "bun scripts/release-check.ts", "release:check": "bun scripts/release-check.ts",
"ui:install": "pnpm -C ui install", "ui:install": "node scripts/ui.js install",
"ui:dev": "pnpm -C ui dev", "ui:dev": "node scripts/ui.js dev",
"ui:build": "pnpm -C ui build", "ui:build": "node scripts/ui.js build",
"start": "bun src/entry.ts", "start": "bun src/entry.ts",
"clawdbot": "bun src/entry.ts", "clawdbot": "bun src/entry.ts",
"gateway:watch": "bun --watch src/entry.ts gateway --force", "gateway:watch": "bun --watch src/entry.ts gateway --force",

View File

@ -146,8 +146,8 @@ else
fi fi
if [[ "${SKIP_UI_BUILD:-0}" != "1" ]]; then if [[ "${SKIP_UI_BUILD:-0}" != "1" ]]; then
echo "🖥 Building Control UI (pnpm ui:build)" echo "🖥 Building Control UI (ui:build)"
(cd "$ROOT_DIR" && pnpm ui:build) (cd "$ROOT_DIR" && node scripts/ui.js build)
else else
echo "🖥 Skipping Control UI build (SKIP_UI_BUILD=1)" echo "🖥 Skipping Control UI build (SKIP_UI_BUILD=1)"
fi fi

102
scripts/ui.js Normal file
View File

@ -0,0 +1,102 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const uiDir = path.join(repoRoot, "ui");
function usage() {
// keep this tiny; it's invoked from npm scripts too
process.stderr.write(
"Usage: node scripts/ui.js <install|dev|build|test> [...args]\n",
);
}
function which(cmd) {
try {
const key = process.platform === "win32" ? "Path" : "PATH";
const paths = (process.env[key] ?? process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean);
const extensions =
process.platform === "win32"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.filter(Boolean)
: [""];
for (const entry of paths) {
for (const ext of extensions) {
const candidate = path.join(entry, process.platform === "win32" ? `${cmd}${ext}` : cmd);
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// ignore
}
}
}
} catch {
// ignore
}
return null;
}
function resolveRunner() {
const bun = which("bun");
if (bun) return { cmd: bun, kind: "bun" };
const pnpm = which("pnpm");
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
return null;
}
function run(cmd, args) {
const child = spawn(cmd, args, {
cwd: uiDir,
stdio: "inherit",
env: process.env,
});
child.on("exit", (code, signal) => {
if (signal) process.exit(1);
process.exit(code ?? 1);
});
}
const [, , action, ...rest] = process.argv;
if (!action) {
usage();
process.exit(2);
}
const runner = resolveRunner();
if (!runner) {
process.stderr.write(
"Missing UI runner: install bun or pnpm, then retry.\n",
);
process.exit(1);
}
const script =
action === "install"
? null
: action === "dev"
? "dev"
: action === "build"
? "build"
: action === "test"
? "test"
: null;
if (action !== "install" && !script) {
usage();
process.exit(2);
}
if (runner.kind === "bun") {
if (action === "install") run(runner.cmd, ["install", ...rest]);
else run(runner.cmd, ["run", script, ...rest]);
} else {
if (action === "install") run(runner.cmd, ["install", ...rest]);
else run(runner.cmd, ["run", script, ...rest]);
}

View File

@ -50,13 +50,20 @@ describe("resolveAuthProfileOrder", () => {
expect(order).toContain("anthropic:default"); expect(order).toContain("anthropic:default");
}); });
it("prioritizes last-good profile when no preferred override", () => { it("does not prioritize lastGood over round-robin ordering", () => {
const order = resolveAuthProfileOrder({ const order = resolveAuthProfileOrder({
cfg, cfg,
store: { ...store, lastGood: { anthropic: "anthropic:work" } }, store: {
...store,
lastGood: { anthropic: "anthropic:work" },
usageStats: {
"anthropic:default": { lastUsed: 100 },
"anthropic:work": { lastUsed: 200 },
},
},
provider: "anthropic", provider: "anthropic",
}); });
expect(order[0]).toBe("anthropic:work"); expect(order[0]).toBe("anthropic:default");
}); });
it("uses explicit profiles when order is missing", () => { it("uses explicit profiles when order is missing", () => {

View File

@ -433,19 +433,14 @@ export function resolveAuthProfileOrder(params: {
.filter(([, profile]) => profile.provider === provider) .filter(([, profile]) => profile.provider === provider)
.map(([profileId]) => profileId) .map(([profileId]) => profileId)
: []; : [];
const lastGood = store.lastGood?.[provider];
const baseOrder = const baseOrder =
configuredOrder ?? configuredOrder ??
(explicitProfiles.length > 0 (explicitProfiles.length > 0
? explicitProfiles ? explicitProfiles
: listProfilesForProvider(store, provider)); : listProfilesForProvider(store, provider));
if (baseOrder.length === 0) return []; if (baseOrder.length === 0) return [];
const order =
configuredOrder && configuredOrder.length > 0
? baseOrder
: orderProfilesByMode(baseOrder, store);
const filtered = order.filter((profileId) => { const filtered = baseOrder.filter((profileId) => {
const cred = store.profiles[profileId]; const cred = store.profiles[profileId];
return cred ? cred.provider === provider : true; return cred ? cred.provider === provider : true;
}); });
@ -453,21 +448,29 @@ export function resolveAuthProfileOrder(params: {
for (const entry of filtered) { for (const entry of filtered) {
if (!deduped.includes(entry)) deduped.push(entry); if (!deduped.includes(entry)) deduped.push(entry);
} }
if (preferredProfile && deduped.includes(preferredProfile)) {
const rest = deduped.filter((entry) => entry !== preferredProfile); // If user specified explicit order in config, respect it exactly
if (lastGood && rest.includes(lastGood)) { if (configuredOrder && configuredOrder.length > 0) {
// Still put preferredProfile first if specified
if (preferredProfile && deduped.includes(preferredProfile)) {
return [ return [
preferredProfile, preferredProfile,
lastGood, ...deduped.filter((e) => e !== preferredProfile),
...rest.filter((entry) => entry !== lastGood),
]; ];
} }
return [preferredProfile, ...rest]; return deduped;
} }
if (lastGood && deduped.includes(lastGood)) {
return [lastGood, ...deduped.filter((entry) => entry !== lastGood)]; // Otherwise, use round-robin: sort by lastUsed (oldest first)
// preferredProfile goes first if specified (for explicit user choice)
// lastGood is NOT prioritized - that would defeat round-robin
const sorted = orderProfilesByMode(deduped, store);
if (preferredProfile && sorted.includes(preferredProfile)) {
return [preferredProfile, ...sorted.filter((e) => e !== preferredProfile)];
} }
return deduped;
return sorted;
} }
function orderProfilesByMode( function orderProfilesByMode(

View File

@ -17,6 +17,7 @@ export function createClawdbotTools(options?: {
browserControlUrl?: string; browserControlUrl?: string;
agentSessionKey?: string; agentSessionKey?: string;
agentSurface?: string; agentSurface?: string;
sandboxed?: boolean;
config?: ClawdbotConfig; config?: ClawdbotConfig;
}): AnyAgentTool[] { }): AnyAgentTool[] {
const imageTool = createImageTool({ config: options?.config }); const imageTool = createImageTool({ config: options?.config });
@ -28,15 +29,23 @@ export function createClawdbotTools(options?: {
createDiscordTool(), createDiscordTool(),
createSlackTool(), createSlackTool(),
createGatewayTool(), createGatewayTool(),
createSessionsListTool(), createSessionsListTool({
createSessionsHistoryTool(), agentSessionKey: options?.agentSessionKey,
sandboxed: options?.sandboxed,
}),
createSessionsHistoryTool({
agentSessionKey: options?.agentSessionKey,
sandboxed: options?.sandboxed,
}),
createSessionsSendTool({ createSessionsSendTool({
agentSessionKey: options?.agentSessionKey, agentSessionKey: options?.agentSessionKey,
agentSurface: options?.agentSurface, agentSurface: options?.agentSurface,
sandboxed: options?.sandboxed,
}), }),
createSessionsSpawnTool({ createSessionsSpawnTool({
agentSessionKey: options?.agentSessionKey, agentSessionKey: options?.agentSessionKey,
agentSurface: options?.agentSurface, agentSurface: options?.agentSurface,
sandboxed: options?.sandboxed,
}), }),
...(imageTool ? [imageTool] : []), ...(imageTool ? [imageTool] : []),
]; ];

View File

@ -556,6 +556,7 @@ export function createClawdbotCodingTools(options?: {
browserControlUrl: sandbox?.browser?.controlUrl, browserControlUrl: sandbox?.browser?.controlUrl,
agentSessionKey: options?.sessionKey, agentSessionKey: options?.sessionKey,
agentSurface: options?.surface, agentSurface: options?.surface,
sandboxed: !!sandbox,
config: options?.config, config: options?.config,
}), }),
]; ];

View File

@ -114,7 +114,17 @@ const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdbot-sbx-";
const DEFAULT_SANDBOX_WORKDIR = "/workspace"; const DEFAULT_SANDBOX_WORKDIR = "/workspace";
const DEFAULT_SANDBOX_IDLE_HOURS = 24; const DEFAULT_SANDBOX_IDLE_HOURS = 24;
const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7; const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7;
const DEFAULT_TOOL_ALLOW = ["bash", "process", "read", "write", "edit"]; const DEFAULT_TOOL_ALLOW = [
"bash",
"process",
"read",
"write",
"edit",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
];
const DEFAULT_TOOL_DENY = [ const DEFAULT_TOOL_DENY = [
"browser", "browser",
"canvas", "canvas",

View File

@ -3,11 +3,23 @@ import {
normalizeCronJobCreate, normalizeCronJobCreate,
normalizeCronJobPatch, normalizeCronJobPatch,
} from "../../cron/normalize.js"; } from "../../cron/normalize.js";
import { CronAddParamsSchema } from "../../gateway/protocol/schema.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
const CronJobPatchSchema = Type.Partial(CronAddParamsSchema); // NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
// instead of CronAddParamsSchema/CronJobPatchSchema because:
//
// 1. CronAddParamsSchema contains nested Type.Union (for schedule, payload, etc.)
// 2. TypeBox compiles Type.Union to JSON Schema `anyOf`
// 3. pi-ai's sanitizeSchemaForGoogle() strips `anyOf` from nested properties
// 4. This leaves empty schemas `{}` which Claude rejects as invalid
//
// The actual validation happens at runtime via normalizeCronJobCreate/Patch
// and the gateway's validateCronAddParams. This schema just needs to accept
// any object so the AI can pass through the job definition.
//
// See: https://github.com/anthropics/anthropic-cookbook/blob/main/misc/tool_use_best_practices.md
// Claude requires valid JSON Schema 2020-12 with explicit types.
const CronToolSchema = Type.Union([ const CronToolSchema = Type.Union([
Type.Object({ Type.Object({
@ -28,7 +40,7 @@ const CronToolSchema = Type.Union([
gatewayUrl: Type.Optional(Type.String()), gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
job: CronAddParamsSchema, job: Type.Object({}, { additionalProperties: true }),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("update"), action: Type.Literal("update"),
@ -36,7 +48,7 @@ const CronToolSchema = Type.Union([
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
id: Type.String(), id: Type.String(),
patch: CronJobPatchSchema, patch: Type.Object({}, { additionalProperties: true }),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("remove"), action: Type.Literal("remove"),

View File

@ -17,7 +17,37 @@ const SessionsHistoryToolSchema = Type.Object({
includeTools: Type.Optional(Type.Boolean()), includeTools: Type.Optional(Type.Boolean()),
}); });
export function createSessionsHistoryTool(): AnyAgentTool { function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
}
async function isSpawnedSessionAllowed(params: {
requesterSessionKey: string;
targetSessionKey: string;
}): Promise<boolean> {
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: params.requesterSessionKey,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
return sessions.some((entry) => entry?.key === params.targetSessionKey);
} catch {
return false;
}
}
export function createSessionsHistoryTool(opts?: {
agentSessionKey?: string;
sandboxed?: boolean;
}): AnyAgentTool {
return { return {
label: "Session History", label: "Session History",
name: "sessions_history", name: "sessions_history",
@ -30,11 +60,37 @@ export function createSessionsHistoryTool(): AnyAgentTool {
}); });
const cfg = loadConfig(); const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg); const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility = resolveSandboxSessionToolsVisibility(cfg);
const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({
key: opts.agentSessionKey,
alias,
mainKey,
})
: undefined;
const resolvedKey = resolveInternalSessionKey({ const resolvedKey = resolveInternalSessionKey({
key: sessionKey, key: sessionKey,
alias, alias,
mainKey, mainKey,
}); });
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!requesterInternalKey.toLowerCase().startsWith("subagent:");
if (restrictToSpawned) {
const ok = await isSpawnedSessionAllowed({
requesterSessionKey: requesterInternalKey,
targetSessionKey: resolvedKey,
});
if (!ok) {
return jsonResult({
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
});
}
}
const limit = const limit =
typeof params.limit === "number" && Number.isFinite(params.limit) typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit)) ? Math.max(1, Math.floor(params.limit))

View File

@ -44,7 +44,16 @@ const SessionsListToolSchema = Type.Object({
messageLimit: Type.Optional(Type.Integer({ minimum: 0 })), messageLimit: Type.Optional(Type.Integer({ minimum: 0 })),
}); });
export function createSessionsListTool(): AnyAgentTool { function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
}
export function createSessionsListTool(opts?: {
agentSessionKey?: string;
sandboxed?: boolean;
}): AnyAgentTool {
return { return {
label: "Sessions", label: "Sessions",
name: "sessions_list", name: "sessions_list",
@ -54,6 +63,20 @@ export function createSessionsListTool(): AnyAgentTool {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const cfg = loadConfig(); const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg); const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility = resolveSandboxSessionToolsVisibility(cfg);
const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({
key: opts.agentSessionKey,
alias,
mainKey,
})
: undefined;
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!requesterInternalKey.toLowerCase().startsWith("subagent:");
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) => const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
value.trim().toLowerCase(), value.trim().toLowerCase(),
@ -86,8 +109,9 @@ export function createSessionsListTool(): AnyAgentTool {
params: { params: {
limit, limit,
activeMinutes, activeMinutes,
includeGlobal: true, includeGlobal: !restrictToSpawned,
includeUnknown: true, includeUnknown: !restrictToSpawned,
spawnedBy: restrictToSpawned ? requesterInternalKey : undefined,
}, },
})) as { })) as {
path?: string; path?: string;

View File

@ -33,6 +33,7 @@ const SessionsSendToolSchema = Type.Object({
export function createSessionsSendTool(opts?: { export function createSessionsSendTool(opts?: {
agentSessionKey?: string; agentSessionKey?: string;
agentSurface?: string; agentSurface?: string;
sandboxed?: boolean;
}): AnyAgentTool { }): AnyAgentTool {
return { return {
label: "Session Send", label: "Session Send",
@ -47,11 +48,64 @@ export function createSessionsSendTool(opts?: {
const message = readStringParam(params, "message", { required: true }); const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig(); const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg); const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility =
cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({
key: opts.agentSessionKey,
alias,
mainKey,
})
: undefined;
const resolvedKey = resolveInternalSessionKey({ const resolvedKey = resolveInternalSessionKey({
key: sessionKey, key: sessionKey,
alias, alias,
mainKey, mainKey,
}); });
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!requesterInternalKey.toLowerCase().startsWith("subagent:");
if (restrictToSpawned) {
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: requesterInternalKey,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
});
}
} catch {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
});
}
}
const timeoutSeconds = const timeoutSeconds =
typeof params.timeoutSeconds === "number" && typeof params.timeoutSeconds === "number" &&
Number.isFinite(params.timeoutSeconds) Number.isFinite(params.timeoutSeconds)

View File

@ -160,6 +160,7 @@ async function runSubagentAnnounceFlow(params: {
export function createSessionsSpawnTool(opts?: { export function createSessionsSpawnTool(opts?: {
agentSessionKey?: string; agentSessionKey?: string;
agentSurface?: string; agentSurface?: string;
sandboxed?: boolean;
}): AnyAgentTool { }): AnyAgentTool {
return { return {
label: "Sessions", label: "Sessions",
@ -185,6 +186,15 @@ export function createSessionsSpawnTool(opts?: {
const cfg = loadConfig(); const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg); const { mainKey, alias } = resolveMainSessionAlias(cfg);
const requesterSessionKey = opts?.agentSessionKey; const requesterSessionKey = opts?.agentSessionKey;
if (
typeof requesterSessionKey === "string" &&
requesterSessionKey.trim().toLowerCase().startsWith("subagent:")
) {
return jsonResult({
status: "forbidden",
error: "sessions_spawn is not allowed from sub-agent sessions",
});
}
const requesterInternalKey = requesterSessionKey const requesterInternalKey = requesterSessionKey
? resolveInternalSessionKey({ ? resolveInternalSessionKey({
key: requesterSessionKey, key: requesterSessionKey,
@ -199,6 +209,17 @@ export function createSessionsSpawnTool(opts?: {
}); });
const childSessionKey = `subagent:${crypto.randomUUID()}`; const childSessionKey = `subagent:${crypto.randomUUID()}`;
if (opts?.sandboxed === true) {
try {
await callGateway({
method: "sessions.patch",
params: { key: childSessionKey, spawnedBy: requesterInternalKey },
timeoutMs: 10_000,
});
} catch {
// best-effort; scoping relies on this metadata but spawning still works without it
}
}
const childSystemPrompt = buildSubagentSystemPrompt({ const childSystemPrompt = buildSubagentSystemPrompt({
requesterSessionKey, requesterSessionKey,
requesterSurface: opts?.agentSurface, requesterSurface: opts?.agentSurface,

View File

@ -10,7 +10,12 @@ import {
spinner, spinner,
text, text,
} from "@clack/prompts"; } from "@clack/prompts";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai"; import {
loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
CONFIG_PATH_CLAWDBOT, CONFIG_PATH_CLAWDBOT,
@ -54,6 +59,10 @@ import {
import { setupProviders } from "./onboard-providers.js"; import { setupProviders } from "./onboard-providers.js";
import { promptRemoteGatewayConfig } from "./onboard-remote.js"; import { promptRemoteGatewayConfig } from "./onboard-remote.js";
import { setupSkills } from "./onboard-skills.js"; import { setupSkills } from "./onboard-skills.js";
import {
applyOpenAICodexModelDefault,
OPENAI_CODEX_DEFAULT_MODEL,
} from "./openai-codex-model-default.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
type WizardSection = type WizardSection =
@ -234,6 +243,7 @@ async function promptAuthConfig(
message: "Model/auth choice", message: "Model/auth choice",
options: [ options: [
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
{ value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" },
{ {
value: "antigravity", value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
@ -244,7 +254,7 @@ async function promptAuthConfig(
], ],
}), }),
runtime, runtime,
) as "oauth" | "antigravity" | "apiKey" | "minimax" | "skip"; ) as "oauth" | "openai-codex" | "antigravity" | "apiKey" | "minimax" | "skip";
let next = cfg; let next = cfg;
@ -286,6 +296,79 @@ async function promptAuthConfig(
spin.stop("OAuth failed"); spin.stop("OAuth failed");
runtime.error(String(err)); runtime.error(String(err));
} }
} else if (authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
].join("\n")
: [
"Browser will open for OpenAI authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"OpenAI OAuth uses localhost:1455 for the callback.",
].join("\n"),
"OpenAI Codex OAuth",
);
const spin = spinner();
spin.start("Starting OAuth flow…");
let manualCodePromise: Promise<string> | undefined;
try {
const creds = await loginOpenAICodex({
onAuth: async ({ url }) => {
if (isRemote) {
spin.message("OAuth URL ready (see below)…");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
manualCodePromise = text({
message: "Paste the redirect URL (or authorization code)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}).then((value) => String(guardCancel(value, runtime)));
} else {
spin.message("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
onPrompt: async (prompt) => {
if (manualCodePromise) return manualCodePromise;
const code = guardCancel(
await text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
return String(code);
},
onProgress: (msg) => spin.message(msg),
});
spin.stop("OpenAI OAuth complete");
if (creds) {
await writeOAuthCredentials(
"openai-codex" as unknown as OAuthProvider,
creds,
);
next = applyAuthProfileConfig(next, {
profileId: "openai-codex:default",
provider: "openai-codex",
mode: "oauth",
});
const applied = applyOpenAICodexModelDefault(next);
next = applied.next;
if (applied.changed) {
note(
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
"Model configured",
);
}
}
} catch (err) {
spin.stop("OpenAI OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "antigravity") { } else if (authChoice === "antigravity") {
const isRemote = isRemoteEnvironment(); const isRemote = isRemoteEnvironment();
note( note(

View File

@ -44,16 +44,25 @@ export function applyAuthProfileConfig(
...(params.email ? { email: params.email } : {}), ...(params.email ? { email: params.email } : {}),
}, },
}; };
const order = { ...cfg.auth?.order };
const list = order[params.provider] ? [...order[params.provider]] : []; // Only maintain `auth.order` when the user explicitly configured it.
if (!list.includes(params.profileId)) list.push(params.profileId); // Default behavior: no explicit order -> resolveAuthProfileOrder can round-robin by lastUsed.
order[params.provider] = list; const existingProviderOrder = cfg.auth?.order?.[params.provider];
const order =
existingProviderOrder !== undefined
? {
...cfg.auth?.order,
[params.provider]: existingProviderOrder.includes(params.profileId)
? existingProviderOrder
: [...existingProviderOrder, params.profileId],
}
: cfg.auth?.order;
return { return {
...cfg, ...cfg,
auth: { auth: {
...cfg.auth, ...cfg.auth,
profiles, profiles,
order, ...(order ? { order } : {}),
}, },
}; };
} }

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
applyOpenAICodexModelDefault,
OPENAI_CODEX_DEFAULT_MODEL,
} from "./openai-codex-model-default.js";
describe("applyOpenAICodexModelDefault", () => {
it("sets openai-codex default when model is unset", () => {
const cfg: ClawdbotConfig = { agent: {} };
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
primary: OPENAI_CODEX_DEFAULT_MODEL,
});
});
it("sets openai-codex default when model is openai/*", () => {
const cfg: ClawdbotConfig = { agent: { model: "openai/gpt-5.2" } };
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
primary: OPENAI_CODEX_DEFAULT_MODEL,
});
});
it("does not override openai-codex/*", () => {
const cfg: ClawdbotConfig = { agent: { model: "openai-codex/gpt-5.2" } };
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
});
it("does not override non-openai models", () => {
const cfg: ClawdbotConfig = {
agent: { model: "anthropic/claude-opus-4-5" },
};
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
});
});

View File

@ -0,0 +1,46 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { AgentModelListConfig } from "../config/types.js";
export const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.2";
function shouldSetOpenAICodexModel(model?: string): boolean {
const trimmed = model?.trim();
if (!trimmed) return true;
const normalized = trimmed.toLowerCase();
if (normalized.startsWith("openai-codex/")) return false;
if (normalized.startsWith("openai/")) return true;
return normalized === "gpt" || normalized === "gpt-mini";
}
function resolvePrimaryModel(
model?: AgentModelListConfig | string,
): string | undefined {
if (typeof model === "string") return model;
if (model && typeof model === "object" && typeof model.primary === "string") {
return model.primary;
}
return undefined;
}
export function applyOpenAICodexModelDefault(cfg: ClawdbotConfig): {
next: ClawdbotConfig;
changed: boolean;
} {
const current = resolvePrimaryModel(cfg.agent?.model);
if (!shouldSetOpenAICodexModel(current)) {
return { next: cfg, changed: false };
}
return {
next: {
...cfg,
agent: {
...cfg.agent,
model:
cfg.agent?.model && typeof cfg.agent.model === "object"
? { ...cfg.agent.model, primary: OPENAI_CODEX_DEFAULT_MODEL }
: { primary: OPENAI_CODEX_DEFAULT_MODEL },
},
},
changed: true,
};
}

View File

@ -26,6 +26,8 @@ export type SessionChatType = "direct" | "group" | "room";
export type SessionEntry = { export type SessionEntry = {
sessionId: string; sessionId: string;
updatedAt: number; updatedAt: number;
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */
spawnedBy?: string;
systemSent?: boolean; systemSent?: boolean;
abortedLastRun?: boolean; abortedLastRun?: boolean;
chatType?: SessionChatType; chatType?: SessionChatType;

View File

@ -77,6 +77,8 @@ export type AgentElevatedAllowFromConfig = {
}; };
export type WhatsAppConfig = { export type WhatsAppConfig = {
/** Optional per-account WhatsApp configuration (multi-account). */
accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional allowlist for WhatsApp direct chats (E.164). */ /** Optional allowlist for WhatsApp direct chats (E.164). */
allowFrom?: string[]; allowFrom?: string[];
/** Optional allowlist for WhatsApp group senders (E.164). */ /** Optional allowlist for WhatsApp group senders (E.164). */
@ -98,6 +100,23 @@ export type WhatsAppConfig = {
>; >;
}; };
export type WhatsAppAccountConfig = {
/** If false, do not start this WhatsApp account provider. Default: true. */
enabled?: boolean;
/** Override auth directory (Baileys multi-file auth state). */
authDir?: string;
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
textChunkLimit?: number;
groups?: Record<
string,
{
requireMention?: boolean;
}
>;
};
export type BrowserProfileConfig = { export type BrowserProfileConfig = {
/** CDP port for this profile. Allocated once at creation, persisted permanently. */ /** CDP port for this profile. Allocated once at creation, persisted permanently. */
cdpPort?: number; cdpPort?: number;
@ -488,6 +507,37 @@ export type RoutingConfig = {
timeoutSeconds?: number; timeoutSeconds?: number;
}; };
groupChat?: GroupChatConfig; groupChat?: GroupChatConfig;
/** Default agent id when no binding matches. Default: "main". */
defaultAgentId?: string;
agentToAgent?: {
/** Enable agent-to-agent messaging tools. Default: false. */
enabled?: boolean;
/** Allowlist of agent ids or patterns (implementation-defined). */
allow?: string[];
};
agents?: Record<
string,
{
workspace?: string;
agentDir?: string;
model?: string;
sandbox?: {
mode?: "off" | "non-main" | "all";
perSession?: boolean;
workspaceRoot?: string;
};
}
>;
bindings?: Array<{
agentId: string;
match: {
surface: string;
surfaceAccountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string;
teamId?: string;
};
}>;
queue?: { queue?: {
mode?: QueueMode; mode?: QueueMode;
bySurface?: QueueModeBySurface; bySurface?: QueueModeBySurface;
@ -836,6 +886,12 @@ export type ClawdbotConfig = {
sandbox?: { sandbox?: {
/** Enable sandboxing for sessions. */ /** Enable sandboxing for sessions. */
mode?: "off" | "non-main" | "all"; mode?: "off" | "non-main" | "all";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Use one container per session (recommended for hard isolation). */ /** Use one container per session (recommended for hard isolation). */
perSession?: boolean; perSession?: boolean;
/** Root directory for sandbox workspaces. */ /** Root directory for sandbox workspaces. */

View File

@ -201,6 +201,61 @@ const RoutingSchema = z
.object({ .object({
groupChat: GroupChatSchema, groupChat: GroupChatSchema,
transcribeAudio: TranscribeAudioSchema, transcribeAudio: TranscribeAudioSchema,
defaultAgentId: z.string().optional(),
agentToAgent: z
.object({
enabled: z.boolean().optional(),
allow: z.array(z.string()).optional(),
})
.optional(),
agents: z
.record(
z.string(),
z
.object({
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: z.string().optional(),
sandbox: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("non-main"),
z.literal("all"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
})
.optional(),
})
.optional(),
)
.optional(),
bindings: z
.array(
z.object({
agentId: z.string(),
match: z.object({
surface: z.string(),
surfaceAccountId: z.string().optional(),
peer: z
.object({
kind: z.union([
z.literal("dm"),
z.literal("group"),
z.literal("channel"),
]),
id: z.string(),
})
.optional(),
guildId: z.string().optional(),
teamId: z.string().optional(),
}),
}),
)
.optional(),
queue: z queue: z
.object({ .object({
mode: QueueModeSchema.optional(), mode: QueueModeSchema.optional(),
@ -504,6 +559,9 @@ export const ClawdbotSchema = z.object({
mode: z mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")]) .union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(), .optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
perSession: z.boolean().optional(), perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(), workspaceRoot: z.string().optional(),
docker: z docker: z
@ -608,6 +666,32 @@ export const ClawdbotSchema = z.object({
.optional(), .optional(),
whatsapp: z whatsapp: z
.object({ .object({
accounts: z
.record(
z.string(),
z
.object({
enabled: z.boolean().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
})
.optional(),
)
.optional(),
allowFrom: z.array(z.string()).optional(), allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"), groupPolicy: GroupPolicySchema.optional().default("open"),

View File

@ -157,7 +157,7 @@ export function handleControlUiHttpRequest(
res.statusCode = 503; res.statusCode = 503;
res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end( res.end(
"Control UI assets not found. Build them with `pnpm ui:build` (or run `pnpm ui:dev` during development).", "Control UI assets not found. Build them with `bun run ui:build` (or run `bun run ui:dev` during development).",
); );
return true; return true;
} }

View File

@ -311,6 +311,7 @@ export const SessionsListParamsSchema = Type.Object(
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
spawnedBy: Type.Optional(NonEmptyString),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@ -322,6 +323,7 @@ export const SessionsPatchParamsSchema = Type.Object(
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
sendPolicy: Type.Optional( sendPolicy: Type.Optional(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
), ),

View File

@ -349,6 +349,52 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
} }
: { sessionId: randomUUID(), updatedAt: now }; : { sessionId: randomUUID(), updatedAt: now };
if ("spawnedBy" in p) {
const raw = p.spawnedBy;
if (raw === null) {
if (existing?.spawnedBy) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "spawnedBy cannot be cleared once set",
},
};
}
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid spawnedBy: empty",
},
};
}
if (!key.startsWith("subagent:")) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message:
"spawnedBy is only supported for subagent:* sessions",
},
};
}
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "spawnedBy cannot be changed once set",
},
};
}
next.spawnedBy = trimmed;
}
}
if ("thinkingLevel" in p) { if ("thinkingLevel" in p) {
const raw = p.thinkingLevel; const raw = p.thinkingLevel;
if (raw === null) { if (raw === null) {

View File

@ -110,6 +110,56 @@ export const sessionsHandlers: GatewayRequestHandlers = {
} }
: { sessionId: randomUUID(), updatedAt: now }; : { sessionId: randomUUID(), updatedAt: now };
if ("spawnedBy" in p) {
const raw = p.spawnedBy;
if (raw === null) {
if (existing?.spawnedBy) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"spawnedBy cannot be cleared once set",
),
);
return;
}
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid spawnedBy: empty"),
);
return;
}
if (!key.startsWith("subagent:")) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"spawnedBy is only supported for subagent:* sessions",
),
);
return;
}
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"spawnedBy cannot be changed once set",
),
);
return;
}
next.spawnedBy = trimmed;
}
}
if ("thinkingLevel" in p) { if ("thinkingLevel" in p) {
const raw = p.thinkingLevel; const raw = p.thinkingLevel;
if (raw === null) { if (raw === null) {

View File

@ -327,7 +327,9 @@ describe("gateway server cron", () => {
: ""; : "";
expect(storePath).toContain("jobs.json"); expect(storePath).toContain("jobs.json");
const atMs = Date.now() + 80; // Avoid races: if we schedule too close to "now", the cron runner can
// finish before we start listening for the "finished" event.
const atMs = Date.now() + 1000;
const addRes = await rpcReq(ws, "cron.add", { const addRes = await rpcReq(ws, "cron.add", {
name: "auto run test", name: "auto run test",
enabled: true, enabled: true,
@ -345,8 +347,12 @@ describe("gateway server cron", () => {
type: "event"; type: "event";
event: string; event: string;
payload?: { jobId?: string; action?: string; status?: string } | null; payload?: { jobId?: string; action?: string; status?: string } | null;
}>((resolve) => { }>((resolve, reject) => {
const timeout = setTimeout(() => resolve(null as never), 8000); const timeout = setTimeout(() => {
reject(
new Error(`timeout waiting for cron finished event: ${jobId}`),
);
}, 8000);
ws.on("message", (data) => { ws.on("message", (data) => {
const obj = JSON.parse(decodeWsData(data)); const obj = JSON.parse(decodeWsData(data));
if ( if (

View File

@ -53,6 +53,11 @@ describe("gateway server sessions", () => {
updatedAt: now - 120_000, updatedAt: now - 120_000,
totalTokens: 50, totalTokens: 50,
}, },
"subagent:one": {
sessionId: "sess-subagent",
updatedAt: now - 120_000,
spawnedBy: "main",
},
global: { global: {
sessionId: "sess-global", sessionId: "sess-global",
updatedAt: now - 10_000, updatedAt: now - 10_000,
@ -148,6 +153,31 @@ describe("gateway server sessions", () => {
expect(main2?.verboseLevel).toBeUndefined(); expect(main2?.verboseLevel).toBeUndefined();
expect(main2?.sendPolicy).toBe("deny"); expect(main2?.sendPolicy).toBe("deny");
const spawnedOnly = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: true,
includeUnknown: true,
spawnedBy: "main",
});
expect(spawnedOnly.ok).toBe(true);
expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual([
"subagent:one",
]);
const spawnedPatched = await rpcReq<{
ok: true;
entry: { spawnedBy?: string };
}>(ws, "sessions.patch", { key: "subagent:two", spawnedBy: "main" });
expect(spawnedPatched.ok).toBe(true);
expect(spawnedPatched.payload?.entry.spawnedBy).toBe("main");
const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", {
key: "main",
spawnedBy: "main",
});
expect(spawnedPatchedInvalidKey.ok).toBe(false);
piSdkMock.enabled = true; piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const modelPatched = await rpcReq<{ const modelPatched = await rpcReq<{

View File

@ -227,6 +227,7 @@ export function listSessionsFromStore(params: {
const includeGlobal = opts.includeGlobal === true; const includeGlobal = opts.includeGlobal === true;
const includeUnknown = opts.includeUnknown === true; const includeUnknown = opts.includeUnknown === true;
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
const activeMinutes = const activeMinutes =
typeof opts.activeMinutes === "number" && typeof opts.activeMinutes === "number" &&
Number.isFinite(opts.activeMinutes) Number.isFinite(opts.activeMinutes)
@ -239,6 +240,11 @@ export function listSessionsFromStore(params: {
if (!includeUnknown && key === "unknown") return false; if (!includeUnknown && key === "unknown") return false;
return true; return true;
}) })
.filter(([key, entry]) => {
if (!spawnedBy) return true;
if (key === "unknown" || key === "global") return false;
return entry?.spawnedBy === spawnedBy;
})
.map(([key, entry]) => { .map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null; const updatedAt = entry?.updatedAt ?? null;
const input = entry?.inputTokens ?? 0; const input = entry?.inputTokens ?? 0;

View File

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { runCommandWithTimeout, runExec } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
export function resolveControlUiRepoRoot( export function resolveControlUiRepoRoot(
@ -76,7 +76,7 @@ export async function ensureControlUiAssetsBuilt(
return { return {
ok: false, ok: false,
built: false, built: false,
message: `${hint}. Build them with \`pnpm ui:build\`.`, message: `${hint}. Build them with \`bun run ui:build\`.`,
}; };
} }
@ -85,35 +85,28 @@ export async function ensureControlUiAssetsBuilt(
return { ok: true, built: false }; return { ok: true, built: false };
} }
const pnpmWhich = process.platform === "win32" ? "where" : "which"; const uiScript = path.join(repoRoot, "scripts", "ui.js");
const pnpm = await runExec(pnpmWhich, ["pnpm"]) if (!fs.existsSync(uiScript)) {
.then(
(r) =>
r.stdout
.split(/\r?\n/g)
.map((l) => l.trim())
.find(Boolean) ?? "",
)
.catch(() => "");
if (!pnpm) {
return { return {
ok: false, ok: false,
built: false, built: false,
message: message: `Control UI assets missing but ${uiScript} is unavailable.`,
"Control UI assets not found and pnpm missing. Install pnpm, then run `pnpm ui:build`.",
}; };
} }
runtime.log("Control UI assets missing; building (pnpm ui:build)…"); runtime.log("Control UI assets missing; building (ui:build)…");
const ensureInstalled = !fs.existsSync( const ensureInstalled = !fs.existsSync(
path.join(repoRoot, "ui", "node_modules"), path.join(repoRoot, "ui", "node_modules"),
); );
if (ensureInstalled) { if (ensureInstalled) {
const install = await runCommandWithTimeout([pnpm, "ui:install"], { const install = await runCommandWithTimeout(
cwd: repoRoot, [process.execPath, uiScript, "install"],
timeoutMs: opts?.timeoutMs ?? 10 * 60_000, {
}); cwd: repoRoot,
timeoutMs: opts?.timeoutMs ?? 10 * 60_000,
},
);
if (install.code !== 0) { if (install.code !== 0) {
return { return {
ok: false, ok: false,
@ -123,10 +116,13 @@ export async function ensureControlUiAssetsBuilt(
} }
} }
const build = await runCommandWithTimeout([pnpm, "ui:build"], { const build = await runCommandWithTimeout(
cwd: repoRoot, [process.execPath, uiScript, "build"],
timeoutMs: opts?.timeoutMs ?? 10 * 60_000, {
}); cwd: repoRoot,
timeoutMs: opts?.timeoutMs ?? 10 * 60_000,
},
);
if (build.code !== 0) { if (build.code !== 0) {
return { return {
ok: false, ok: false,

View File

@ -52,6 +52,10 @@ import type {
OnboardOptions, OnboardOptions,
ResetScope, ResetScope,
} from "../commands/onboard-types.js"; } from "../commands/onboard-types.js";
import {
applyOpenAICodexModelDefault,
OPENAI_CODEX_DEFAULT_MODEL,
} from "../commands/openai-codex-model-default.js";
import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js"; import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
@ -60,7 +64,6 @@ import {
resolveGatewayPort, resolveGatewayPort,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import type { AgentModelListConfig } from "../config/types.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
@ -70,50 +73,6 @@ import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js"; import { resolveUserPath, sleep } from "../utils.js";
import type { WizardPrompter } from "./prompts.js"; import type { WizardPrompter } from "./prompts.js";
const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.2";
function shouldSetOpenAICodexModel(model?: string): boolean {
const trimmed = model?.trim();
if (!trimmed) return true;
const normalized = trimmed.toLowerCase();
if (normalized.startsWith("openai-codex/")) return false;
if (normalized.startsWith("openai/")) return true;
return normalized === "gpt" || normalized === "gpt-mini";
}
function resolvePrimaryModel(
model?: AgentModelListConfig | string,
): string | undefined {
if (typeof model === "string") return model;
if (model && typeof model === "object" && typeof model.primary === "string") {
return model.primary;
}
return undefined;
}
function applyOpenAICodexModelDefault(cfg: ClawdbotConfig): {
next: ClawdbotConfig;
changed: boolean;
} {
const current = resolvePrimaryModel(cfg.agent?.model);
if (!shouldSetOpenAICodexModel(current)) {
return { next: cfg, changed: false };
}
return {
next: {
...cfg,
agent: {
...cfg.agent,
model:
cfg.agent?.model && typeof cfg.agent.model === "object"
? { ...cfg.agent.model, primary: OPENAI_CODEX_DEFAULT_MODEL }
: { primary: OPENAI_CODEX_DEFAULT_MODEL },
},
},
changed: true,
};
}
async function warnIfModelConfigLooksOff( async function warnIfModelConfigLooksOff(
config: ClawdbotConfig, config: ClawdbotConfig,
prompter: WizardPrompter, prompter: WizardPrompter,

View File

@ -825,6 +825,13 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.shell--chat-focus .chat-compose {
bottom: var(--shell-pad);
padding-bottom: calc(var(--shell-pad) + env(safe-area-inset-bottom, 0px));
border-bottom-left-radius: 18px;
border-bottom-right-radius: 18px;
}
.chat-compose__field { .chat-compose__field {
gap: 4px; gap: 4px;
} }