diff --git a/CHANGELOG.md b/CHANGELOG.md index 021462476..ff9b73b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - 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. - 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`). - 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. @@ -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`. - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. +- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`). - Control UI: show a reading indicator bubble while the assistant is responding. - Control UI: animate reading indicator dots (honors reduced-motion). - Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). - Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268. - Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274. - Control UI: add Chat focus mode toggle to collapse header + sidebar. +- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported). - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). - 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. - Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249. - Auth: rotate across multiple OAuth profiles with cooldown tracking and email-based profile IDs. Thanks @mukhtharcm for PR #269. +- Auth: fix multi-account OAuth rotation so round-robin alternates instead of pinning to lastGood. Thanks @mukhtharcm for PR #281. +- Configure: stop auto-writing `auth.order` for newly added auth profiles (round-robin default unless explicitly pinned). - Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242. - Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241. - Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220. @@ -133,7 +138,7 @@ - Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`. - Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env). - Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). -- Onboarding: when running from source, auto-build missing Control UI assets (`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). - Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. - Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. diff --git a/README.md b/README.md index 9bbc44217..2c8dccf6f 100644 --- a/README.md +++ b/README.md @@ -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. -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**. Works with npm, pnpm, or bun. @@ -29,7 +29,7 @@ Works with npm, pnpm, or bun. - **Anthropic** (Claude Pro/Max) - **OpenAI** (ChatGPT/Codex) -Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawdbot.com/onboarding). +Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://github.com/clawdbot/clawdbot/blob/main/docs/onboarding.md). ## Recommended setup (from source) @@ -40,10 +40,10 @@ Do **not** download prebuilt binaries. Build from source. git clone https://github.com/clawdbot/clawdbot.git cd clawdbot -pnpm install -pnpm build -pnpm ui:build -pnpm clawdbot onboard +bun install +bun run build +bun run ui:build +bun run clawdbot onboard ``` ## Quick start (from source) @@ -88,45 +88,45 @@ If you run from source, prefer `bun run clawdbot …` or `pnpm clawdbot …` (no ## Highlights -- **[Local-first Gateway](https://docs.clawdbot.com/gateway)** — 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. -- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — 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). -- **[First-class tools](https://docs.clawdbot.com/tools)** — 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). -- **[Onboarding](https://docs.clawdbot.com/wizard) + [skills](https://docs.clawdbot.com/skills)** — wizard-driven setup with bundled/managed/workspace skills. +- **[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://github.com/clawdbot/clawdbot/blob/main/docs/surface.md)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android. +- **[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://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://github.com/clawdbot/clawdbot/blob/main/docs/tools.md)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. +- **[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://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 ### 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). -- [CLI surface](https://docs.clawdbot.com/agent-send): gateway, agent, send, [wizard](https://docs.clawdbot.com/wizard), and [doctor](https://docs.clawdbot.com/doctor). -- [Pi agent runtime](https://docs.clawdbot.com/agent) 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). -- [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). +- [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://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://github.com/clawdbot/clawdbot/blob/main/docs/agent.md) in RPC mode with tool streaming and block streaming. +- [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://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 -- [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). -- [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). +- [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://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 -- [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. -- [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. -- [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. -- [macOS node mode](https://docs.clawdbot.com/nodes): system.run/notify + canvas/camera exposure. +- [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://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://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://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md): system.run/notify + canvas/camera exposure. ### Tools + automation -- [Browser control](https://docs.clawdbot.com/browser): 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. -- [Nodes](https://docs.clawdbot.com/nodes): camera snap/clip, screen record, [location.get](https://docs.clawdbot.com/location-command), notifications. -- [Cron + wakeups](https://docs.clawdbot.com/cron); [webhooks](https://docs.clawdbot.com/webhook); [Gmail Pub/Sub](https://docs.clawdbot.com/gmail-pubsub). -- [Skills platform](https://docs.clawdbot.com/skills): bundled, managed, and workspace skills with install gating + UI. +- [Browser control](https://github.com/clawdbot/clawdbot/blob/main/docs/browser.md): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles. +- [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://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://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://github.com/clawdbot/clawdbot/blob/main/docs/skills.md): bundled, managed, and workspace skills with install gating + UI. ### Ops + packaging -- [Control UI](https://docs.clawdbot.com/web) + [WebChat](https://docs.clawdbot.com/webchat) 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. -- [Nix mode](https://docs.clawdbot.com/nix) for declarative config; [Docker](https://docs.clawdbot.com/docker)-based installs. -- [Doctor](https://docs.clawdbot.com/doctor) migrations, [logging](https://docs.clawdbot.com/logging). +- [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://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://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://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) @@ -148,12 +148,12 @@ WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat ## 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)). -- **[Tailscale exposure](https://docs.clawdbot.com/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawdbot.com/remote)). -- **[Browser control](https://docs.clawdbot.com/browser)** — clawd‑managed Chrome/Chromium with CDP control. -- **[Canvas + A2UI](https://docs.clawdbot.com/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui)). -- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always‑on speech and continuous conversation. -- **[Nodes](https://docs.clawdbot.com/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`. +- **[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://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://github.com/clawdbot/clawdbot/blob/main/docs/browser.md)** — clawd‑managed Chrome/Chromium with CDP control. +- **[Canvas + A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui)). +- **[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 and continuous conversation. +- **[Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`. ## Tailscale access (Gateway dashboard) @@ -169,7 +169,7 @@ Notes: - Funnel refuses to start unless `gateway.auth.mode: "password"` is set. - Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown. -Details: [Tailscale guide](https://docs.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) @@ -179,7 +179,7 @@ It’s perfectly fine to run the Gateway on a small Linux instance. Clients (mac - **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`. In short: bash runs where the Gateway lives; device actions run where the device lives. -Details: [Remote access](https://docs.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 @@ -194,7 +194,7 @@ Elevated bash (host permissions) is separate from macOS TCC: - Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted. - Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`. -Details: [Nodes](https://docs.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) @@ -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_send` — message another session; optional reply‑back ping‑pong + 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) @@ -249,13 +249,13 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see - Voice trigger forwarding + Canvas surface. - 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) - Pairs via the same Bridge + pairing flow as iOS. - 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 @@ -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) @@ -283,15 +283,15 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults): - **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions. - **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; 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`). - Allowlist who can talk to the assistant via `whatsapp.allowFrom`. - If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all. -### [Telegram](https://docs.clawdbot.com/telegram) +### [Telegram](https://github.com/clawdbot/clawdbot/blob/main/docs/telegram.md) - 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. @@ -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`). -### [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). - 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. -### [iMessage](https://docs.clawdbot.com/imessage) +### [iMessage](https://github.com/clawdbot/clawdbot/blob/main/docs/imessage.md) - macOS only; Messages must be signed in. - 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. @@ -349,69 +349,69 @@ Browser control (optional): ## Docs Use these when you’re past the onboarding flow and want the deeper reference. -- [Start with the docs index for navigation and “what’s where.”](https://docs.clawdbot.com/) -- [Read the architecture overview for the gateway + protocol model.](https://docs.clawdbot.com/architecture) -- [Use the full configuration reference when you need every key and example.](https://docs.clawdbot.com/configuration) -- [Run the Gateway by the book with the operational runbook.](https://docs.clawdbot.com/gateway) -- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawdbot.com/web) -- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawdbot.com/remote) -- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawdbot.com/wizard) -- [Wire external triggers via the webhook surface.](https://docs.clawdbot.com/webhook) -- [Set up Gmail Pub/Sub triggers.](https://docs.clawdbot.com/gmail-pubsub) -- [Learn the macOS menu bar companion details.](https://docs.clawdbot.com/mac/menu-bar) -- [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) -- [Debug common failures with the troubleshooting guide.](https://docs.clawdbot.com/troubleshooting) -- [Review security guidance before exposing anything.](https://docs.clawdbot.com/security) +- [Start with the docs index for navigation and “what’s where.”](https://github.com/clawdbot/clawdbot/blob/main/docs/index.md) +- [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://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md) +- [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://github.com/clawdbot/clawdbot/blob/main/docs/web.md) +- [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://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md) +- [Wire external triggers via the webhook surface.](https://github.com/clawdbot/clawdbot/blob/main/docs/webhook.md) +- [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://github.com/clawdbot/clawdbot/blob/main/docs/mac/menu-bar.md) +- [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://github.com/clawdbot/clawdbot/blob/main/docs/troubleshooting.md) +- [Review security guidance before exposing anything.](https://github.com/clawdbot/clawdbot/blob/main/docs/security.md) ## Advanced docs (discovery + control) -- [Discovery + transports](https://docs.clawdbot.com/discovery) -- [Bonjour/mDNS](https://docs.clawdbot.com/bonjour) -- [Gateway pairing](https://docs.clawdbot.com/gateway/pairing) -- [Remote gateway README](https://docs.clawdbot.com/remote-gateway-readme) -- [Control UI](https://docs.clawdbot.com/control-ui) -- [Dashboard](https://docs.clawdbot.com/dashboard) +- [Discovery + transports](https://github.com/clawdbot/clawdbot/blob/main/docs/discovery.md) +- [Bonjour/mDNS](https://github.com/clawdbot/clawdbot/blob/main/docs/bonjour.md) +- [Gateway pairing](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway/pairing.md) +- [Remote gateway README](https://github.com/clawdbot/clawdbot/blob/main/docs/remote-gateway-readme.md) +- [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/control-ui.md) +- [Dashboard](https://github.com/clawdbot/clawdbot/blob/main/docs/dashboard.md) ## Operations & troubleshooting -- [Health checks](https://docs.clawdbot.com/health) -- [Gateway lock](https://docs.clawdbot.com/gateway-lock) -- [Background process](https://docs.clawdbot.com/background-process) -- [Browser troubleshooting (Linux)](https://docs.clawdbot.com/browser-linux-troubleshooting) -- [Logging](https://docs.clawdbot.com/logging) +- [Health checks](https://github.com/clawdbot/clawdbot/blob/main/docs/health.md) +- [Gateway lock](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway-lock.md) +- [Background process](https://github.com/clawdbot/clawdbot/blob/main/docs/background-process.md) +- [Browser troubleshooting (Linux)](https://github.com/clawdbot/clawdbot/blob/main/docs/browser-linux-troubleshooting.md) +- [Logging](https://github.com/clawdbot/clawdbot/blob/main/docs/logging.md) ## Deep dives -- [Agent loop](https://docs.clawdbot.com/agent-loop) -- [Presence](https://docs.clawdbot.com/presence) -- [TypeBox schemas](https://docs.clawdbot.com/typebox) -- [RPC adapters](https://docs.clawdbot.com/rpc) -- [Queue](https://docs.clawdbot.com/queue) +- [Agent loop](https://github.com/clawdbot/clawdbot/blob/main/docs/agent-loop.md) +- [Presence](https://github.com/clawdbot/clawdbot/blob/main/docs/presence.md) +- [TypeBox schemas](https://github.com/clawdbot/clawdbot/blob/main/docs/typebox.md) +- [RPC adapters](https://github.com/clawdbot/clawdbot/blob/main/docs/rpc.md) +- [Queue](https://github.com/clawdbot/clawdbot/blob/main/docs/queue.md) ## Workspace & skills -- [Skills config](https://docs.clawdbot.com/skills-config) -- [Default AGENTS](https://docs.clawdbot.com/AGENTS.default) -- [Templates: AGENTS](https://docs.clawdbot.com/templates/AGENTS) -- [Templates: BOOTSTRAP](https://docs.clawdbot.com/templates/BOOTSTRAP) -- [Templates: IDENTITY](https://docs.clawdbot.com/templates/IDENTITY) -- [Templates: SOUL](https://docs.clawdbot.com/templates/SOUL) -- [Templates: TOOLS](https://docs.clawdbot.com/templates/TOOLS) -- [Templates: USER](https://docs.clawdbot.com/templates/USER) +- [Skills config](https://github.com/clawdbot/clawdbot/blob/main/docs/skills-config.md) +- [Default AGENTS](https://github.com/clawdbot/clawdbot/blob/main/docs/AGENTS.default.md) +- [Templates: AGENTS](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/AGENTS.md) +- [Templates: BOOTSTRAP](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/BOOTSTRAP.md) +- [Templates: IDENTITY](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/IDENTITY.md) +- [Templates: SOUL](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/SOUL.md) +- [Templates: TOOLS](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/TOOLS.md) +- [Templates: USER](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/USER.md) ## Platform internals -- [macOS dev setup](https://docs.clawdbot.com/mac/dev-setup) -- [macOS menu bar](https://docs.clawdbot.com/mac/menu-bar) -- [macOS voice wake](https://docs.clawdbot.com/mac/voicewake) -- [iOS node](https://docs.clawdbot.com/ios) -- [Android node](https://docs.clawdbot.com/android) -- [Windows app](https://docs.clawdbot.com/windows) -- [Linux app](https://docs.clawdbot.com/linux) +- [macOS dev setup](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/dev-setup.md) +- [macOS menu bar](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/menu-bar.md) +- [macOS voice wake](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/voicewake.md) +- [iOS node](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md) +- [Android node](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md) +- [Windows app](https://github.com/clawdbot/clawdbot/blob/main/docs/windows.md) +- [Linux app](https://github.com/clawdbot/clawdbot/blob/main/docs/linux.md) ## 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 don’t want auto-start. @@ -442,5 +442,5 @@ Thanks to all clawtributors: scald sreekaransrinath ratulsarna osolmaz conhecendocontato hrdwdmrbl jayhickey jamesgroat gtsifrikas djangonavarro220 azade-c andranik-sahakyan adamgall jalehman jarvis-medmatic mneves75 regenrek tobiasbischoff MSch obviyus dbhurley - Asleep123 Iamadig imfing kitze nachoiacovino VACInc + Asleep123 Iamadig imfing kitze nachoiacovino VACInc cash-echo-bot claude kiranjd pcty-nextgen-service-account

diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 85ee13fdb..77d74cf85 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -655,23 +655,27 @@ public struct SessionsListParams: Codable, Sendable { public let activeminutes: Int? public let includeglobal: Bool? public let includeunknown: Bool? + public let spawnedby: String? public init( limit: Int?, activeminutes: Int?, includeglobal: Bool?, - includeunknown: Bool? + includeunknown: Bool?, + spawnedby: String? ) { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown + self.spawnedby = spawnedby } private enum CodingKeys: String, CodingKey { case limit case activeminutes = "activeMinutes" case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" + case spawnedby = "spawnedBy" } } @@ -681,6 +685,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let verboselevel: AnyCodable? public let elevatedlevel: AnyCodable? public let model: AnyCodable? + public let spawnedby: AnyCodable? public let sendpolicy: AnyCodable? public let groupactivation: AnyCodable? @@ -690,6 +695,7 @@ public struct SessionsPatchParams: Codable, Sendable { verboselevel: AnyCodable?, elevatedlevel: AnyCodable?, model: AnyCodable?, + spawnedby: AnyCodable?, sendpolicy: AnyCodable?, groupactivation: AnyCodable? ) { @@ -698,6 +704,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.verboselevel = verboselevel self.elevatedlevel = elevatedlevel self.model = model + self.spawnedby = spawnedby self.sendpolicy = sendpolicy self.groupactivation = groupactivation } @@ -707,6 +714,7 @@ public struct SessionsPatchParams: Codable, Sendable { case verboselevel = "verboseLevel" case elevatedlevel = "elevatedLevel" case model + case spawnedby = "spawnedBy" case sendpolicy = "sendPolicy" case groupactivation = "groupActivation" } diff --git a/docs/control-ui.md b/docs/control-ui.md index 315e1415c..0e4fe0c32 100644 --- a/docs/control-ui.md +++ b/docs/control-ui.md @@ -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: ```bash -pnpm ui:install -pnpm ui:build +bun run ui:install +bun run ui:build ``` Optional absolute base (when you want fixed asset URLs): ```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): ```bash -pnpm ui:install -pnpm ui:dev +bun run ui:install +bun run ui:dev ``` Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`). diff --git a/docs/index.md b/docs/index.md index ef0abd887..88a2a33da 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ read_when:

GitHub · Releases · - Docs · + Docs · Clawd setup

diff --git a/docs/session-tool.md b/docs/session-tool.md index 253e0f7e4..272acac78 100644 --- a/docs/session-tool.md +++ b/docs/session-tool.md @@ -35,6 +35,7 @@ Parameters: Behavior: - `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. +- When running in a **sandboxed** agent session, session tools default to **spawned-only visibility** (see below). Row shape (JSON): - `key`: session key (string) @@ -131,5 +132,23 @@ Parameters: Behavior: - Starts a new `subagent:` session with `deliver: false`. - 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. - 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" + } + } +} +``` diff --git a/docs/subagents.md b/docs/subagents.md index 238fbf8be..0d66c85f4 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -13,6 +13,7 @@ Primary goals: - Parallelize “research / long task / slow tool” work without blocking the main run. - 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. +- Avoid nested fan-out: sub-agents cannot spawn sub-agents. ## 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-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve. - diff --git a/docs/web.md b/docs/web.md index aeb0967f1..6e1a7274a 100644 --- a/docs/web.md +++ b/docs/web.md @@ -110,6 +110,6 @@ Open: The Gateway serves static files from `dist/control-ui`. Build them with: ```bash -pnpm ui:install -pnpm ui:build +bun run ui:install +bun run ui:build ``` diff --git a/package.json b/package.json index 4e30de34a..1e2ba8f6d 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,9 @@ "docs:build": "cd docs && pnpm dlx mint broken-links", "build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts", "release:check": "bun scripts/release-check.ts", - "ui:install": "pnpm -C ui install", - "ui:dev": "pnpm -C ui dev", - "ui:build": "pnpm -C ui build", + "ui:install": "node scripts/ui.js install", + "ui:dev": "node scripts/ui.js dev", + "ui:build": "node scripts/ui.js build", "start": "bun src/entry.ts", "clawdbot": "bun src/entry.ts", "gateway:watch": "bun --watch src/entry.ts gateway --force", diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 7c7fe1b1f..b60a6cb75 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -146,8 +146,8 @@ else fi if [[ "${SKIP_UI_BUILD:-0}" != "1" ]]; then - echo "🖥 Building Control UI (pnpm ui:build)" - (cd "$ROOT_DIR" && pnpm ui:build) + echo "🖥 Building Control UI (ui:build)" + (cd "$ROOT_DIR" && node scripts/ui.js build) else echo "🖥 Skipping Control UI build (SKIP_UI_BUILD=1)" fi diff --git a/scripts/ui.js b/scripts/ui.js new file mode 100644 index 000000000..bb84ebbff --- /dev/null +++ b/scripts/ui.js @@ -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 [...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]); +} diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts index ea5d5fdcb..3b19dd8d5 100644 --- a/src/agents/auth-profiles.test.ts +++ b/src/agents/auth-profiles.test.ts @@ -50,13 +50,20 @@ describe("resolveAuthProfileOrder", () => { 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({ cfg, - store: { ...store, lastGood: { anthropic: "anthropic:work" } }, + store: { + ...store, + lastGood: { anthropic: "anthropic:work" }, + usageStats: { + "anthropic:default": { lastUsed: 100 }, + "anthropic:work": { lastUsed: 200 }, + }, + }, provider: "anthropic", }); - expect(order[0]).toBe("anthropic:work"); + expect(order[0]).toBe("anthropic:default"); }); it("uses explicit profiles when order is missing", () => { diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 43308674c..1d96c94ac 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -433,19 +433,14 @@ export function resolveAuthProfileOrder(params: { .filter(([, profile]) => profile.provider === provider) .map(([profileId]) => profileId) : []; - const lastGood = store.lastGood?.[provider]; const baseOrder = configuredOrder ?? (explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, provider)); 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]; return cred ? cred.provider === provider : true; }); @@ -453,21 +448,29 @@ export function resolveAuthProfileOrder(params: { for (const entry of filtered) { if (!deduped.includes(entry)) deduped.push(entry); } - if (preferredProfile && deduped.includes(preferredProfile)) { - const rest = deduped.filter((entry) => entry !== preferredProfile); - if (lastGood && rest.includes(lastGood)) { + + // If user specified explicit order in config, respect it exactly + if (configuredOrder && configuredOrder.length > 0) { + // Still put preferredProfile first if specified + if (preferredProfile && deduped.includes(preferredProfile)) { return [ preferredProfile, - lastGood, - ...rest.filter((entry) => entry !== lastGood), + ...deduped.filter((e) => e !== preferredProfile), ]; } - 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( diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index c5c35c1d0..b4400eba8 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -17,6 +17,7 @@ export function createClawdbotTools(options?: { browserControlUrl?: string; agentSessionKey?: string; agentSurface?: string; + sandboxed?: boolean; config?: ClawdbotConfig; }): AnyAgentTool[] { const imageTool = createImageTool({ config: options?.config }); @@ -28,15 +29,23 @@ export function createClawdbotTools(options?: { createDiscordTool(), createSlackTool(), createGatewayTool(), - createSessionsListTool(), - createSessionsHistoryTool(), + createSessionsListTool({ + agentSessionKey: options?.agentSessionKey, + sandboxed: options?.sandboxed, + }), + createSessionsHistoryTool({ + agentSessionKey: options?.agentSessionKey, + sandboxed: options?.sandboxed, + }), createSessionsSendTool({ agentSessionKey: options?.agentSessionKey, agentSurface: options?.agentSurface, + sandboxed: options?.sandboxed, }), createSessionsSpawnTool({ agentSessionKey: options?.agentSessionKey, agentSurface: options?.agentSurface, + sandboxed: options?.sandboxed, }), ...(imageTool ? [imageTool] : []), ]; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 4c5fc5f4f..f438002ce 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -556,6 +556,7 @@ export function createClawdbotCodingTools(options?: { browserControlUrl: sandbox?.browser?.controlUrl, agentSessionKey: options?.sessionKey, agentSurface: options?.surface, + sandboxed: !!sandbox, config: options?.config, }), ]; diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 6166f3349..b788e25a5 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -114,7 +114,17 @@ const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdbot-sbx-"; const DEFAULT_SANDBOX_WORKDIR = "/workspace"; const DEFAULT_SANDBOX_IDLE_HOURS = 24; 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 = [ "browser", "canvas", diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 38e7ee9e9..95f7a68ba 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -3,11 +3,23 @@ import { normalizeCronJobCreate, normalizeCronJobPatch, } from "../../cron/normalize.js"; -import { CronAddParamsSchema } from "../../gateway/protocol/schema.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.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([ Type.Object({ @@ -28,7 +40,7 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - job: CronAddParamsSchema, + job: Type.Object({}, { additionalProperties: true }), }), Type.Object({ action: Type.Literal("update"), @@ -36,7 +48,7 @@ const CronToolSchema = Type.Union([ gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), id: Type.String(), - patch: CronJobPatchSchema, + patch: Type.Object({}, { additionalProperties: true }), }), Type.Object({ action: Type.Literal("remove"), diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index d3ddd5534..9ed9e1470 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -17,7 +17,37 @@ const SessionsHistoryToolSchema = Type.Object({ includeTools: Type.Optional(Type.Boolean()), }); -export function createSessionsHistoryTool(): AnyAgentTool { +function resolveSandboxSessionToolsVisibility( + cfg: ReturnType, +) { + return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; +} + +async function isSpawnedSessionAllowed(params: { + requesterSessionKey: string; + targetSessionKey: string; +}): Promise { + try { + const list = (await callGateway({ + method: "sessions.list", + params: { + includeGlobal: false, + includeUnknown: false, + limit: 500, + spawnedBy: params.requesterSessionKey, + }, + })) as { sessions?: Array> }; + 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 { label: "Session History", name: "sessions_history", @@ -30,11 +60,37 @@ export function createSessionsHistoryTool(): AnyAgentTool { }); const cfg = loadConfig(); 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({ key: sessionKey, alias, 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 = typeof params.limit === "number" && Number.isFinite(params.limit) ? Math.max(1, Math.floor(params.limit)) diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 0209813f2..dc2dd14aa 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -44,7 +44,16 @@ const SessionsListToolSchema = Type.Object({ messageLimit: Type.Optional(Type.Integer({ minimum: 0 })), }); -export function createSessionsListTool(): AnyAgentTool { +function resolveSandboxSessionToolsVisibility( + cfg: ReturnType, +) { + return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; +} + +export function createSessionsListTool(opts?: { + agentSessionKey?: string; + sandboxed?: boolean; +}): AnyAgentTool { return { label: "Sessions", name: "sessions_list", @@ -54,6 +63,20 @@ export function createSessionsListTool(): AnyAgentTool { const params = args as Record; const cfg = loadConfig(); 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) => value.trim().toLowerCase(), @@ -86,8 +109,9 @@ export function createSessionsListTool(): AnyAgentTool { params: { limit, activeMinutes, - includeGlobal: true, - includeUnknown: true, + includeGlobal: !restrictToSpawned, + includeUnknown: !restrictToSpawned, + spawnedBy: restrictToSpawned ? requesterInternalKey : undefined, }, })) as { path?: string; diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index bcc732486..72183c896 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -33,6 +33,7 @@ const SessionsSendToolSchema = Type.Object({ export function createSessionsSendTool(opts?: { agentSessionKey?: string; agentSurface?: string; + sandboxed?: boolean; }): AnyAgentTool { return { label: "Session Send", @@ -47,11 +48,64 @@ export function createSessionsSendTool(opts?: { const message = readStringParam(params, "message", { required: true }); const cfg = loadConfig(); 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({ key: sessionKey, alias, 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> }; + 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 = typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds) diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 1464974c4..cd7a97f83 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -160,6 +160,7 @@ async function runSubagentAnnounceFlow(params: { export function createSessionsSpawnTool(opts?: { agentSessionKey?: string; agentSurface?: string; + sandboxed?: boolean; }): AnyAgentTool { return { label: "Sessions", @@ -185,6 +186,15 @@ export function createSessionsSpawnTool(opts?: { const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); 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 ? resolveInternalSessionKey({ key: requesterSessionKey, @@ -199,6 +209,17 @@ export function createSessionsSpawnTool(opts?: { }); 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({ requesterSessionKey, requesterSurface: opts?.agentSurface, diff --git a/src/commands/configure.ts b/src/commands/configure.ts index d65908f5a..98eca3125 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -10,7 +10,12 @@ import { spinner, text, } 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 { CONFIG_PATH_CLAWDBOT, @@ -54,6 +59,10 @@ import { import { setupProviders } from "./onboard-providers.js"; import { promptRemoteGatewayConfig } from "./onboard-remote.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"; type WizardSection = @@ -234,6 +243,7 @@ async function promptAuthConfig( message: "Model/auth choice", options: [ { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, + { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, { value: "antigravity", label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", @@ -244,7 +254,7 @@ async function promptAuthConfig( ], }), runtime, - ) as "oauth" | "antigravity" | "apiKey" | "minimax" | "skip"; + ) as "oauth" | "openai-codex" | "antigravity" | "apiKey" | "minimax" | "skip"; let next = cfg; @@ -286,6 +296,79 @@ async function promptAuthConfig( spin.stop("OAuth failed"); 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 | 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") { const isRemote = isRemoteEnvironment(); note( diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 8151bfca3..f2032f8ad 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -44,16 +44,25 @@ export function applyAuthProfileConfig( ...(params.email ? { email: params.email } : {}), }, }; - const order = { ...cfg.auth?.order }; - const list = order[params.provider] ? [...order[params.provider]] : []; - if (!list.includes(params.profileId)) list.push(params.profileId); - order[params.provider] = list; + + // Only maintain `auth.order` when the user explicitly configured it. + // Default behavior: no explicit order -> resolveAuthProfileOrder can round-robin by lastUsed. + 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 { ...cfg, auth: { ...cfg.auth, profiles, - order, + ...(order ? { order } : {}), }, }; } diff --git a/src/commands/openai-codex-model-default.test.ts b/src/commands/openai-codex-model-default.test.ts new file mode 100644 index 000000000..86497bb90 --- /dev/null +++ b/src/commands/openai-codex-model-default.test.ts @@ -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); + }); +}); diff --git a/src/commands/openai-codex-model-default.ts b/src/commands/openai-codex-model-default.ts new file mode 100644 index 000000000..d1d5b0914 --- /dev/null +++ b/src/commands/openai-codex-model-default.ts @@ -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, + }; +} diff --git a/src/config/sessions.ts b/src/config/sessions.ts index ff440eab8..a92219c40 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -26,6 +26,8 @@ export type SessionChatType = "direct" | "group" | "room"; export type SessionEntry = { sessionId: string; updatedAt: number; + /** Parent session key that spawned this session (used for sandbox session-tool scoping). */ + spawnedBy?: string; systemSent?: boolean; abortedLastRun?: boolean; chatType?: SessionChatType; diff --git a/src/config/types.ts b/src/config/types.ts index 9b03b78ef..7d7bb92b5 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -77,6 +77,8 @@ export type AgentElevatedAllowFromConfig = { }; export type WhatsAppConfig = { + /** Optional per-account WhatsApp configuration (multi-account). */ + accounts?: Record; /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; /** 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 = { /** CDP port for this profile. Allocated once at creation, persisted permanently. */ cdpPort?: number; @@ -488,6 +507,37 @@ export type RoutingConfig = { timeoutSeconds?: number; }; 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?: { mode?: QueueMode; bySurface?: QueueModeBySurface; @@ -836,6 +886,12 @@ export type ClawdbotConfig = { sandbox?: { /** Enable sandboxing for sessions. */ 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). */ perSession?: boolean; /** Root directory for sandbox workspaces. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 15abff42e..6039afb70 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -201,6 +201,61 @@ const RoutingSchema = z .object({ groupChat: GroupChatSchema, 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 .object({ mode: QueueModeSchema.optional(), @@ -504,6 +559,9 @@ export const ClawdbotSchema = z.object({ mode: z .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) .optional(), + sessionToolsVisibility: z + .union([z.literal("spawned"), z.literal("all")]) + .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: z @@ -608,6 +666,32 @@ export const ClawdbotSchema = z.object({ .optional(), whatsapp: z .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(), groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index e53f4e352..8f69e806a 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -157,7 +157,7 @@ export function handleControlUiHttpRequest( res.statusCode = 503; res.setHeader("Content-Type", "text/plain; charset=utf-8"); 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; } diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index c93645366..eec93fe79 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -311,6 +311,7 @@ export const SessionsListParamsSchema = Type.Object( activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), includeGlobal: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()), + spawnedBy: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); @@ -322,6 +323,7 @@ export const SessionsPatchParamsSchema = Type.Object( verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: 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( Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), ), diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 610c74946..a0be80f91 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -349,6 +349,52 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } : { 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) { const raw = p.thinkingLevel; if (raw === null) { diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index a0ec54352..4c45d22ea 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -110,6 +110,56 @@ export const sessionsHandlers: GatewayRequestHandlers = { } : { 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) { const raw = p.thinkingLevel; if (raw === null) { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 6b387a4be..33f0f9112 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -327,7 +327,9 @@ describe("gateway server cron", () => { : ""; 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", { name: "auto run test", enabled: true, @@ -345,8 +347,12 @@ describe("gateway server cron", () => { type: "event"; event: string; payload?: { jobId?: string; action?: string; status?: string } | null; - }>((resolve) => { - const timeout = setTimeout(() => resolve(null as never), 8000); + }>((resolve, reject) => { + const timeout = setTimeout(() => { + reject( + new Error(`timeout waiting for cron finished event: ${jobId}`), + ); + }, 8000); ws.on("message", (data) => { const obj = JSON.parse(decodeWsData(data)); if ( diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 1c8ef9176..590e1c774 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -53,6 +53,11 @@ describe("gateway server sessions", () => { updatedAt: now - 120_000, totalTokens: 50, }, + "subagent:one": { + sessionId: "sess-subagent", + updatedAt: now - 120_000, + spawnedBy: "main", + }, global: { sessionId: "sess-global", updatedAt: now - 10_000, @@ -148,6 +153,31 @@ describe("gateway server sessions", () => { expect(main2?.verboseLevel).toBeUndefined(); 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.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const modelPatched = await rpcReq<{ diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 3deba73d9..46bb66fce 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -227,6 +227,7 @@ export function listSessionsFromStore(params: { const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; + const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; const activeMinutes = typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes) @@ -239,6 +240,11 @@ export function listSessionsFromStore(params: { if (!includeUnknown && key === "unknown") return false; return true; }) + .filter(([key, entry]) => { + if (!spawnedBy) return true; + if (key === "unknown" || key === "global") return false; + return entry?.spawnedBy === spawnedBy; + }) .map(([key, entry]) => { const updatedAt = entry?.updatedAt ?? null; const input = entry?.inputTokens ?? 0; diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index e005789dc..0b995a0cd 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; 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"; export function resolveControlUiRepoRoot( @@ -76,7 +76,7 @@ export async function ensureControlUiAssetsBuilt( return { ok: 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 }; } - const pnpmWhich = process.platform === "win32" ? "where" : "which"; - const pnpm = await runExec(pnpmWhich, ["pnpm"]) - .then( - (r) => - r.stdout - .split(/\r?\n/g) - .map((l) => l.trim()) - .find(Boolean) ?? "", - ) - .catch(() => ""); - if (!pnpm) { + const uiScript = path.join(repoRoot, "scripts", "ui.js"); + if (!fs.existsSync(uiScript)) { return { ok: false, built: false, - message: - "Control UI assets not found and pnpm missing. Install pnpm, then run `pnpm ui:build`.", + message: `Control UI assets missing but ${uiScript} is unavailable.`, }; } - runtime.log("Control UI assets missing; building (pnpm ui:build)…"); + runtime.log("Control UI assets missing; building (ui:build)…"); const ensureInstalled = !fs.existsSync( path.join(repoRoot, "ui", "node_modules"), ); if (ensureInstalled) { - const install = await runCommandWithTimeout([pnpm, "ui:install"], { - cwd: repoRoot, - timeoutMs: opts?.timeoutMs ?? 10 * 60_000, - }); + const install = await runCommandWithTimeout( + [process.execPath, uiScript, "install"], + { + cwd: repoRoot, + timeoutMs: opts?.timeoutMs ?? 10 * 60_000, + }, + ); if (install.code !== 0) { return { ok: false, @@ -123,10 +116,13 @@ export async function ensureControlUiAssetsBuilt( } } - const build = await runCommandWithTimeout([pnpm, "ui:build"], { - cwd: repoRoot, - timeoutMs: opts?.timeoutMs ?? 10 * 60_000, - }); + const build = await runCommandWithTimeout( + [process.execPath, uiScript, "build"], + { + cwd: repoRoot, + timeoutMs: opts?.timeoutMs ?? 10 * 60_000, + }, + ); if (build.code !== 0) { return { ok: false, diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index d157c7cf3..539e4497e 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -52,6 +52,10 @@ import type { OnboardOptions, ResetScope, } 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 type { ClawdbotConfig } from "../config/config.js"; import { @@ -60,7 +64,6 @@ import { resolveGatewayPort, writeConfigFile, } from "../config/config.js"; -import type { AgentModelListConfig } from "../config/types.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; @@ -70,50 +73,6 @@ import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.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( config: ClawdbotConfig, prompter: WizardPrompter, diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index f47b34450..616f7f5e1 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -825,6 +825,13 @@ 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 { gap: 4px; }