Compare commits

..
59 changed files with 214 additions and 5886 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext
+1 -8
View File
@@ -1,13 +1,12 @@
# Changelog
## 2.0.0-beta5 — 2026-01-03
## 2.0.0-beta5 — Unreleased
### Fixed
- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).
- Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).
- Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).
- Tests: cover read tool image metadata + text output.
- Tests: add queue mode coverage (collect/followup + directive parsing).
### Breaking
- Skills config schema moved under `skills.*`:
@@ -22,13 +21,10 @@
- Config: remove `routing.groupChat.requireMention` + `telegram.requireMention`; use `whatsapp.groups`, `imessage.groups`, and `telegram.groups` defaults instead (run `clawdis doctor` to migrate).
### Features
- Discord: expand `discord` tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.
- Discord/Telegram: add reply tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`) with per-provider `replyToMode` (off|first|all) for native threaded replies.
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
- Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
- CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
- Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via `*.groups` with `"*"` defaults; Discord now supports `discord.guilds."*"` as a default.
- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.
@@ -49,7 +45,6 @@
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
- CLI: add `clawdis tui` gateway-connected terminal UI (local or remote).
- Skills: allow `bun` as a node manager for skill installs.
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
@@ -62,7 +57,6 @@
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
- Chat UI: show rich session display names in Web Chat + SwiftUI + Android.
- Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.
- Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).
- Providers: make outbound text chunk limits configurable via `*.textChunkLimit` (defaults remain 4000/Discord 2000).
@@ -101,7 +95,6 @@
- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
- macOS codesign: include camera entitlement so permission prompts work in the menu bar app.
- Agent tools: bash tool supports real TTY via `stdinMode: "pty"` with node-pty, warning + fallback on load/start failure.
- Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors.
- Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches.
- macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.
+14 -203
View File
@@ -2,219 +2,30 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdis</title>
<item>
<title>2.0.0-beta3</title>
<pubDate>Sat, 27 Dec 2025 19:02:02 +0100</pubDate>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<sparkle:version>2.0.0-beta3</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta3/Clawdis-2.0.0-beta3.zip" length="70407960" type="application/octet-stream" sparkle:edSignature="A8ySMmbLRrpIkqkrmc9QrC+6om8Iyqray/6x/YNiJxDoJeXjp2T5t8XT0CKJeNBUlDkzIj/fwiK53v0qQ59cDQ=="/>
</item>
<item>
<title>2.0.0-beta4</title>
<pubDate>Sat, 03 Jan 2026 06:04:02 +0100</pubDate>
<pubDate>Sat, 27 Dec 2025 19:43:22 +0100</pubDate>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<sparkle:version>2.0.0-beta4</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta4</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
</item>
<item>
<title>2.0.0-beta5</title>
<pubDate>Sat, 03 Jan 2026 06:04:02 +0100</pubDate>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<sparkle:version>2.0.0-beta5</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdis 2.0.0-beta5</h2>
<h3>Fixed</h3>
<ul>
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
<li>Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).</li>
<li>Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).</li>
<li>Tests: cover read tool image metadata + text output.</li>
<li>Tests: add queue mode coverage (collect/followup + directive parsing).</li>
</ul>
<h3>Breaking</h3>
<ul>
<li>Skills config schema moved under <code>skills.*</code>:</li>
</ul>
- <code>skillsLoad.extraDirs</code> → <code>skills.load.extraDirs</code>
- <code>skillsInstall.*</code> → <code>skills.install.*</code>
- per-skill config map moved to <code>skills.entries</code> (e.g. <code>skills.peekaboo.enabled</code> → <code>skills.entries.peekaboo.enabled</code>)
- new optional bundled allowlist: <code>skills.allowBundled</code> (only affects bundled skills)
<ul>
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdis doctor</code> to migrate).</li>
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdis doctor</code> to migrate).</li>
</ul>
<h3>Features</h3>
<ul>
<li>Discord: expand <code>discord</code> tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.</li>
<li>Discord/Telegram: add reply tags (<code>[[reply_to_current]]</code>, <code>[[reply_to:<id>]]</code>) with per-provider <code>replyToMode</code> (off|first|all) for native threaded replies.</li>
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDIS_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
<li>Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.</li>
<li>Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.</li>
<li>Signal: add <code>signal-cli</code> JSON-RPC support for send/receive via the Signal provider.</li>
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
<li>Discord: allow agent-triggered reactions via <code>clawdis_discord</code> when enabled, and surface message ids in context.</li>
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
<li>Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).</li>
<li>Tests: add a Z.AI live test gate for smoke validation when keys are present.</li>
<li>macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.</li>
<li>CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.</li>
<li>CLI: add ASCII banner header to wizard entry points.</li>
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
<li>CLI: add <code>clawdis tui</code> gateway-connected terminal UI (local or remote).</li>
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
<li>Tests: add a Docker-based onboarding E2E harness.</li>
<li>Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.</li>
<li>Browser tools: add remote CDP URL support, Linux launcher options (<code>executablePath</code>, <code>noSandbox</code>), and surface <code>cdpUrl</code> in status.</li>
<li>Skills: add tmux-first coding-agent skill + <code>requires.anyBins</code> gate for multi-CLI setup (thanks @sreekaransrinath).</li>
</ul>
<description><![CDATA[<h2>Clawdis 2.0.0-beta4</h2>
<h3>Fixes</h3>
<ul>
<li>Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).</li>
<li>macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.</li>
<li>Chat UI: keep the chat scrolled to the latest message after switching sessions.</li>
<li>Chat UI: show rich session display names in Web Chat + SwiftUI + Android.</li>
<li>Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.</li>
<li>Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).</li>
<li>Providers: make outbound text chunk limits configurable via <code>*.textChunkLimit</code> (defaults remain 4000/Discord 2000).</li>
<li>CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.</li>
<li>Control UI: accept a <code>?token=</code> URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.</li>
<li>Agent prompt: remove hardcoded user name in system prompt example.</li>
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
<li>Gateway CLI: read <code>CLAWDIS_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
<li>Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.</li>
<li>Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.</li>
<li>Discord: include recent guild context when replying to mentions and add <code>discord.historyLimit</code> to tune how many messages are captured.</li>
<li>Discord: include author tag + id in group context <code>[from:]</code> lines for ping-ready replies (thanks @thewilloftheshadow).</li>
<li>Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).</li>
<li>Discord: preserve newlines when stripping reply tags from agent output.</li>
<li>Gateway: fix TypeScript build by aligning hook mapping <code>channel</code> types and removing a dead Group DM branch in Discord monitor.</li>
<li>Skills: switch imsg installer to brew tap formula.</li>
<li>Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.</li>
<li>Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.</li>
<li>Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.</li>
<li>CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.</li>
<li>CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).</li>
<li>CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.</li>
<li>CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.</li>
<li>CLI onboarding: always prompt for WhatsApp <code>whatsapp.allowFrom</code> and print (optionally open) the Control UI URL when done.</li>
<li>CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).</li>
<li>macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.</li>
<li>macOS: keep config writes on the main actor to satisfy Swift concurrency rules.</li>
<li>macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient <code>cancelled</code> device refresh errors, and auto-recover the control channel on disconnect.</li>
<li>macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.</li>
<li>macOS menu: tighten session row padding and time out session preview loading with cached fallback.</li>
<li>macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.</li>
<li>macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b</li>
<li>macOS codesign: include camera entitlement so permission prompts work in the menu bar app.</li>
<li>Agent tools: bash tool supports real TTY via <code>stdinMode: "pty"</code> with node-pty, warning + fallback on load/start failure.</li>
<li>Agent tools: map <code>camera.snap</code> JPEG payloads to <code>image/jpeg</code> to avoid MIME mismatch errors.</li>
<li>Tests: cover <code>camera.snap</code> MIME mapping to prevent image/png vs image/jpeg mismatches.</li>
<li>macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.</li>
<li>Camera snap: add <code>delayMs</code> parameter (default 2000ms on macOS) to improve exposure reliability.</li>
<li>Camera: add <code>camera.list</code> and optional <code>deviceId</code> selection for snaps/clips.</li>
<li>Tests: cover camera device selection params in CLI + agent tools.</li>
<li>macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b</li>
<li>macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b</li>
<li>macOS remote: route settings through gateway config and avoid local config reads in remote mode.</li>
<li>Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).</li>
<li>Telegram: honor per-group mention gating defaults/overrides via <code>telegram.groups</code> and <code>"*"</code> defaults (thanks @joshp123).</li>
<li>Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl</li>
<li>Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.</li>
<li>Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.</li>
<li>Cron: prevent <code>every</code> schedules without an anchor from firing in a tight loop (thanks @jamesgroat).</li>
<li>Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock</li>
<li>Docs/agent tools: clarify that browser <code>wait</code> should be avoided by default and used only in exceptional cases.</li>
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
<li>Browser CLI: add <code>clawdis browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
<li>CLI: tighten onboarding prompt typing to keep bun builds green.</li>
<li>macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.</li>
<li>macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.</li>
<li>macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).</li>
<li>macOS Debug: add an icon for the App Logging submenu.</li>
<li>macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.</li>
<li>macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.</li>
<li>macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).</li>
<li>macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.</li>
<li>macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.</li>
<li>macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.</li>
<li>WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.</li>
<li>Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).</li>
<li>Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.</li>
<li>Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.</li>
<li>iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.</li>
<li>ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.</li>
<li>Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).</li>
<li>Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).</li>
<li>Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.</li>
<li>Chat UI: user bubbles use <code>ui.seamColor</code> (fallback to a calmer default blue).</li>
<li>Android Chat UI: use <code>onPrimary</code> for user bubble text to preserve contrast (thanks @Syhids).</li>
<li>Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.</li>
<li>Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.</li>
<li>Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.</li>
<li>Control UI: soften chat bubble text opacity for calmer readability.</li>
<li>macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.</li>
<li>macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).</li>
<li>Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).</li>
<li>iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).</li>
<li>iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.</li>
<li>iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.</li>
<li>iOS/Android Talk Mode: explicitly <code>chat.subscribe</code> when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.</li>
<li>Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.</li>
<li>Gateway: <code>voice.transcript</code> now also maps agent bus output to <code>chat</code> events, ensuring chat UIs refresh for voice-triggered runs.</li>
<li>Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.</li>
<li>iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.</li>
<li>Gateway config: inject <code>talk.apiKey</code> from <code>ELEVENLABS_API_KEY</code>/shell profile so nodes can fetch it on demand.</li>
<li>Canvas A2UI: tag requests with <code>platform=android|ios|macos</code> and boost Android canvas background contrast.</li>
<li>iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).</li>
<li>macOS menu: device list now uses <code>node.list</code> (devices only; no agent/tool presence entries).</li>
<li>macOS menu: device list now shows connected nodes only.</li>
<li>macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.</li>
<li>macOS menu: split device platform/version across first and second rows for better fit.</li>
<li>macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.</li>
<li>Canvas A2UI: polish the debug status HUD styling.</li>
<li>iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.</li>
<li>iOS Talk Mode: avoid audio tap queue assertions when starting recognition.</li>
<li>macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).</li>
<li>macOS remote: harden SSH tunnel recovery/logging, honor <code>gateway.remote.url</code> port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.</li>
<li>iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.</li>
<li>macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).</li>
<li>iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.</li>
<li>iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.</li>
<li>iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.</li>
<li>iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.</li>
<li>iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.</li>
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.</li>
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
<li>Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.</li>
</ul>
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta5.zip" length="72078343" type="application/octet-stream" sparkle:edSignature="xSfMHcYYEktlE+EC8cnM7GJP0oqjfN4EK+oIIcTHvXtgXG7LcBqo4kMQEVrr1kIzCIqQ+7/R6HMd/rWtfkc3Cg=="/>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
</item>
</channel>
</rss>
</rss>
@@ -148,7 +148,7 @@ fun ChatComposer(
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
Spacer(modifier = Modifier.weight(1f))
if (pendingRunCount > 0) {
@@ -186,7 +186,7 @@ fun ChatComposer(
}
@Composable
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -201,7 +201,7 @@ private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
shape = androidx.compose.foundation.shape.CircleShape,
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
) {}
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
Text(sessionKey, style = MaterialTheme.typography.labelSmall)
Text(
if (healthOk) "Connected" else "Connecting…",
style = MaterialTheme.typography.labelSmall,
@@ -310,6 +310,12 @@ struct ConnectionsSettings: View {
TextField("20", text: self.$store.discordHistoryLimit)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Reactions")
Toggle("", isOn: self.$store.discordEnableReactions)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Slash command")
Toggle("", isOn: self.$store.discordSlashEnabled)
@@ -334,105 +340,6 @@ struct ConnectionsSettings: View {
}
}
Divider().padding(.vertical, 2)
Text("Tool actions")
.font(.caption)
.foregroundStyle(.secondary)
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Reactions")
Toggle("", isOn: self.$store.discordActionReactions)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Stickers")
Toggle("", isOn: self.$store.discordActionStickers)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Polls")
Toggle("", isOn: self.$store.discordActionPolls)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Permissions")
Toggle("", isOn: self.$store.discordActionPermissions)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Messages")
Toggle("", isOn: self.$store.discordActionMessages)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Threads")
Toggle("", isOn: self.$store.discordActionThreads)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Pins")
Toggle("", isOn: self.$store.discordActionPins)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Search")
Toggle("", isOn: self.$store.discordActionSearch)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Member info")
Toggle("", isOn: self.$store.discordActionMemberInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Role info")
Toggle("", isOn: self.$store.discordActionRoleInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Channel info")
Toggle("", isOn: self.$store.discordActionChannelInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Voice status")
Toggle("", isOn: self.$store.discordActionVoiceStatus)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Events")
Toggle("", isOn: self.$store.discordActionEvents)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Role changes")
Toggle("", isOn: self.$store.discordActionRoles)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Moderation")
Toggle("", isOn: self.$store.discordActionModeration)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
if self.isDiscordTokenLocked {
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
.font(.caption)
@@ -174,21 +174,7 @@ final class ConnectionsStore {
var discordGroupChannels: String = ""
var discordMediaMaxMb: String = ""
var discordHistoryLimit: String = ""
var discordActionReactions = true
var discordActionStickers = true
var discordActionPolls = true
var discordActionPermissions = true
var discordActionMessages = true
var discordActionThreads = true
var discordActionPins = true
var discordActionSearch = true
var discordActionMemberInfo = true
var discordActionRoleInfo = true
var discordActionChannelInfo = true
var discordActionVoiceStatus = true
var discordActionEvents = true
var discordActionRoles = false
var discordActionModeration = false
var discordEnableReactions = true
var discordSlashEnabled = false
var discordSlashName: String = ""
var discordSlashSessionPrefix: String = ""
@@ -434,22 +420,7 @@ final class ConnectionsStore {
} else {
self.discordHistoryLimit = ""
}
let discordActions = discord?["actions"]?.dictionaryValue
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
self.discordEnableReactions = discord?["enableReactions"]?.boolValue ?? true
let slash = discord?["slashCommand"]?.dictionaryValue
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
self.discordSlashName = slash?["name"]?.stringValue ?? ""
@@ -673,33 +644,10 @@ final class ConnectionsStore {
discord.removeValue(forKey: "historyLimit")
}
var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:]
func setAction(_ key: String, value: Bool, defaultValue: Bool) {
if value == defaultValue {
actions.removeValue(forKey: key)
} else {
actions[key] = value
}
}
setAction("reactions", value: self.discordActionReactions, defaultValue: true)
setAction("stickers", value: self.discordActionStickers, defaultValue: true)
setAction("polls", value: self.discordActionPolls, defaultValue: true)
setAction("permissions", value: self.discordActionPermissions, defaultValue: true)
setAction("messages", value: self.discordActionMessages, defaultValue: true)
setAction("threads", value: self.discordActionThreads, defaultValue: true)
setAction("pins", value: self.discordActionPins, defaultValue: true)
setAction("search", value: self.discordActionSearch, defaultValue: true)
setAction("memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
setAction("roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
setAction("channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
setAction("voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
setAction("events", value: self.discordActionEvents, defaultValue: true)
setAction("roles", value: self.discordActionRoles, defaultValue: false)
setAction("moderation", value: self.discordActionModeration, defaultValue: false)
if actions.isEmpty {
discord.removeValue(forKey: "actions")
if self.discordEnableReactions {
discord.removeValue(forKey: "enableReactions")
} else {
discord["actions"] = actions
discord["enableReactions"] = false
}
var slash: [String: Any] = (discord["slashCommand"] as? [String: Any]) ?? [:]
@@ -209,7 +209,7 @@ struct ClawdisChatComposer: View {
Circle()
.fill(self.viewModel.healthOK ? .green : .orange)
.frame(width: 7, height: 7)
Text(self.activeSessionLabel)
Text(self.viewModel.sessionKey)
.font(.caption2.weight(.semibold))
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
.font(.caption2)
@@ -221,12 +221,6 @@ struct ClawdisChatComposer: View {
.clipShape(Capsule())
}
private var activeSessionLabel: String {
let match = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey }
let trimmed = match?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.viewModel.sessionKey : trimmed
}
private var editorOverlay: some View {
ZStack(alignment: .topLeading) {
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
-1
View File
@@ -94,7 +94,6 @@ git commit -m "Add Clawd workspace"
- **eightctl** — Control your sleep, from the terminal.
- **imsg** — Send, read, stream iMessage & SMS.
- **wacli** — WhatsApp CLI: sync, search, send.
- **discord** — Discord actions: react, stickers, polls.
- **gog** — Google Suite CLI: Gmail, Calendar, Drive, Contacts.
- **spotify-player** — Terminal Spotify client to search/queue/control playback.
- **sag** — ElevenLabs speech with mac-style say UX; streams to speakers by default.
+1 -10
View File
@@ -70,16 +70,7 @@ Legacy Pi/Tau session folders are **not** read.
## Steering while streaming
When queue mode is `steer`, inbound messages are injected into the current run.
The queue is checked **after each tool call**; if a queued message is present,
remaining tool calls from the current assistant message are skipped (error tool
results with "Skipped due to queued user message."), then the queued user
message is injected before the next assistant response.
When queue mode is `followup` or `collect`, inbound messages are held until the
current turn ends, then a new agent turn starts with the queued payloads. See
`docs/queue.md` for mode + debounce/cap behavior.
Incoming user messages are queued while the agent is streaming. The queue is checked **after each tool call**. If a queued message is present, remaining tool calls from the current assistant message are skipped (error tool results with "Skipped due to queued user message."), then the queued user message is injected before the next assistant response.
Block streaming sends completed assistant blocks as soon as they finish; disable
via `agent.blockStreamingDefault: "off"` if you only want the final response.
Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`).
-44
View File
@@ -1,44 +0,0 @@
---
summary: "Bash tool usage, stdin modes, and TTY support"
read_when:
- Using or modifying the bash tool
- Debugging stdin or TTY behavior
---
# Bash tool
Run shell commands in the workspace. Supports foreground + background execution via `process`.
## Parameters
- `command` (required)
- `yieldMs` (default 20000): auto-background after delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry
- `stdinMode` (`pipe` | `pty`):
- `pipe` (default): classic stdin/stdout/stderr pipes
- `pty`: real TTY via node-pty (merged stdout/stderr)
## TTY mode (`stdinMode: "pty"`)
- Uses node-pty if available. If node-pty fails to load/start, the tool warns and falls back to `pipe`.
- Output streams are merged (no separate stderr).
- `process write` sends raw input; `eof: true` sends Ctrl-D (`\x04`).
## Examples
Foreground:
```json
{"tool":"bash","command":"ls -la"}
```
Background + poll:
```json
{"tool":"bash","command":"npm run build","yieldMs":1000}
{"tool":"process","action":"poll","sessionId":"<id>"}
```
TTY command:
```json
{"tool":"bash","command":"htop","stdinMode":"pty","background":true}
```
+7 -32
View File
@@ -156,16 +156,13 @@ Controls how inbound messages behave when an agent run is already active.
{
routing: {
queue: {
mode: "collect", // steer | followup | collect | steer-backlog (steer+backlog ok) | interrupt (queue=steer legacy)
debounceMs: 1000,
cap: 20,
drop: "summarize", // old | new | summarize
mode: "interrupt", // global default: queue | interrupt
bySurface: {
whatsapp: "collect",
telegram: "collect",
discord: "steer-backlog",
imessage: "collect",
webchat: "collect"
whatsapp: "interrupt",
telegram: "interrupt",
discord: "queue",
imessage: "interrupt",
webchat: "queue"
}
}
}
@@ -224,24 +221,7 @@ Configure the Discord bot by setting the bot token and optional gating:
enabled: true,
token: "your-bot-token",
mediaMaxMb: 8, // clamp inbound media size
actions: { // tool action gates (false disables)
reactions: true,
stickers: true,
polls: true,
permissions: true,
messages: true,
threads: true,
pins: true,
search: true,
memberInfo: true,
roleInfo: true,
roles: false,
channelInfo: true,
voiceStatus: true,
events: true,
moderation: false
},
replyToMode: "off", // off | first | all
enableReactions: true, // allow agent-triggered reactions
slashCommand: { // user-installed app slash commands
enabled: true,
name: "clawd",
@@ -730,11 +710,6 @@ Gmail helper config (used by `clawdis hooks gmail setup` / `run`):
}
```
Gateway auto-start:
- If `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
`gog gmail watch serve` on boot and auto-renews the watch.
- Set `CLAWDIS_SKIP_GMAIL_WATCHER=1` to disable the auto-start (for manual runs).
Note: when `tailscale.mode` is on, Clawdis defaults `serve.path` to `/` so
Tailscale can proxy `/gmail-pubsub` correctly (it strips the set-path prefix).
+7 -54
View File
@@ -27,7 +27,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
11. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`).
11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session.
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
@@ -50,23 +50,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
enabled: true,
token: "abc.123",
mediaMaxMb: 8,
actions: {
reactions: true,
stickers: true,
polls: true,
permissions: true,
messages: true,
threads: true,
pins: true,
search: true,
memberInfo: true,
roleInfo: true,
roles: false,
channelInfo: true,
voiceStatus: true,
events: true,
moderation: false
},
enableReactions: true,
replyToMode: "off",
slashCommand: {
enabled: true,
@@ -109,32 +93,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
- `slashCommand`: optional config for user-installed slash commands (ephemeral responses).
- `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
- `reactions` (covers react + read reactions)
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
- `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`)
### Tool action defaults
| Action group | Default | Notes |
| --- | --- | --- |
| reactions | enabled | React + list reactions + emojiList |
| stickers | enabled | Send stickers |
| polls | enabled | Create polls |
| permissions | enabled | Channel permission snapshot |
| messages | enabled | Read/send/edit/delete |
| threads | enabled | Create/list/reply |
| pins | enabled | Pin/unpin/list |
| search | enabled | Message search (preview spec) |
| memberInfo | enabled | Member info |
| roleInfo | enabled | Role list |
| channelInfo | enabled | Channel info + list |
| voiceStatus | enabled | Voice state lookup |
| events | enabled | List/create scheduled events |
| roles | disabled | Role add/remove |
| moderation | disabled | Timeout/kick/ban |
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
- `replyToMode`: `off` (default), `first`, or `all`. Applies only when the model includes a reply tag.
## Reply tags
@@ -160,16 +119,10 @@ Slash command notes:
- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules).
- Clawdis will auto-register `/clawd` (or the configured name) if it doesn't already exist.
## Tool actions
The agent can call `discord` with actions like:
- `react` / `reactions` (add or list reactions)
- `sticker`, `poll`, `permissions`
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
- `threadCreate`, `threadList`, `threadReply`
- `pinMessage`, `unpinMessage`, `listPins`
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
- `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate`
- `timeout`, `kick`, `ban`
## Reactions
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
- `action: "react"`
- `channelId`, `messageId`, `emoji`
Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them.
Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`.
+1 -6
View File
@@ -56,12 +56,7 @@ Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.
Platform note: on macOS the wizard installs `gcloud`, `gogcli`, and `tailscale`
via Homebrew; on Linux install them manually first.
Gateway auto-start (recommended):
- When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
`gog gmail watch serve` on boot and auto-renews the watch.
- Set `CLAWDIS_SKIP_GMAIL_WATCHER=1` to opt out (useful if you run the daemon yourself).
Manual daemon (starts `gog gmail watch serve` + auto-renew):
Run the daemon (starts `gog gmail watch serve` + auto-renew):
```bash
clawdis hooks gmail run
+8 -24
View File
@@ -3,7 +3,7 @@ summary: "Command queue design that serializes auto-reply command execution"
read_when:
- Changing auto-reply execution or concurrency
---
# Command Queue (2026-01-03)
# Command Queue (2025-11-25)
We now serialize command-based auto-replies (WhatsApp Web listener) through a tiny in-process queue to prevent multiple commands from running at once, while allowing safe parallelism across sessions.
@@ -19,16 +19,13 @@ We now serialize command-based auto-replies (WhatsApp Web listener) through a ti
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn.
## Queue modes (per surface)
Inbound messages can steer the current run, wait for a followup turn, or do both:
- `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup.
- `followup`: enqueue for the next agent turn after the current run ends.
- `collect`: coalesce all queued messages into a **single** followup turn (default).
- `steer-backlog` (aka `steer+backlog`): steer now **and** preserve the message for a followup turn.
- `interrupt` (legacy): abort the active run for that session, then run the newest message.
- `queue` (legacy alias): same as `steer`.
Inbound messages can either queue or interrupt when a run is already active:
- `queue`: serialize per session; if the agent is streaming, the new message is appended to the current run.
- `interrupt`: abort the active run for that session, then run the newest message.
Defaults (when unset in config):
- All surfaces → `collect`
- WhatsApp + Telegram → `interrupt`
- Discord + WebChat → `queue`
Configure globally or per surface via `routing.queue`:
@@ -36,29 +33,16 @@ Configure globally or per surface via `routing.queue`:
{
routing: {
queue: {
mode: "collect",
debounceMs: 1000,
cap: 20,
drop: "summarize",
bySurface: { discord: "steer-backlog" }
mode: "interrupt",
bySurface: { discord: "queue", telegram: "interrupt" }
}
}
}
```
## Queue options
Options apply to `followup`, `collect`, and `steer-backlog` (and to `steer` when it falls back to followup):
- `debounceMs`: wait for quiet before starting a followup turn (prevents “continue, continue”).
- `cap`: max queued messages per session.
- `drop`: overflow policy (`old`, `new`, `summarize`).
Summarize keeps a short bullet list of dropped messages and injects it as a synthetic followup prompt.
Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
## Per-session overrides
- `/queue <mode>` as a standalone command stores the mode for the current session.
- `/queue <mode>` embedded in a message applies **once** (no persistence).
- Options can be combined: `/queue collect debounce:2s cap:25 drop:summarize`
- `/queue default` or `/queue reset` clears the session override.
## Scope and guarantees
-39
View File
@@ -105,45 +105,6 @@ Core actions:
Notes:
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
### `discord`
Send Discord reactions, stickers, or polls.
Core actions:
- `react` (`channelId`, `messageId`, `emoji`)
- `reactions` (`channelId`, `messageId`, optional `limit`)
- `sticker` (`to`, `stickerIds`, optional `content`)
- `poll` (`to`, `question`, `answers`, optional `allowMultiselect`, `durationHours`, `content`)
- `permissions` (`channelId`)
- `readMessages` (`channelId`, optional `limit`/`before`/`after`/`around`)
- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyTo`)
- `editMessage` (`channelId`, `messageId`, `content`)
- `deleteMessage` (`channelId`, `messageId`)
- `threadCreate` (`channelId`, `name`, optional `messageId`, `autoArchiveMinutes`)
- `threadList` (`guildId`, optional `channelId`, `includeArchived`, `before`, `limit`)
- `threadReply` (`channelId`, `content`, optional `mediaUrl`, `replyTo`)
- `pinMessage`/`unpinMessage` (`channelId`, `messageId`)
- `listPins` (`channelId`)
- `searchMessages` (`guildId`, `content`, optional `channelId`/`channelIds`, `authorId`/`authorIds`, `limit`)
- `memberInfo` (`guildId`, `userId`)
- `roleInfo` (`guildId`)
- `emojiList` (`guildId`)
- `roleAdd`/`roleRemove` (`guildId`, `userId`, `roleId`)
- `channelInfo` (`channelId`)
- `channelList` (`guildId`)
- `voiceStatus` (`guildId`, `userId`)
- `eventList` (`guildId`)
- `eventCreate` (`guildId`, `name`, `startTime`, optional `endTime`, `description`, `channelId`, `entityType`, `location`)
- `timeout` (`guildId`, `userId`, optional `durationMinutes`, `until`, `reason`)
- `kick` (`guildId`, `userId`, optional `reason`)
- `ban` (`guildId`, `userId`, optional `reason`, `deleteMessageDays`)
Notes:
- `to` accepts `channel:<id>` or `user:<id>`.
- Polls require 2–10 answers and default to 24 hours.
- `reactions` returns per-emoji user lists (limited to 100 per reaction).
- `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`.
- `searchMessages` follows the Discord preview spec (limit max 25, channel/author filters accept arrays).
## Parameters (common)
Gateway-backed tools (`clawdis_canvas`, `clawdis_nodes`, `clawdis_cron`):
-54
View File
@@ -1,54 +0,0 @@
---
summary: "Terminal UI (TUI) for Clawdis via the Gateway"
read_when:
- You want a terminal UI that connects to the Gateway from any machine
- You are debugging the TUI client or Gateway chat stream
---
# TUI (Gateway chat client)
Updated: 2026-01-03
## What it is
- A terminal UI that connects to the Gateway WebSocket and speaks the same chat APIs as WebChat.
- Works locally (loopback) or remotely (Tailscale/SSH tunnel) without running a separate agent process.
## Run
```bash
clawdis tui
```
### Remote
```bash
clawdis tui --url ws://127.0.0.1:18789 --token <gateway-token>
```
Use SSH tunneling or Tailscale to reach the Gateway WS.
## Options
- `--url <url>`: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`).
- `--token <token>`: Gateway token (if required).
- `--password <password>`: Gateway password (if required).
- `--session <key>`: Session key (default: `session.mainKey` or `main`).
- `--deliver`: Deliver assistant replies to the provider.
- `--thinking <level>`: Override thinking level for sends.
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
- `--history-limit <n>`: History entries to load (default 200).
## Controls
- Enter: send message
- Esc: abort active run
- Ctrl+C: exit
## Slash commands
- `/help`
- `/session <key>`
- `/abort`
- `/exit`
## Notes
- The TUI shows Gateway chat deltas (`event: chat`) and final responses.
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
## Files
- CLI: `src/cli/tui-cli.ts`
- Runner: `src/tui/tui.ts`
- Gateway client: `src/tui/gateway-chat.ts`
-2
View File
@@ -75,7 +75,6 @@
"@mariozechner/pi-agent-core": "^0.31.1",
"@mariozechner/pi-ai": "^0.31.1",
"@mariozechner/pi-coding-agent": "^0.31.1",
"@mariozechner/pi-tui": "^0.31.1",
"@sinclair/typebox": "0.34.46",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ajv": "^8.17.1",
@@ -86,7 +85,6 @@
"commander": "^14.0.2",
"croner": "^9.1.0",
"detect-libc": "^2.1.2",
"discord-api-types": "^0.38.33",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"express": "^5.2.1",
+1 -87
View File
@@ -1,91 +1,5 @@
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
index ff9cbcfebfac6b4370d85dc838f5cacf2a60ed64..42096c82aec925b412258348a36ba4a7025b283b 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
@@ -140,6 +140,72 @@ export function convertMessages(model, context) {
}
return contents;
}
+/**
+ * Sanitize JSON Schema for Google Cloud Code Assist API.
+ * Removes unsupported keywords like patternProperties, const, anyOf, etc.
+ * and converts to a format compatible with Google's function declarations.
+ */
+function sanitizeSchemaForGoogle(schema) {
+ if (!schema || typeof schema !== 'object') {
+ return schema;
+ }
+ // If it's an array, sanitize each element
+ if (Array.isArray(schema)) {
+ return schema.map(item => sanitizeSchemaForGoogle(item));
+ }
+ const sanitized = {};
+ // List of unsupported JSON Schema keywords that Google's API doesn't understand
+ const unsupportedKeywords = [
+ 'patternProperties',
+ 'const',
+ 'anyOf',
+ 'oneOf',
+ 'allOf',
+ 'not',
+ '$schema',
+ '$id',
+ '$ref',
+ '$defs',
+ 'definitions',
+ 'if',
+ 'then',
+ 'else',
+ 'dependentSchemas',
+ 'dependentRequired',
+ 'unevaluatedProperties',
+ 'unevaluatedItems',
+ 'contentEncoding',
+ 'contentMediaType',
+ 'contentSchema',
+ 'deprecated',
+ 'readOnly',
+ 'writeOnly',
+ 'examples',
+ '$comment',
+ 'additionalProperties',
+ ];
+ // TODO(steipete): lossy schema scrub; revisit when Google supports these keywords.
+ for (const [key, value] of Object.entries(schema)) {
+ // Skip unsupported keywords
+ if (unsupportedKeywords.includes(key)) {
+ continue;
+ }
+ // Recursively sanitize nested objects
+ if (key === 'properties' && typeof value === 'object' && value !== null) {
+ sanitized[key] = {};
+ for (const [propKey, propValue] of Object.entries(value)) {
+ sanitized[key][propKey] = sanitizeSchemaForGoogle(propValue);
+ }
+ } else if (key === 'items' && typeof value === 'object') {
+ sanitized[key] = sanitizeSchemaForGoogle(value);
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ sanitized[key] = sanitizeSchemaForGoogle(value);
+ } else {
+ sanitized[key] = value;
+ }
+ }
+ return sanitized;
+}
/**
* Convert tools to Gemini function declarations format.
*/
@@ -151,7 +216,7 @@ export function convertTools(tools) {
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
- parameters: tool.parameters,
+ parameters: sanitizeSchemaForGoogle(tool.parameters),
})),
},
];
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..31bae0aface1319487ce62d35f1f3b6ed334863e 100644
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..c2bc63f483f3285b00755901ba97db810221cea6 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -486,7 +486,6 @@ function convertTools(tools) {
+5 -11
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
'@mariozechner/pi-ai':
hash: 969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae
hash: bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832
path: patches/@mariozechner__pi-ai.patch
'@mariozechner/pi-coding-agent@0.31.1':
hash: d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745
@@ -33,13 +33,10 @@ importers:
version: 0.31.1(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai':
specifier: ^0.31.1
version: 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
version: 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-coding-agent':
specifier: ^0.31.1
version: 0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-tui':
specifier: ^0.31.1
version: 0.31.1
'@sinclair/typebox':
specifier: 0.34.46
version: 0.34.46
@@ -70,9 +67,6 @@ importers:
detect-libc:
specifier: ^2.1.2
version: 2.1.2
discord-api-types:
specifier: ^0.38.33
version: 0.38.37
discord.js:
specifier: ^14.25.1
version: 14.25.1
@@ -3353,7 +3347,7 @@ snapshots:
'@mariozechner/pi-agent-core@0.31.1(ws@8.18.3)(zod@4.3.4)':
dependencies:
'@mariozechner/pi-ai': 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai': 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-tui': 0.31.1
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -3363,7 +3357,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)':
'@mariozechner/pi-ai@0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.4)
'@google/genai': 1.34.0
@@ -3386,7 +3380,7 @@ snapshots:
'@mariozechner/pi-coding-agent@0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)':
dependencies:
'@mariozechner/pi-agent-core': 0.31.1(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai': 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai': 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-tui': 0.31.1
chalk: 5.6.2
cli-highlight: 2.1.11
-272
View File
@@ -1,272 +0,0 @@
---
name: discord
description: Use when you need to control Discord from Clawdis via the discord tool: send messages, react, post stickers, run polls, manage threads/pins/search, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
---
# Discord Actions
## Overview
Use `discord` to manage messages, reactions, threads, polls, and moderation. You can disable groups via `discord.actions.*` (defaults to enabled, except roles/moderation). The tool uses the bot token configured for Clawdis.
## Inputs to collect
- For reactions: `channelId`, `messageId`, and an `emoji`.
- For stickers/polls: a `to` target (`channel:<id>` or `user:<id>`). Optional `content` text.
- Polls also need a `question` plus 2–10 `answers`.
Message context lines include `discord message id` and `channel` fields you can reuse directly.
## Actions
### React to a message
```json
{
"action": "react",
"channelId": "123",
"messageId": "456",
"emoji": "✅"
}
```
### List reactions + users
```json
{
"action": "reactions",
"channelId": "123",
"messageId": "456",
"limit": 100
}
```
### Send a sticker
```json
{
"action": "sticker",
"to": "channel:123",
"stickerIds": ["9876543210"],
"content": "Nice work!"
}
```
- Up to 3 sticker IDs per message.
- `to` can be `user:<id>` for DMs.
### Create a poll
```json
{
"action": "poll",
"to": "channel:123",
"question": "Lunch?",
"answers": ["Pizza", "Sushi", "Salad"],
"allowMultiselect": false,
"durationHours": 24,
"content": "Vote now"
}
```
- `durationHours` defaults to 24; max 32 days (768 hours).
### Check bot permissions for a channel
```json
{
"action": "permissions",
"channelId": "123"
}
```
## Ideas to try
- React with ✅/⚠️ to mark status updates.
- Post a quick poll for release decisions or meeting times.
- Send celebratory stickers after successful deploys.
- Run weekly “priority check” polls in team channels.
- DM stickers as acknowledgements when a user’s request is completed.
## Action gating
Use `discord.actions.*` to disable action groups:
- `reactions` (react + reactions list + emojiList)
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
- `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`)
### Read recent messages
```json
{
"action": "readMessages",
"channelId": "123",
"limit": 20
}
```
### Send/edit/delete a message
```json
{
"action": "sendMessage",
"to": "channel:123",
"content": "Hello from Clawdis"
}
```
```json
{
"action": "editMessage",
"channelId": "123",
"messageId": "456",
"content": "Fixed typo"
}
```
```json
{
"action": "deleteMessage",
"channelId": "123",
"messageId": "456"
}
```
### Threads
```json
{
"action": "threadCreate",
"channelId": "123",
"name": "Bug triage",
"messageId": "456"
}
```
```json
{
"action": "threadList",
"guildId": "999"
}
```
```json
{
"action": "threadReply",
"channelId": "777",
"content": "Replying in thread"
}
```
### Pins
```json
{
"action": "pinMessage",
"channelId": "123",
"messageId": "456"
}
```
```json
{
"action": "listPins",
"channelId": "123"
}
```
### Search messages
```json
{
"action": "searchMessages",
"guildId": "999",
"content": "release notes",
"channelIds": ["123", "456"],
"limit": 10
}
```
### Member + role info
```json
{
"action": "memberInfo",
"guildId": "999",
"userId": "111"
}
```
```json
{
"action": "roleInfo",
"guildId": "999"
}
```
### List available custom emojis
```json
{
"action": "emojiList",
"guildId": "999"
}
```
### Role changes (disabled by default)
```json
{
"action": "roleAdd",
"guildId": "999",
"userId": "111",
"roleId": "222"
}
```
### Channel info
```json
{
"action": "channelInfo",
"channelId": "123"
}
```
```json
{
"action": "channelList",
"guildId": "999"
}
```
### Voice status
```json
{
"action": "voiceStatus",
"guildId": "999",
"userId": "111"
}
```
### Scheduled events
```json
{
"action": "eventList",
"guildId": "999"
}
```
### Moderation (disabled by default)
```json
{
"action": "timeout",
"guildId": "999",
"userId": "111",
"durationMinutes": 10
}
```
-19
View File
@@ -41,22 +41,3 @@ Voice defaults
- `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`
Confirm voice + speaker before long output.
## Chat voice responses
When Peter asks for a "voice" reply (e.g., "crazy scientist voice", "explain in voice"), generate audio and send it:
```bash
# Generate audio file
sag -v Clawd -o /tmp/voice-reply.mp3 "Your message here"
# Then include in reply:
# MEDIA:/tmp/voice-reply.mp3
```
Voice character tips:
- Crazy scientist: Use `[excited]` tags, dramatic pauses `[short pause]`, vary intensity
- Calm: Use `[whispers]` or slower pacing
- Dramatic: Use `[sings]` or `[shouts]` sparingly
Default voice for Clawd: `lj2rcrvANS3gaWWnczSX` (or just `-v Clawd`)
+5 -14
View File
@@ -1,28 +1,19 @@
---
name: spotify-player
description: Terminal Spotify playback/search via spogo (preferred) or spotify_player.
description: Terminal Spotify client (TUI + CLI commands) for playback and search.
homepage: https://www.spotify.com
metadata: {"clawdis":{"emoji":"🎵","requires":{"anyBins":["spogo","spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spogo","tap":"steipete/tap","bins":["spogo"],"label":"Install spogo (brew)"},{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify_player (brew)"}]}}
metadata: {"clawdis":{"emoji":"🎵","requires":{"bins":["spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify-player (brew)"}]}}
---
# spogo / spotify_player
# spotify_player
Use `spogo` **(preferred)** for Spotify playback/search. Fall back to `spotify_player` if needed.
Use `spotify_player` for Spotify playback/search in the terminal.
Requirements
- Spotify Premium account.
- Either `spogo` or `spotify_player` installed.
spogo setup
- Import cookies: `spogo auth import --browser chrome`
- First-time auth: `spotify_player authenticate`.
Common CLI commands
- Search: `spogo search track "query"`
- Playback: `spogo play|pause|next|prev`
- Devices: `spogo device list`, `spogo device set "<name|id>"`
- Status: `spogo status`
spotify_player commands (fallback)
- Search: `spotify_player search "query"`
- Playback: `spotify_player playback play|pause|next|previous`
- Connect device: `spotify_player connect`
-68
View File
@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from "vitest";
describe("bash tool pty mode", () => {
it("falls back to pipe with warning when node-pty fails to load", async () => {
vi.resetModules();
vi.doMock("node-pty", () => {
throw new Error("boom");
});
const { createBashTool } = await import("./bash-tools.js");
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
const result = await tool.execute("call", {
command: "echo test",
stdinMode: "pty",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("Warning: node-pty failed to load");
expect(text).toContain("falling back to pipe mode.");
vi.doUnmock("node-pty");
});
it("uses node-pty when available", async () => {
vi.resetModules();
const spawn = vi.fn(() => {
let onData: ((data: string) => void) | undefined;
let onExit:
| ((event: { exitCode: number | null; signal?: number | null }) => void)
| undefined;
const pty = {
pid: 4321,
onData: (cb: (data: string) => void) => {
onData = cb;
},
onExit: (
cb: (event: {
exitCode: number | null;
signal?: number | null;
}) => void,
) => {
onExit = cb;
},
write: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
onData?.("hello\n");
onExit?.({ exitCode: 0, signal: null });
}, 10);
return pty;
});
vi.doMock("node-pty", () => ({ spawn }));
const { createBashTool } = await import("./bash-tools.js");
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
const result = await tool.execute("call", {
command: "ignored",
stdinMode: "pty",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("hello");
expect(text).not.toContain("Warning:");
vi.doUnmock("node-pty");
});
});
+19 -120
View File
@@ -1,8 +1,5 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import type { IPty } from "node-pty";
@@ -34,20 +31,16 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
1_000,
150_000,
);
const DEFAULT_SHELL_PATH = "/bin/sh";
const DEFAULT_PATH =
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
const DEFAULT_PTY_NAME = "xterm-256color";
type PtyModule = typeof import("node-pty");
type PtyLoadResult = { module: PtyModule | null; error?: unknown };
let ptyModulePromise: Promise<PtyLoadResult> | null = null;
let ptyModulePromise: Promise<PtyModule | null> | null = null;
async function loadPtyModule(): Promise<PtyLoadResult> {
async function loadPtyModule(): Promise<PtyModule | null> {
if (!ptyModulePromise) {
ptyModulePromise = import("node-pty")
.then((mod) => ({ module: mod }))
.catch((error) => ({ module: null, error }));
.then((mod) => mod)
.catch(() => null);
}
return ptyModulePromise;
}
@@ -161,70 +154,40 @@ export function createBashTool(
const maxOutput = DEFAULT_MAX_OUTPUT;
const startedAt = Date.now();
const sessionId = randomUUID();
const warnings: string[] = [];
const workdir = resolveWorkdir(
params.workdir?.trim() || process.cwd(),
warnings,
);
const workdir = params.workdir?.trim() || process.cwd();
const { shell, args: shellArgs } = getShellConfig();
const env = params.env ? { ...process.env, ...params.env } : process.env;
const requestedStdinMode = params.stdinMode === "pty" ? "pty" : "pipe";
const requestedStdinMode =
params.stdinMode === "pty" ? "pty" : "pipe";
let stdinMode: ProcessStdinMode = requestedStdinMode;
let warning: string | null = null;
let child: ChildProcessWithoutNullStreams | undefined;
let pty: IPty | undefined;
if (stdinMode === "pty") {
const { module: ptyModule, error: ptyError } = await loadPtyModule();
const ptyModule = await loadPtyModule();
if (!ptyModule) {
warning =
`Warning: node-pty failed to load${formatPtyError(ptyError)}; ` +
"falling back to pipe mode.";
"Warning: node-pty failed to load; falling back to pipe mode.";
stdinMode = "pipe";
} else {
const ptyEnv = ensurePath({
const ptyEnv = {
...env,
TERM: env.TERM ?? DEFAULT_PTY_NAME,
} as Record<string, string>);
const ptyShell = resolveShellPath(shell, ptyEnv);
} as Record<string, string>;
try {
pty = ptyModule.spawn(ptyShell, [...shellArgs, params.command], {
pty = ptyModule.spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env: ptyEnv,
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
cols: 120,
rows: 30,
});
} catch (error) {
if (
ptyShell !== DEFAULT_SHELL_PATH &&
existsSync(DEFAULT_SHELL_PATH)
) {
try {
pty = ptyModule.spawn(
DEFAULT_SHELL_PATH,
[...shellArgs, params.command],
{
cwd: workdir,
env: ptyEnv,
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
cols: 120,
rows: 30,
},
);
} catch (fallbackError) {
warning =
`Warning: node-pty failed to start${formatPtyError(fallbackError)}; ` +
"falling back to pipe mode.";
stdinMode = "pipe";
}
} else {
warning =
`Warning: node-pty failed to start${formatPtyError(error)}; ` +
"falling back to pipe mode.";
stdinMode = "pipe";
}
} catch {
warning =
"Warning: node-pty failed to start; falling back to pipe mode.";
stdinMode = "pipe";
}
}
}
@@ -238,8 +201,6 @@ export function createBashTool(
});
}
if (warning) warnings.push(warning);
const session = {
id: sessionId,
command: params.command,
@@ -296,7 +257,7 @@ export function createBashTool(
const emitUpdate = () => {
if (!onUpdate) return;
const tailText = session.tail || session.aggregated;
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
const warningText = warning ? `${warning}\n\n` : "";
onUpdate({
content: [{ type: "text", text: warningText + (tailText || "") }],
details: {
@@ -346,7 +307,7 @@ export function createBashTool(
{
type: "text",
text:
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
`${warning ? `${warning}\n\n` : ""}` +
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
},
@@ -420,7 +381,7 @@ export function createBashTool(
{
type: "text",
text:
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
`${warning ? `${warning}\n\n` : ""}` +
(aggregated || "(no output)"),
},
],
@@ -925,68 +886,6 @@ function killSession(session: {
}
}
function resolveWorkdir(workdir: string, warnings: string[]) {
const current = safeCwd();
const fallback = current ?? homedir();
try {
const stats = statSync(workdir);
if (stats.isDirectory()) return workdir;
} catch {
// ignore, fallback below
}
warnings.push(
`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`,
);
return fallback;
}
function safeCwd() {
try {
const cwd = process.cwd();
return existsSync(cwd) ? cwd : null;
} catch {
return null;
}
}
function ensurePath(env: Record<string, string>) {
if (!env.PATH?.trim()) {
env.PATH = DEFAULT_PATH;
}
return env;
}
function resolveShellPath(shell: string, env: Record<string, string>) {
if (process.platform === "win32") return shell;
if (shell.includes("/") && existsSync(shell)) {
return shell;
}
const searchPath = env.PATH ?? "";
for (const segment of searchPath.split(path.delimiter)) {
if (!segment) continue;
const candidate = path.join(segment, shell);
if (existsSync(candidate)) return candidate;
}
if (existsSync(DEFAULT_SHELL_PATH)) {
return DEFAULT_SHELL_PATH;
}
return shell;
}
function formatPtyError(error: unknown) {
if (!error) return "";
if (typeof error === "string") return ` (${error})`;
if (error instanceof Error) {
const firstLine = error.message.split(/\r?\n/)[0]?.trim();
return firstLine ? ` (${firstLine})` : "";
}
try {
return ` (${JSON.stringify(error)})`;
} catch {
return "";
}
}
function clampNumber(
value: number | undefined,
defaultValue: number,
+22 -763
View File
@@ -40,37 +40,8 @@ import {
writeScreenRecordToFile,
} from "../cli/nodes-screen.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { type DiscordActionConfig, loadConfig } from "../config/config.js";
import {
addRoleDiscord,
banMemberDiscord,
createScheduledEventDiscord,
createThreadDiscord,
deleteMessageDiscord,
editMessageDiscord,
fetchChannelInfoDiscord,
fetchChannelPermissionsDiscord,
fetchMemberInfoDiscord,
fetchReactionsDiscord,
fetchRoleInfoDiscord,
fetchVoiceStatusDiscord,
kickMemberDiscord,
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listPinsDiscord,
listScheduledEventsDiscord,
listThreadsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeRoleDiscord,
searchMessagesDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
timeoutMemberDiscord,
unpinMessageDiscord,
} from "../discord/send.js";
import { loadConfig } from "../config/config.js";
import { reactMessageDiscord } from "../discord/send.js";
import { callGateway } from "../gateway/call.js";
import { detectMime, imageMimeFromFormat } from "../media/mime.js";
import { sanitizeToolResultImages } from "./tool-images.js";
@@ -137,46 +108,6 @@ function readStringParam(
return value;
}
function readStringArrayParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions & { required: true },
): string[];
function readStringArrayParam(
params: Record<string, unknown>,
key: string,
options?: StringParamOptions,
): string[] | undefined;
function readStringArrayParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions = {},
) {
const { required = false, label = key } = options;
const raw = params[key];
if (Array.isArray(raw)) {
const values = raw
.filter((entry) => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean);
if (values.length === 0) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return values;
}
if (typeof raw === "string") {
const value = raw.trim();
if (!value) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return [value];
}
if (required) throw new Error(`${label} required`);
return undefined;
}
async function callGatewayTool<T = unknown>(
method: string,
opts: GatewayCallOptions,
@@ -1555,709 +1486,37 @@ const DiscordToolSchema = Type.Union([
messageId: Type.String(),
emoji: Type.String(),
}),
Type.Object({
action: Type.Literal("reactions"),
channelId: Type.String(),
messageId: Type.String(),
limit: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("sticker"),
to: Type.String(),
stickerIds: Type.Array(Type.String()),
content: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("poll"),
to: Type.String(),
question: Type.String(),
answers: Type.Array(Type.String()),
allowMultiselect: Type.Optional(Type.Boolean()),
durationHours: Type.Optional(Type.Number()),
content: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("permissions"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("readMessages"),
channelId: Type.String(),
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("sendMessage"),
to: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("editMessage"),
channelId: Type.String(),
messageId: Type.String(),
content: Type.String(),
}),
Type.Object({
action: Type.Literal("deleteMessage"),
channelId: Type.String(),
messageId: Type.String(),
}),
Type.Object({
action: Type.Literal("threadCreate"),
channelId: Type.String(),
name: Type.String(),
messageId: Type.Optional(Type.String()),
autoArchiveMinutes: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("threadList"),
guildId: Type.String(),
channelId: Type.Optional(Type.String()),
includeArchived: Type.Optional(Type.Boolean()),
before: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("threadReply"),
channelId: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("pinMessage"),
channelId: Type.String(),
messageId: Type.String(),
}),
Type.Object({
action: Type.Literal("unpinMessage"),
channelId: Type.String(),
messageId: Type.String(),
}),
Type.Object({
action: Type.Literal("listPins"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("searchMessages"),
guildId: Type.String(),
content: Type.String(),
channelId: Type.Optional(Type.String()),
channelIds: Type.Optional(Type.Array(Type.String())),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
limit: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("memberInfo"),
guildId: Type.String(),
userId: Type.String(),
}),
Type.Object({
action: Type.Literal("roleInfo"),
guildId: Type.String(),
}),
Type.Object({
action: Type.Literal("emojiList"),
guildId: Type.String(),
}),
Type.Object({
action: Type.Literal("roleAdd"),
guildId: Type.String(),
userId: Type.String(),
roleId: Type.String(),
}),
Type.Object({
action: Type.Literal("roleRemove"),
guildId: Type.String(),
userId: Type.String(),
roleId: Type.String(),
}),
Type.Object({
action: Type.Literal("channelInfo"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("channelList"),
guildId: Type.String(),
}),
Type.Object({
action: Type.Literal("voiceStatus"),
guildId: Type.String(),
userId: Type.String(),
}),
Type.Object({
action: Type.Literal("eventList"),
guildId: Type.String(),
}),
Type.Object({
action: Type.Literal("eventCreate"),
guildId: Type.String(),
name: Type.String(),
startTime: Type.String(),
endTime: Type.Optional(Type.String()),
description: Type.Optional(Type.String()),
channelId: Type.Optional(Type.String()),
entityType: Type.Optional(
Type.Union([
Type.Literal("voice"),
Type.Literal("stage"),
Type.Literal("external"),
]),
),
location: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("timeout"),
guildId: Type.String(),
userId: Type.String(),
durationMinutes: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("kick"),
guildId: Type.String(),
userId: Type.String(),
reason: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("ban"),
guildId: Type.String(),
userId: Type.String(),
reason: Type.Optional(Type.String()),
deleteMessageDays: Type.Optional(Type.Number()),
}),
]);
function createDiscordTool(): AnyAgentTool {
return {
label: "Clawdis Discord",
name: "discord",
description: "Manage Discord messages, reactions, and moderation.",
name: "clawdis_discord",
description:
"React to Discord messages. Controlled by discord.enableReactions (default: true).",
parameters: DiscordToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const cfg = loadConfig();
const isActionEnabled = (
key: keyof DiscordActionConfig,
defaultValue = true,
) => {
const value = cfg.discord?.actions?.[key];
if (value === undefined) return defaultValue;
return value !== false;
};
if (action !== "react") throw new Error(`Unknown action: ${action}`);
switch (action) {
case "react": {
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { required: true });
await reactMessageDiscord(channelId, messageId, emoji);
return jsonResult({ ok: true });
}
case "reactions": {
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw)
? limitRaw
: undefined;
const reactions = await fetchReactionsDiscord(channelId, messageId, {
limit,
});
return jsonResult({ ok: true, reactions });
}
case "sticker": {
if (!isActionEnabled("stickers")) {
throw new Error("Discord stickers are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content");
const stickerIds = readStringArrayParam(params, "stickerIds", {
required: true,
label: "stickerIds",
});
await sendStickerDiscord(to, stickerIds, { content });
return jsonResult({ ok: true });
}
case "poll": {
if (!isActionEnabled("polls")) {
throw new Error("Discord polls are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content");
const question = readStringParam(params, "question", {
required: true,
});
const answers = readStringArrayParam(params, "answers", {
required: true,
label: "answers",
});
const allowMultiselectRaw = params.allowMultiselect;
const allowMultiselect =
typeof allowMultiselectRaw === "boolean"
? allowMultiselectRaw
: undefined;
const durationRaw = params.durationHours;
const durationHours =
typeof durationRaw === "number" && Number.isFinite(durationRaw)
? durationRaw
: undefined;
await sendPollDiscord(
to,
{ question, answers, allowMultiselect, durationHours },
{ content },
);
return jsonResult({ ok: true });
}
case "permissions": {
if (!isActionEnabled("permissions")) {
throw new Error("Discord permissions are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const permissions = await fetchChannelPermissionsDiscord(channelId);
return jsonResult({ ok: true, permissions });
}
case "readMessages": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message reads are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messages = await readMessagesDiscord(channelId, {
limit:
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
});
return jsonResult({ ok: true, messages });
}
case "sendMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message sends are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", {
required: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const result = await sendMessageDiscord(to, content, {
mediaUrl,
replyTo,
});
return jsonResult({ ok: true, result });
}
case "editMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message edits are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "content", {
required: true,
});
const message = await editMessageDiscord(channelId, messageId, {
content,
});
return jsonResult({ ok: true, message });
}
case "deleteMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message deletes are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
await deleteMessageDiscord(channelId, messageId);
return jsonResult({ ok: true });
}
case "threadCreate": {
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const name = readStringParam(params, "name", { required: true });
const messageId = readStringParam(params, "messageId");
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
const autoArchiveMinutes =
typeof autoArchiveMinutesRaw === "number" &&
Number.isFinite(autoArchiveMinutesRaw)
? autoArchiveMinutesRaw
: undefined;
const thread = await createThreadDiscord(channelId, {
name,
messageId,
autoArchiveMinutes,
});
return jsonResult({ ok: true, thread });
}
case "threadList": {
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean"
? params.includeArchived
: undefined;
const before = readStringParam(params, "before");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const threads = await listThreadsDiscord({
guildId,
channelId,
includeArchived,
before,
limit,
});
return jsonResult({ ok: true, threads });
}
case "threadReply": {
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const content = readStringParam(params, "content", {
required: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const result = await sendMessageDiscord(
`channel:${channelId}`,
content,
{
mediaUrl,
replyTo,
},
);
return jsonResult({ ok: true, result });
}
case "pinMessage": {
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
await pinMessageDiscord(channelId, messageId);
return jsonResult({ ok: true });
}
case "unpinMessage": {
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
await unpinMessageDiscord(channelId, messageId);
return jsonResult({ ok: true });
}
case "listPins": {
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const pins = await listPinsDiscord(channelId);
return jsonResult({ ok: true, pins });
}
case "searchMessages": {
if (!isActionEnabled("search")) {
throw new Error("Discord search is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const content = readStringParam(params, "content", {
required: true,
});
const channelId = readStringParam(params, "channelId");
const channelIds = readStringArrayParam(params, "channelIds");
const authorId = readStringParam(params, "authorId");
const authorIds = readStringArrayParam(params, "authorIds");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const channelIdList = [
...(channelIds ?? []),
...(channelId ? [channelId] : []),
];
const authorIdList = [
...(authorIds ?? []),
...(authorId ? [authorId] : []),
];
const results = await searchMessagesDiscord({
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
});
return jsonResult({ ok: true, results });
}
case "memberInfo": {
if (!isActionEnabled("memberInfo")) {
throw new Error("Discord member info is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const member = await fetchMemberInfoDiscord(guildId, userId);
return jsonResult({ ok: true, member });
}
case "roleInfo": {
if (!isActionEnabled("roleInfo")) {
throw new Error("Discord role info is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const roles = await fetchRoleInfoDiscord(guildId);
return jsonResult({ ok: true, roles });
}
case "emojiList": {
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const emojis = await listGuildEmojisDiscord(guildId);
return jsonResult({ ok: true, emojis });
}
case "roleAdd": {
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const roleId = readStringParam(params, "roleId", {
required: true,
});
await addRoleDiscord({ guildId, userId, roleId });
return jsonResult({ ok: true });
}
case "roleRemove": {
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const roleId = readStringParam(params, "roleId", {
required: true,
});
await removeRoleDiscord({ guildId, userId, roleId });
return jsonResult({ ok: true });
}
case "channelInfo": {
if (!isActionEnabled("channelInfo")) {
throw new Error("Discord channel info is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channel = await fetchChannelInfoDiscord(channelId);
return jsonResult({ ok: true, channel });
}
case "channelList": {
if (!isActionEnabled("channelInfo")) {
throw new Error("Discord channel info is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const channels = await listGuildChannelsDiscord(guildId);
return jsonResult({ ok: true, channels });
}
case "voiceStatus": {
if (!isActionEnabled("voiceStatus")) {
throw new Error("Discord voice status is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const voice = await fetchVoiceStatusDiscord(guildId, userId);
return jsonResult({ ok: true, voice });
}
case "eventList": {
if (!isActionEnabled("events")) {
throw new Error("Discord events are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const events = await listScheduledEventsDiscord(guildId);
return jsonResult({ ok: true, events });
}
case "eventCreate": {
if (!isActionEnabled("events")) {
throw new Error("Discord events are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const name = readStringParam(params, "name", { required: true });
const startTime = readStringParam(params, "startTime", {
required: true,
});
const endTime = readStringParam(params, "endTime");
const description = readStringParam(params, "description");
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const entityTypeRaw = readStringParam(params, "entityType");
const entityType =
entityTypeRaw === "stage"
? 1
: entityTypeRaw === "external"
? 3
: 2;
const payload = {
name,
description,
scheduled_start_time: startTime,
scheduled_end_time: endTime,
entity_type: entityType,
channel_id: channelId,
entity_metadata:
entityType === 3 && location ? { location } : undefined,
privacy_level: 2,
};
const event = await createScheduledEventDiscord(guildId, payload);
return jsonResult({ ok: true, event });
}
case "timeout": {
if (!isActionEnabled("moderation", false)) {
throw new Error("Discord moderation is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const durationMinutes =
typeof params.durationMinutes === "number" &&
Number.isFinite(params.durationMinutes)
? params.durationMinutes
: undefined;
const until = readStringParam(params, "until");
const reason = readStringParam(params, "reason");
const member = await timeoutMemberDiscord({
guildId,
userId,
durationMinutes,
until,
reason,
});
return jsonResult({ ok: true, member });
}
case "kick": {
if (!isActionEnabled("moderation", false)) {
throw new Error("Discord moderation is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const reason = readStringParam(params, "reason");
await kickMemberDiscord({ guildId, userId, reason });
return jsonResult({ ok: true });
}
case "ban": {
if (!isActionEnabled("moderation", false)) {
throw new Error("Discord moderation is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const reason = readStringParam(params, "reason");
const deleteMessageDays =
typeof params.deleteMessageDays === "number" &&
Number.isFinite(params.deleteMessageDays)
? params.deleteMessageDays
: undefined;
await banMemberDiscord({
guildId,
userId,
reason,
deleteMessageDays,
});
return jsonResult({ ok: true });
}
default:
throw new Error(`Unknown action: ${action}`);
const cfg = loadConfig();
if (cfg.discord?.enableReactions === false) {
throw new Error(
"Discord reactions are disabled (set discord.enableReactions=true).",
);
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { required: true });
await reactMessageDiscord(channelId, messageId, emoji);
return jsonResult({ ok: true });
},
};
}
@@ -14,8 +14,6 @@ vi.mock("../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
-83
View File
@@ -26,8 +26,6 @@ vi.mock("../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
@@ -99,47 +97,6 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("please now");
});
it("parses queue options and modes", () => {
const res = extractQueueDirective(
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
);
expect(res.hasDirective).toBe(true);
expect(res.queueMode).toBe("steer-backlog");
expect(res.debounceMs).toBe(2000);
expect(res.cap).toBe(5);
expect(res.dropPolicy).toBe("summarize");
expect(res.cleaned).toBe("please now");
});
it("errors on invalid queue options", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/queue collect debounce:bogus cap:zero drop:maybe",
From: "+1222",
To: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Invalid debounce");
expect(text).toContain("Invalid cap");
expect(text).toContain("Invalid drop policy");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("extracts reply_to_current tag", () => {
const res = extractReplyToTag("ok [[reply_to_current]]", "msg-1");
expect(res.replyToId).toBe("msg-1");
@@ -319,43 +276,6 @@ describe("directive parsing", () => {
});
});
it("persists queue options when directive is standalone", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/queue collect debounce:2s cap:5 drop:old",
From: "+1222",
To: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toMatch(/^⚙️ Queue mode set to collect\./);
expect(text).toMatch(/Queue debounce set to 2000ms/);
expect(text).toMatch(/Queue cap set to 5/);
expect(text).toMatch(/Queue drop set to old/);
const store = loadSessionStore(storePath);
const entry = Object.values(store)[0];
expect(entry?.queueMode).toBe("collect");
expect(entry?.queueDebounceMs).toBe(2000);
expect(entry?.queueCap).toBe(5);
expect(entry?.queueDrop).toBe("old");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("resets queue mode to default", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
@@ -392,9 +312,6 @@ describe("directive parsing", () => {
const store = loadSessionStore(storePath);
const entry = Object.values(store)[0];
expect(entry?.queueMode).toBeUndefined();
expect(entry?.queueDebounceMs).toBeUndefined();
expect(entry?.queueCap).toBeUndefined();
expect(entry?.queueDrop).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
-165
View File
@@ -1,165 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isEmbeddedPiRunActive,
isEmbeddedPiRunStreaming,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
function makeResult(text: string) {
return {
payloads: [{ text }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
}
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-queue-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(base);
} finally {
process.env.HOME = previousHome;
try {
await fs.rm(base, { recursive: true, force: true });
} catch {
// ignore cleanup failures in tests
}
}
}
function makeCfg(home: string, queue?: Record<string, unknown>) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
routing: queue ? { queue } : undefined,
};
}
describe("queue followups", () => {
afterEach(() => {
vi.useRealTimers();
});
it("collects queued messages and drains after run completes", async () => {
vi.useFakeTimers();
await withTempHome(async (home) => {
const prompts: string[] = [];
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
prompts.push(params.prompt);
if (params.prompt.includes("[Queued messages while agent was busy]")) {
return makeResult("followup");
}
return makeResult("main");
});
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(true);
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(true);
const cfg = makeCfg(home, {
mode: "collect",
debounceMs: 200,
cap: 10,
drop: "summarize",
});
const first = await getReplyFromConfig(
{ Body: "first", From: "+1001", To: "+2000" },
{},
cfg,
);
expect(first).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(false);
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(false);
const second = await getReplyFromConfig(
{ Body: "second", From: "+1001", To: "+2000" },
{},
cfg,
);
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
expect(secondText).toBe("main");
await vi.runAllTimersAsync();
await Promise.resolve();
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
expect(
prompts.some((p) =>
p.includes("[Queued messages while agent was busy]"),
),
).toBe(true);
});
});
it("summarizes dropped followups when cap is exceeded", async () => {
vi.useFakeTimers();
await withTempHome(async (home) => {
const prompts: string[] = [];
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
prompts.push(params.prompt);
return makeResult("ok");
});
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(true);
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(false);
const cfg = makeCfg(home, {
mode: "followup",
debounceMs: 0,
cap: 1,
drop: "summarize",
});
await getReplyFromConfig(
{ Body: "one", From: "+1002", To: "+2000" },
{},
cfg,
);
await getReplyFromConfig(
{ Body: "two", From: "+1002", To: "+2000" },
{},
cfg,
);
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(false);
await getReplyFromConfig(
{ Body: "three", From: "+1002", To: "+2000" },
{},
cfg,
);
await vi.runAllTimersAsync();
await Promise.resolve();
expect(prompts.some((p) => p.includes("[Queue overflow]"))).toBe(true);
});
});
});
+3 -7
View File
@@ -9,8 +9,6 @@ vi.mock("../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
@@ -450,8 +448,6 @@ describe("trigger handling", () => {
});
describe("group intro prompts", () => {
const groupParticipationNote =
"Be a good group participant: lurk and follow the conversation, but only chime in when you have something genuinely helpful or relevant to add. Don't feel obligated to respond to every message — quality over quantity. Even when lurking silently, you can use emoji reactions to acknowledge messages, show support, or react to humor — reactions are always appreciated and don't clutter the chat.";
it("labels Discord groups using the surface metadata", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
@@ -481,7 +477,7 @@ describe("group intro prompts", () => {
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe(
`You are replying inside the Discord group "Release Squad". Group members: Alice, Bob. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
'You are replying inside the Discord group "Release Squad". Group members: Alice, Bob. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). Address the specific sender noted in the message context.',
);
});
});
@@ -514,7 +510,7 @@ describe("group intro prompts", () => {
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe(
`You are replying inside the WhatsApp group "Ops". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
'You are replying inside the WhatsApp group "Ops". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). Address the specific sender noted in the message context.',
);
});
});
@@ -547,7 +543,7 @@ describe("group intro prompts", () => {
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe(
`You are replying inside the Telegram group "Dev Chat". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
'You are replying inside the Telegram group "Dev Chat". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). Address the specific sender noted in the message context.',
);
});
});
+53 -757
View File
@@ -16,21 +16,15 @@ import {
} from "../agents/model-selection.js";
import {
abortEmbeddedPiRun,
isEmbeddedPiRunActive,
isEmbeddedPiRunStreaming,
queueEmbeddedPiMessage,
resolveEmbeddedSessionLane,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import {
buildWorkspaceSkillSnapshot,
type SkillSnapshot,
} from "../agents/skills.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
} from "../agents/workspace.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { type ClawdisConfig, loadConfig } from "../config/config.js";
import {
buildGroupDisplayName,
@@ -81,68 +75,11 @@ const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
const ABORT_MEMORY = new Map<string, boolean>();
const SYSTEM_MARK = "⚙️";
type QueueMode =
| "steer"
| "followup"
| "collect"
| "steer-backlog"
| "interrupt"
| "queue";
type QueueDropPolicy = "old" | "new" | "summarize";
type QueueSettings = {
mode: QueueMode;
debounceMs?: number;
cap?: number;
dropPolicy?: QueueDropPolicy;
};
type FollowupRun = {
prompt: string;
summaryLine?: string;
enqueuedAt: number;
run: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
workspaceDir: string;
config: ClawdisConfig;
skillsSnapshot?: SkillSnapshot;
provider: string;
model: string;
thinkLevel?: ThinkLevel;
verboseLevel?: VerboseLevel;
timeoutMs: number;
blockReplyBreak: "text_end" | "message_end";
ownerNumbers?: string[];
extraSystemPrompt?: string;
enforceFinalTag?: boolean;
};
};
type FollowupQueueState = {
items: FollowupRun[];
draining: boolean;
lastEnqueuedAt: number;
mode: QueueMode;
debounceMs: number;
cap: number;
dropPolicy: QueueDropPolicy;
droppedCount: number;
summaryLines: string[];
lastRun?: FollowupRun["run"];
};
const DEFAULT_QUEUE_DEBOUNCE_MS = 1000;
const DEFAULT_QUEUE_CAP = 20;
const DEFAULT_QUEUE_DROP: QueueDropPolicy = "summarize";
const FOLLOWUP_QUEUES = new Map<string, FollowupQueueState>();
const BARE_SESSION_RESET_PROMPT =
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
type QueueMode = "queue" | "interrupt";
export function extractThinkDirective(body?: string): {
cleaned: string;
thinkLevel?: ThinkLevel;
@@ -191,212 +128,39 @@ export function extractVerboseDirective(body?: string): {
function normalizeQueueMode(raw?: string): QueueMode | undefined {
if (!raw) return undefined;
const cleaned = raw.trim().toLowerCase();
if (cleaned === "queue" || cleaned === "queued") return "steer";
if (cleaned === "queue" || cleaned === "queued") return "queue";
if (
cleaned === "interrupt" ||
cleaned === "interrupts" ||
cleaned === "abort"
)
return "interrupt";
if (cleaned === "steer" || cleaned === "steering") return "steer";
if (
cleaned === "followup" ||
cleaned === "follow-ups" ||
cleaned === "followups"
)
return "followup";
if (cleaned === "collect" || cleaned === "coalesce") return "collect";
if (
cleaned === "steer+backlog" ||
cleaned === "steer-backlog" ||
cleaned === "steer_backlog"
)
return "steer-backlog";
return undefined;
}
function normalizeQueueDropPolicy(raw?: string): QueueDropPolicy | undefined {
if (!raw) return undefined;
const cleaned = raw.trim().toLowerCase();
if (cleaned === "old" || cleaned === "oldest") return "old";
if (cleaned === "new" || cleaned === "newest") return "new";
if (cleaned === "summarize" || cleaned === "summary") return "summarize";
return undefined;
}
function parseQueueDebounce(raw?: string): number | undefined {
if (!raw) return undefined;
try {
const parsed = parseDurationMs(raw.trim(), { defaultUnit: "ms" });
if (!parsed || parsed < 0) return undefined;
return Math.round(parsed);
} catch {
return undefined;
}
}
function parseQueueCap(raw?: string): number | undefined {
if (!raw) return undefined;
const num = Number(raw);
if (!Number.isFinite(num)) return undefined;
const cap = Math.floor(num);
if (cap < 1) return undefined;
return cap;
}
function parseQueueDirectiveArgs(raw: string): {
consumed: number;
queueMode?: QueueMode;
queueReset: boolean;
rawMode?: string;
debounceMs?: number;
cap?: number;
dropPolicy?: QueueDropPolicy;
rawDebounce?: string;
rawCap?: string;
rawDrop?: string;
hasOptions: boolean;
} {
let i = 0;
const len = raw.length;
while (i < len && /\s/.test(raw[i])) i += 1;
if (raw[i] === ":") {
i += 1;
while (i < len && /\s/.test(raw[i])) i += 1;
}
let consumed = i;
let queueMode: QueueMode | undefined;
let queueReset = false;
let rawMode: string | undefined;
let debounceMs: number | undefined;
let cap: number | undefined;
let dropPolicy: QueueDropPolicy | undefined;
let rawDebounce: string | undefined;
let rawCap: string | undefined;
let rawDrop: string | undefined;
let hasOptions = false;
const takeToken = (): string | null => {
if (i >= len) return null;
const start = i;
while (i < len && !/\s/.test(raw[i])) i += 1;
if (start === i) return null;
const token = raw.slice(start, i);
while (i < len && /\s/.test(raw[i])) i += 1;
return token;
};
while (i < len) {
const token = takeToken();
if (!token) break;
const lowered = token.trim().toLowerCase();
if (lowered === "default" || lowered === "reset" || lowered === "clear") {
queueReset = true;
consumed = i;
break;
}
if (lowered.startsWith("debounce:") || lowered.startsWith("debounce=")) {
rawDebounce = token.split(/[:=]/)[1] ?? "";
debounceMs = parseQueueDebounce(rawDebounce);
hasOptions = true;
consumed = i;
continue;
}
if (lowered.startsWith("cap:") || lowered.startsWith("cap=")) {
rawCap = token.split(/[:=]/)[1] ?? "";
cap = parseQueueCap(rawCap);
hasOptions = true;
consumed = i;
continue;
}
if (lowered.startsWith("drop:") || lowered.startsWith("drop=")) {
rawDrop = token.split(/[:=]/)[1] ?? "";
dropPolicy = normalizeQueueDropPolicy(rawDrop);
hasOptions = true;
consumed = i;
continue;
}
const mode = normalizeQueueMode(token);
if (mode) {
queueMode = mode;
rawMode = token;
consumed = i;
continue;
}
// Stop at first unrecognized token.
break;
}
return {
consumed,
queueMode,
queueReset,
rawMode,
debounceMs,
cap,
dropPolicy,
rawDebounce,
rawCap,
rawDrop,
hasOptions,
};
}
export function extractQueueDirective(body?: string): {
cleaned: string;
queueMode?: QueueMode;
queueReset: boolean;
rawMode?: string;
hasDirective: boolean;
debounceMs?: number;
cap?: number;
dropPolicy?: QueueDropPolicy;
rawDebounce?: string;
rawCap?: string;
rawDrop?: string;
hasOptions: boolean;
} {
if (!body)
return {
cleaned: "",
hasDirective: false,
queueReset: false,
hasOptions: false,
};
const re = /(?:^|\s)\/queue(?=$|\s|:)/i;
const match = re.exec(body);
if (!match) {
return {
cleaned: body.trim(),
hasDirective: false,
queueReset: false,
hasOptions: false,
};
}
const start = match.index + match[0].indexOf("/queue");
const argsStart = start + "/queue".length;
const args = body.slice(argsStart);
const parsed = parseQueueDirectiveArgs(args);
const cleanedRaw =
body.slice(0, start) + body.slice(argsStart + parsed.consumed);
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
if (!body) return { cleaned: "", hasDirective: false, queueReset: false };
const match = body.match(/(?:^|\s)\/queue(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i);
const rawMode = match?.[1];
const lowered = rawMode?.trim().toLowerCase();
const queueReset =
lowered === "default" || lowered === "reset" || lowered === "clear";
const queueMode = queueReset ? undefined : normalizeQueueMode(rawMode);
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim();
return {
cleaned,
queueMode: parsed.queueMode,
queueReset: parsed.queueReset,
rawMode: parsed.rawMode,
debounceMs: parsed.debounceMs,
cap: parsed.cap,
dropPolicy: parsed.dropPolicy,
rawDebounce: parsed.rawDebounce,
rawCap: parsed.rawCap,
rawDrop: parsed.rawDrop,
hasDirective: true,
hasOptions: parsed.hasOptions,
queueMode,
queueReset,
rawMode,
hasDirective: !!match,
};
}
@@ -436,181 +200,6 @@ export function extractReplyToTag(
return { cleaned, replyToId, hasTag };
}
function elideText(text: string, limit = 140): string {
if (text.length <= limit) return text;
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
function buildQueueSummaryLine(run: FollowupRun): string {
const base = run.summaryLine?.trim() || run.prompt.trim();
const cleaned = base.replace(/\s+/g, " ").trim();
return elideText(cleaned, 160);
}
function getFollowupQueue(
key: string,
settings: QueueSettings,
): FollowupQueueState {
const existing = FOLLOWUP_QUEUES.get(key);
if (existing) {
existing.mode = settings.mode;
existing.debounceMs =
typeof settings.debounceMs === "number"
? Math.max(0, settings.debounceMs)
: existing.debounceMs;
existing.cap =
typeof settings.cap === "number" && settings.cap > 0
? Math.floor(settings.cap)
: existing.cap;
existing.dropPolicy = settings.dropPolicy ?? existing.dropPolicy;
return existing;
}
const created: FollowupQueueState = {
items: [],
draining: false,
lastEnqueuedAt: 0,
mode: settings.mode,
debounceMs:
typeof settings.debounceMs === "number"
? Math.max(0, settings.debounceMs)
: DEFAULT_QUEUE_DEBOUNCE_MS,
cap:
typeof settings.cap === "number" && settings.cap > 0
? Math.floor(settings.cap)
: DEFAULT_QUEUE_CAP,
dropPolicy: settings.dropPolicy ?? DEFAULT_QUEUE_DROP,
droppedCount: 0,
summaryLines: [],
};
FOLLOWUP_QUEUES.set(key, created);
return created;
}
function enqueueFollowupRun(
key: string,
run: FollowupRun,
settings: QueueSettings,
): boolean {
const queue = getFollowupQueue(key, settings);
queue.lastEnqueuedAt = Date.now();
queue.lastRun = run.run;
const cap = queue.cap;
if (cap > 0 && queue.items.length >= cap) {
if (queue.dropPolicy === "new") {
return false;
}
const dropCount = queue.items.length - cap + 1;
const dropped = queue.items.splice(0, dropCount);
if (queue.dropPolicy === "summarize") {
for (const item of dropped) {
queue.droppedCount += 1;
queue.summaryLines.push(buildQueueSummaryLine(item));
}
while (queue.summaryLines.length > cap) queue.summaryLines.shift();
}
}
queue.items.push(run);
return true;
}
async function waitForQueueDebounce(queue: FollowupQueueState): Promise<void> {
const debounceMs = Math.max(0, queue.debounceMs);
if (debounceMs <= 0) return;
while (true) {
const since = Date.now() - queue.lastEnqueuedAt;
if (since >= debounceMs) return;
await new Promise((resolve) => setTimeout(resolve, debounceMs - since));
}
}
function buildSummaryPrompt(queue: FollowupQueueState): string | undefined {
if (queue.dropPolicy !== "summarize" || queue.droppedCount <= 0) {
return undefined;
}
const lines = [
`[Queue overflow] Dropped ${queue.droppedCount} message${queue.droppedCount === 1 ? "" : "s"} due to cap.`,
];
if (queue.summaryLines.length > 0) {
lines.push("Summary:");
for (const line of queue.summaryLines) {
lines.push(`- ${line}`);
}
}
queue.droppedCount = 0;
queue.summaryLines = [];
return lines.join("\n");
}
function buildCollectPrompt(items: FollowupRun[], summary?: string): string {
const blocks: string[] = ["[Queued messages while agent was busy]"];
if (summary) {
blocks.push(summary);
}
items.forEach((item, idx) => {
blocks.push(`---\nQueued #${idx + 1}\n${item.prompt}`.trim());
});
return blocks.join("\n\n");
}
function scheduleFollowupDrain(
key: string,
runFollowup: (run: FollowupRun) => Promise<void>,
): void {
const queue = FOLLOWUP_QUEUES.get(key);
if (!queue || queue.draining) return;
queue.draining = true;
void (async () => {
try {
while (queue.items.length > 0 || queue.droppedCount > 0) {
await waitForQueueDebounce(queue);
if (queue.mode === "collect") {
const items = queue.items.splice(0, queue.items.length);
const summary = buildSummaryPrompt(queue);
const run = items.at(-1)?.run ?? queue.lastRun;
if (!run) break;
const prompt = buildCollectPrompt(items, summary);
await runFollowup({
prompt,
run,
enqueuedAt: Date.now(),
});
continue;
}
const summaryPrompt = buildSummaryPrompt(queue);
if (summaryPrompt) {
const run = queue.lastRun;
if (!run) break;
await runFollowup({
prompt: summaryPrompt,
run,
enqueuedAt: Date.now(),
});
continue;
}
const next = queue.items.shift();
if (!next) break;
await runFollowup(next);
}
} catch (err) {
defaultRuntime.error?.(
`followup queue drain failed for ${key}: ${String(err)}`,
);
} finally {
queue.draining = false;
if (queue.items.length === 0 && queue.droppedCount === 0) {
FOLLOWUP_QUEUES.delete(key);
} else {
scheduleFollowupDrain(key, runFollowup);
}
}
})();
}
function isAbortTrigger(text?: string): boolean {
if (!text) return false;
const normalized = text.trim().toLowerCase();
@@ -662,59 +251,32 @@ function stripMentions(
function defaultQueueModeForSurface(surface?: string): QueueMode {
const normalized = surface?.trim().toLowerCase();
if (normalized === "discord") return "collect";
if (normalized === "webchat") return "collect";
if (normalized === "whatsapp") return "collect";
if (normalized === "telegram") return "collect";
if (normalized === "imessage") return "collect";
if (normalized === "signal") return "collect";
return "collect";
if (normalized === "discord") return "queue";
if (normalized === "webchat") return "queue";
return "interrupt";
}
function resolveQueueSettings(params: {
function resolveQueueMode(params: {
cfg: ClawdisConfig;
surface?: string;
sessionEntry?: SessionEntry;
inlineMode?: QueueMode;
inlineOptions?: Partial<QueueSettings>;
}): QueueSettings {
}): QueueMode {
const surfaceKey = params.surface?.trim().toLowerCase();
const queueCfg = params.cfg.routing?.queue;
const surfaceModeRaw =
const surfaceMode =
surfaceKey && queueCfg?.bySurface
? (queueCfg.bySurface as Record<string, string | undefined>)[surfaceKey]
? (queueCfg.bySurface as Record<string, QueueMode | undefined>)[
surfaceKey
]
: undefined;
const resolvedMode =
return (
params.inlineMode ??
normalizeQueueMode(params.sessionEntry?.queueMode) ??
normalizeQueueMode(surfaceModeRaw) ??
normalizeQueueMode(queueCfg?.mode) ??
defaultQueueModeForSurface(surfaceKey);
const debounceRaw =
params.inlineOptions?.debounceMs ??
params.sessionEntry?.queueDebounceMs ??
queueCfg?.debounceMs ??
DEFAULT_QUEUE_DEBOUNCE_MS;
const capRaw =
params.inlineOptions?.cap ??
params.sessionEntry?.queueCap ??
queueCfg?.cap ??
DEFAULT_QUEUE_CAP;
const dropRaw =
params.inlineOptions?.dropPolicy ??
params.sessionEntry?.queueDrop ??
normalizeQueueDropPolicy(queueCfg?.drop) ??
DEFAULT_QUEUE_DROP;
return {
mode: resolvedMode,
debounceMs:
typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
cap:
typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
dropPolicy: dropRaw,
};
params.sessionEntry?.queueMode ??
surfaceMode ??
queueCfg?.mode ??
defaultQueueModeForSurface(surfaceKey)
);
}
export async function getReplyFromConfig(
@@ -914,9 +476,6 @@ export async function getReplyFromConfig(
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
queueMode: baseEntry?.queueMode,
queueDebounceMs: baseEntry?.queueDebounceMs,
queueCap: baseEntry?.queueCap,
queueDrop: baseEntry?.queueDrop,
displayName: baseEntry?.displayName,
chatType: baseEntry?.chatType,
surface: baseEntry?.surface,
@@ -984,12 +543,6 @@ export async function getReplyFromConfig(
queueMode: inlineQueueMode,
queueReset: inlineQueueReset,
rawMode: rawQueueMode,
debounceMs: inlineQueueDebounceMs,
cap: inlineQueueCap,
dropPolicy: inlineQueueDrop,
rawDebounce: rawQueueDebounce,
rawCap: rawQueueCap,
rawDrop: rawQueueDrop,
hasDirective: hasQueueDirective,
} = extractQueueDirective(modelCleaned);
sessionCtx.Body = queueCleaned;
@@ -1053,7 +606,9 @@ export async function getReplyFromConfig(
agentCfg?.blockStreamingDefault === "off" ? "off" : "on";
// TODO(steipete): Default to message_end for now; figure out why text_end breaks and whether we can revert.
const resolvedBlockStreamingBreak =
agentCfg?.blockStreamingBreak === "text_end" ? "text_end" : "message_end";
agentCfg?.blockStreamingBreak === "text_end"
? "text_end"
: "message_end";
const blockStreamingEnabled = resolvedBlockStreaming === "on";
const streamedPayloadKeys = new Set<string>();
const pendingBlockTasks = new Set<Promise<void>>();
@@ -1204,50 +759,11 @@ export async function getReplyFromConfig(
text: `Unrecognized verbose level "${rawVerboseLevel ?? ""}". Valid levels: off, on.`,
};
}
const queueModeInvalid =
hasQueueDirective &&
!inlineQueueMode &&
!inlineQueueReset &&
Boolean(rawQueueMode);
const queueDebounceInvalid =
hasQueueDirective &&
rawQueueDebounce !== undefined &&
typeof inlineQueueDebounceMs !== "number";
const queueCapInvalid =
hasQueueDirective &&
rawQueueCap !== undefined &&
typeof inlineQueueCap !== "number";
const queueDropInvalid =
hasQueueDirective && rawQueueDrop !== undefined && !inlineQueueDrop;
if (
queueModeInvalid ||
queueDebounceInvalid ||
queueCapInvalid ||
queueDropInvalid
) {
const errors: string[] = [];
if (queueModeInvalid) {
errors.push(
`Unrecognized queue mode "${rawQueueMode ?? ""}". Valid modes: steer, followup, collect, steer+backlog, interrupt.`,
);
}
if (queueDebounceInvalid) {
errors.push(
`Invalid debounce "${rawQueueDebounce ?? ""}". Use ms/s/m (e.g. debounce:1500ms, debounce:2s).`,
);
}
if (queueCapInvalid) {
errors.push(
`Invalid cap "${rawQueueCap ?? ""}". Use a positive integer (e.g. cap:10).`,
);
}
if (queueDropInvalid) {
errors.push(
`Invalid drop policy "${rawQueueDrop ?? ""}". Use drop:old, drop:new, or drop:summarize.`,
);
}
if (hasQueueDirective && !inlineQueueMode && !inlineQueueReset) {
cleanupTyping();
return { text: errors.join(" ") };
return {
text: `Unrecognized queue mode "${rawQueueMode ?? ""}". Valid modes: queue, interrupt.`,
};
}
let modelSelection:
@@ -1312,20 +828,8 @@ export async function getReplyFromConfig(
}
if (hasQueueDirective && inlineQueueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (hasQueueDirective) {
if (inlineQueueMode) sessionEntry.queueMode = inlineQueueMode;
if (typeof inlineQueueDebounceMs === "number") {
sessionEntry.queueDebounceMs = inlineQueueDebounceMs;
}
if (typeof inlineQueueCap === "number") {
sessionEntry.queueCap = inlineQueueCap;
}
if (inlineQueueDrop) {
sessionEntry.queueDrop = inlineQueueDrop;
}
} else if (hasQueueDirective && inlineQueueMode) {
sessionEntry.queueMode = inlineQueueMode;
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
@@ -1363,17 +867,6 @@ export async function getReplyFromConfig(
} else if (hasQueueDirective && inlineQueueReset) {
parts.push(`${SYSTEM_MARK} Queue mode reset to default.`);
}
if (hasQueueDirective && typeof inlineQueueDebounceMs === "number") {
parts.push(
`${SYSTEM_MARK} Queue debounce set to ${inlineQueueDebounceMs}ms.`,
);
}
if (hasQueueDirective && typeof inlineQueueCap === "number") {
parts.push(`${SYSTEM_MARK} Queue cap set to ${inlineQueueCap}.`);
}
if (hasQueueDirective && inlineQueueDrop) {
parts.push(`${SYSTEM_MARK} Queue drop set to ${inlineQueueDrop}.`);
}
const ack = parts.join(" ").trim();
cleanupTyping();
return { text: ack || "OK." };
@@ -1436,9 +929,6 @@ export async function getReplyFromConfig(
}
if (hasQueueDirective && inlineQueueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
updated = true;
}
if (updated) {
@@ -1449,14 +939,6 @@ export async function getReplyFromConfig(
}
const perMessageQueueMode =
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
const perMessageQueueOptions =
hasQueueDirective && !inlineQueueReset
? {
debounceMs: inlineQueueDebounceMs,
cap: inlineQueueCap,
dropPolicy: inlineQueueDrop,
}
: undefined;
const surface = (ctx.Surface ?? "").trim().toLowerCase();
const isWhatsAppSurface =
@@ -1652,15 +1134,12 @@ export async function getReplyFromConfig(
activation === "always"
? "Be extremely selective: reply only when you are directly addressed, asked a question, or can add clear value. Otherwise stay silent."
: undefined;
const lurkLine =
"Be a good group participant: lurk and follow the conversation, but only chime in when you have something genuinely helpful or relevant to add. Don't feel obligated to respond to every message — quality over quantity. Even when lurking silently, you can use emoji reactions to acknowledge messages, show support, or react to humor — reactions are always appreciated and don't clutter the chat.";
return [
subjectLine,
membersLine,
activationLine,
silenceLine,
cautionLine,
lurkLine,
]
.filter(Boolean)
.join(" ")
@@ -1835,18 +1314,17 @@ export async function getReplyFromConfig(
.trim()
: queueBodyBase;
const resolvedQueue = resolveQueueSettings({
const resolvedQueueMode = resolveQueueMode({
cfg,
surface: sessionCtx.Surface,
sessionEntry,
inlineMode: perMessageQueueMode,
inlineOptions: perMessageQueueOptions,
});
const sessionLaneKey = resolveEmbeddedSessionLane(
sessionKey ?? sessionIdFinal,
);
const laneSize = getQueueSize(sessionLaneKey);
if (resolvedQueue.mode === "interrupt" && laneSize > 0) {
if (resolvedQueueMode === "interrupt" && laneSize > 0) {
const cleared = clearCommandLane(sessionLaneKey);
const aborted = abortEmbeddedPiRun(sessionIdFinal);
logVerbose(
@@ -1854,54 +1332,10 @@ export async function getReplyFromConfig(
);
}
const queueKey = sessionKey ?? sessionIdFinal;
const isActive = isEmbeddedPiRunActive(sessionIdFinal);
const isStreaming = isEmbeddedPiRunStreaming(sessionIdFinal);
const shouldSteer =
resolvedQueue.mode === "steer" || resolvedQueue.mode === "steer-backlog";
const shouldFollowup =
resolvedQueue.mode === "followup" ||
resolvedQueue.mode === "collect" ||
resolvedQueue.mode === "steer-backlog";
const followupRun: FollowupRun = {
prompt: queuedBody,
summaryLine: baseBodyTrimmedRaw,
enqueuedAt: Date.now(),
run: {
sessionId: sessionIdFinal,
sessionKey,
sessionFile,
workspaceDir,
config: cfg,
skillsSnapshot,
provider,
model,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
blockReplyBreak: resolvedBlockStreamingBreak,
ownerNumbers: ownerList.length > 0 ? ownerList : undefined,
extraSystemPrompt: groupIntro || undefined,
enforceFinalTag: provider === "ollama" ? true : undefined,
},
};
if (shouldSteer && isStreaming) {
const steered = queueEmbeddedPiMessage(sessionIdFinal, queuedBody);
if (steered && !shouldFollowup) {
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
cleanupTyping();
return undefined;
}
}
if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) {
enqueueFollowupRun(queueKey, followupRun, resolvedQueue);
if (
resolvedQueueMode === "queue" &&
queueEmbeddedPiMessage(sessionIdFinal, queuedBody)
) {
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
@@ -1911,142 +1345,6 @@ export async function getReplyFromConfig(
return undefined;
}
const sendFollowupPayloads = async (payloads: ReplyPayload[]) => {
if (!opts?.onBlockReply) {
logVerbose("followup queue: no onBlockReply handler; dropping payloads");
return;
}
for (const payload of payloads) {
if (!payload?.text && !payload?.mediaUrl && !payload?.mediaUrls?.length) {
continue;
}
if (
payload.text?.trim() === SILENT_REPLY_TOKEN &&
!payload.mediaUrl &&
!payload.mediaUrls?.length
) {
continue;
}
await startTypingOnText(payload.text);
await opts.onBlockReply(payload);
}
};
const runFollowupTurn = async (queued: FollowupRun) => {
const runId = crypto.randomUUID();
if (queued.run.sessionKey) {
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
try {
runResult = await runEmbeddedPiAgent({
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,
skillsSnapshot: queued.run.skillsSnapshot,
prompt: queued.prompt,
extraSystemPrompt: queued.run.extraSystemPrompt,
ownerNumbers: queued.run.ownerNumbers,
enforceFinalTag: queued.run.enforceFinalTag,
provider: queued.run.provider,
model: queued.run.model,
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
timeoutMs: queued.run.timeoutMs,
runId,
blockReplyBreak: queued.run.blockReplyBreak,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
return;
}
const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return;
const sanitizedPayloads = payloadArray.flatMap((payload) => {
const text = payload.text;
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
const stripped = stripHeartbeatToken(text, { mode: "message" });
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return [];
return [{ ...payload, text: stripped.text }];
});
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
.map((payload) => {
const { cleaned, replyToId } = extractReplyToTag(payload.text);
return {
...payload,
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? payload.replyToId,
};
})
.filter(
(payload) =>
payload.text ||
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0),
);
if (replyTaggedPayloads.length === 0) return;
if (sessionStore && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
const contextTokensUsed =
agentCfg?.contextTokens ??
lookupContextTokens(modelUsed) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
if (usage) {
const entry = sessionStore[sessionKey];
if (entry) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens =
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
sessionStore[sessionKey] = {
...entry,
inputTokens: input,
outputTokens: output,
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
};
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
} else if (modelUsed || contextTokensUsed) {
const entry = sessionStore[sessionKey];
if (entry) {
sessionStore[sessionKey] = {
...entry,
model: modelUsed ?? entry.model,
contextTokens: contextTokensUsed ?? entry.contextTokens,
};
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
}
}
await sendFollowupPayloads(replyTaggedPayloads);
};
const finalizeWithFollowup = <T>(value: T): T => {
scheduleFollowupDrain(queueKey, runFollowupTurn);
return value;
};
let didLogHeartbeatStrip = false;
try {
if (shouldEagerType) {
@@ -2173,11 +1471,11 @@ export async function getReplyFromConfig(
const isContextOverflow =
/context.*overflow|too large|context window/i.test(message);
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
return finalizeWithFollowup({
return {
text: isContextOverflow
? "⚠️ Context overflow - conversation too long. Starting fresh might help!"
: "⚠️ Agent failed. Check gateway logs.",
});
};
}
if (
@@ -2194,7 +1492,7 @@ export async function getReplyFromConfig(
}
const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return finalizeWithFollowup(undefined);
if (payloadArray.length === 0) return undefined;
if (pendingBlockTasks.size > 0) {
await Promise.allSettled(pendingBlockTasks);
}
@@ -2240,7 +1538,7 @@ export async function getReplyFromConfig(
)
: replyTaggedPayloads;
if (filteredPayloads.length === 0) return finalizeWithFollowup(undefined);
if (filteredPayloads.length === 0) return undefined;
const shouldSignalTyping = filteredPayloads.some((payload) => {
const trimmed = payload.text?.trim();
@@ -2305,9 +1603,7 @@ export async function getReplyFromConfig(
];
}
return finalizeWithFollowup(
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
);
return finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads;
} finally {
cleanupTyping();
}
+1 -1
View File
@@ -1 +1 @@
debc115fd2b264d3b3090e28b733551ca5f7532287d827c13fc579e5a7a95b9d
ac7d607313f8977b3fd7b5807201e0bf79d2c7366dbeff48747352d55cd3b253
-2
View File
@@ -24,7 +24,6 @@ import { registerGatewayCli } from "./gateway-cli.js";
import { registerHooksCli } from "./hooks-cli.js";
import { registerNodesCli } from "./nodes-cli.js";
import { forceFreePort } from "./ports.js";
import { registerTuiCli } from "./tui-cli.js";
export { forceFreePort };
@@ -395,7 +394,6 @@ Examples:
registerCanvasCli(program);
registerGatewayCli(program);
registerNodesCli(program);
registerTuiCli(program);
registerCronCli(program);
registerDnsCli(program);
registerHooksCli(program);
-48
View File
@@ -1,48 +0,0 @@
import type { Command } from "commander";
import { defaultRuntime } from "../runtime.js";
import { runTui } from "../tui/tui.js";
export function registerTuiCli(program: Command) {
program
.command("tui")
.description("Open a terminal UI connected to the Gateway")
.option(
"--url <url>",
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
)
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (if required)")
.option(
"--session <key>",
"Session key (default: session.mainKey from config)",
)
.option("--deliver", "Deliver assistant replies", false)
.option("--thinking <level>", "Thinking level override")
.option("--timeout-ms <ms>", "Agent timeout in ms", "30000")
.option("--history-limit <n>", "History entries to load", "200")
.action(async (opts) => {
try {
const timeoutMs = Number.parseInt(
String(opts.timeoutMs ?? "30000"),
10,
);
const historyLimit = Number.parseInt(
String(opts.historyLimit ?? "200"),
10,
);
await runTui({
url: opts.url as string | undefined,
token: opts.token as string | undefined,
password: opts.password as string | undefined,
session: opts.session as string | undefined,
deliver: Boolean(opts.deliver),
thinking: opts.thinking as string | undefined,
timeoutMs: Number.isNaN(timeoutMs) ? undefined : timeoutMs,
historyLimit: Number.isNaN(historyLimit) ? undefined : historyLimit,
});
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}
-398
View File
@@ -1,398 +0,0 @@
/**
* VPS-aware Antigravity OAuth flow.
*
* On local machines: Uses the standard pi-ai loginAntigravity with local server callback.
* On VPS/SSH/headless: Shows URL and prompts user to paste the callback URL manually.
*/
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { stdin, stdout } from "node:process";
import { createInterface } from "node:readline/promises";
import { loginAntigravity, type OAuthCredentials } from "@mariozechner/pi-ai";
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
const decode = (s: string) => Buffer.from(s, "base64").toString();
const CLIENT_ID = decode(
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
);
const CLIENT_SECRET = decode(
"R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=",
);
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
// Antigravity requires these additional scopes
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
];
// Fallback project ID when discovery fails (same as pi-ai)
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
/**
* Detect if running in WSL (Windows Subsystem for Linux).
*/
function isWSL(): boolean {
if (process.platform !== "linux") return false;
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
/**
* Detect if running in WSL2 specifically.
*/
function isWSL2(): boolean {
if (!isWSL()) return false;
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
/**
* Detect if running in a remote/headless environment where localhost callback won't work.
*/
export function isRemoteEnvironment(): boolean {
// SSH session indicators
if (
process.env.SSH_CLIENT ||
process.env.SSH_TTY ||
process.env.SSH_CONNECTION
) {
return true;
}
// Container/cloud environments
if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) {
return true;
}
// Linux without display (and not WSL which can use wslview)
if (
process.platform === "linux" &&
!process.env.DISPLAY &&
!process.env.WAYLAND_DISPLAY &&
!isWSL()
) {
return true;
}
return false;
}
/**
* Whether to skip the local OAuth callback server.
*/
export function shouldUseManualOAuthFlow(): boolean {
return isWSL2() || isRemoteEnvironment();
}
/**
* Generate PKCE verifier and challenge using Node.js crypto.
*/
function generatePKCESync(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Build the Antigravity OAuth authorization URL.
*/
function buildAuthUrl(challenge: string, verifier: string): string {
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
access_type: "offline",
prompt: "consent",
});
return `${AUTH_URL}?${params.toString()}`;
}
/**
* Parse the OAuth callback URL or code input.
*/
function parseCallbackInput(
input: string,
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) {
return { error: "No input provided" };
}
try {
// Try parsing as full URL
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState;
if (!code) {
return { error: "Missing 'code' parameter in URL" };
}
if (!state) {
return { error: "Missing 'state' parameter. Paste the full URL." };
}
return { code, state };
} catch {
// Not a URL - treat as raw code (need state from original request)
if (!expectedState) {
return { error: "Paste the full redirect URL, not just the code." };
}
return { code: trimmed, state: expectedState };
}
}
/**
* Exchange authorization code for tokens.
*/
async function exchangeCodeForTokens(
code: string,
verifier: string,
): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error("No refresh token received. Please try again.");
}
// Fetch user email
const email = await getUserEmail(data.access_token);
// Fetch project ID
const projectId = await fetchProjectId(data.access_token);
// Calculate expiry time (same as pi-ai: current time + expires_in - 5 min buffer)
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
/**
* Get user email from access token.
*/
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// Ignore errors, email is optional
}
return undefined;
}
/**
* Fetch the Antigravity project ID using the access token.
*/
async function fetchProjectId(accessToken: string): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
// Try endpoints in order: prod first, then sandbox
const endpoints = [
"https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
];
for (const endpoint of endpoints) {
try {
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
});
if (!response.ok) continue;
const data = (await response.json()) as {
cloudaicompanionProject?: string | { id?: string };
};
if (typeof data.cloudaicompanionProject === "string") {
return data.cloudaicompanionProject;
}
if (
data.cloudaicompanionProject &&
typeof data.cloudaicompanionProject === "object" &&
data.cloudaicompanionProject.id
) {
return data.cloudaicompanionProject.id;
}
} catch {
// ignore failed endpoint, try next
}
}
// Use fallback project ID
return DEFAULT_PROJECT_ID;
}
/**
* Prompt user for input via readline.
*/
async function promptInput(message: string): Promise<string> {
const rl = createInterface({ input: stdin, output: stdout });
try {
return (await rl.question(message)).trim();
} finally {
rl.close();
}
}
/**
* VPS-aware Antigravity OAuth login.
*
* On local machines: Uses the standard pi-ai flow with automatic localhost callback.
* On VPS/SSH: Shows URL and prompts user to paste the callback URL manually.
*/
export async function loginAntigravityVpsAware(
onUrl: (url: string) => void | Promise<void>,
onProgress?: (message: string) => void,
): Promise<OAuthCredentials | null> {
// Check if we're in a remote environment
if (shouldUseManualOAuthFlow()) {
return loginAntigravityManual(onUrl, onProgress);
}
// Use the standard pi-ai flow for local environments
try {
return await loginAntigravity(
async ({ url, instructions }) => {
await onUrl(url);
onProgress?.(instructions ?? "Complete sign-in in browser...");
},
(msg) => onProgress?.(msg),
);
} catch (err) {
// If the local server fails (e.g., port in use), fall back to manual
if (
err instanceof Error &&
(err.message.includes("EADDRINUSE") ||
err.message.includes("port") ||
err.message.includes("listen"))
) {
onProgress?.("Local callback server failed. Switching to manual mode...");
return loginAntigravityManual(onUrl, onProgress);
}
throw err;
}
}
/**
* Manual Antigravity OAuth login for VPS/headless environments.
*
* Shows the OAuth URL and prompts user to paste the callback URL.
*/
export async function loginAntigravityManual(
onUrl: (url: string) => void | Promise<void>,
onProgress?: (message: string) => void,
): Promise<OAuthCredentials | null> {
const { verifier, challenge } = generatePKCESync();
const authUrl = buildAuthUrl(challenge, verifier);
// Show the URL to the user
await onUrl(authUrl);
onProgress?.("Waiting for you to paste the callback URL...");
console.log("\n");
console.log("=".repeat(60));
console.log("VPS/Remote Mode - Manual OAuth");
console.log("=".repeat(60));
console.log("\n1. Open the URL above in your LOCAL browser");
console.log("2. Complete the Google sign-in");
console.log(
"3. Your browser will redirect to a localhost URL that won't load",
);
console.log("4. Copy the ENTIRE URL from your browser's address bar");
console.log("5. Paste it below\n");
console.log("The URL will look like:");
console.log("http://localhost:51121/oauth-callback?code=xxx&state=yyy\n");
const callbackInput = await promptInput("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) {
throw new Error(parsed.error);
}
// Verify state matches
if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again");
}
onProgress?.("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier);
}
+2 -62
View File
@@ -11,6 +11,7 @@ import {
text,
} from "@clack/prompts";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
import type { ClawdisConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDIS,
@@ -23,10 +24,6 @@ import { resolveGatewayService } from "../daemon/service.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import { healthCommand } from "./health.js";
import {
applyMinimaxConfig,
@@ -226,17 +223,13 @@ async function promptAuthConfig(
message: "Model/auth choice",
options: [
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
{
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
},
{ value: "apiKey", label: "Anthropic API key" },
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
{ value: "skip", label: "Skip for now" },
],
}),
runtime,
) as "oauth" | "antigravity" | "apiKey" | "minimax" | "skip";
) as "oauth" | "apiKey" | "minimax" | "skip";
let next = cfg;
@@ -273,59 +266,6 @@ async function promptAuthConfig(
spin.stop("OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "antigravity") {
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, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account that has Antigravity access.",
"The callback will be captured automatically on localhost:51121.",
].join("\n"),
"Google Antigravity OAuth",
);
const spin = spinner();
spin.start("Starting OAuth flow…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAntigravityVpsAware(
async (url) => {
if (isRemote) {
spin.stop("OAuth URL ready");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else {
spin.message("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
(msg) => spin.message(msg),
);
spin.stop("Antigravity OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("google-antigravity", oauthCreds);
// Set default model to Claude Opus 4.5 via Antigravity
next = {
...next,
agent: {
...next.agent,
model: "google-antigravity/claude-opus-4-5-thinking",
},
};
note(
"Default model set to google-antigravity/claude-opus-4-5-thinking",
"Model configured",
);
}
} catch (err) {
spin.stop("Antigravity OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "apiKey") {
const key = guardCancel(
await text({
+2 -2
View File
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdisAgentDir } from "../agents/agent-paths.js";
@@ -9,7 +9,7 @@ import type { ClawdisConfig } from "../config/config.js";
import { CONFIG_DIR } from "../utils.js";
export async function writeOAuthCredentials(
provider: OAuthProvider,
provider: "anthropic",
creds: OAuthCredentials,
): Promise<void> {
const dir = path.join(CONFIG_DIR, "credentials");
+1 -61
View File
@@ -10,6 +10,7 @@ import {
text,
} from "@clack/prompts";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
import type { ClawdisConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDIS,
@@ -22,10 +23,6 @@ import { resolveGatewayService } from "../daemon/service.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import { healthCommand } from "./health.js";
import {
applyMinimaxConfig,
@@ -201,10 +198,6 @@ export async function runInteractiveOnboarding(
message: "Model/auth choice",
options: [
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
{
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
},
{ value: "apiKey", label: "Anthropic API key" },
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
{ value: "skip", label: "Skip for now" },
@@ -246,59 +239,6 @@ export async function runInteractiveOnboarding(
spin.stop("OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "antigravity") {
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, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account that has Antigravity access.",
"The callback will be captured automatically on localhost:51121.",
].join("\n"),
"Google Antigravity OAuth",
);
const spin = spinner();
spin.start("Starting OAuth flow…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAntigravityVpsAware(
async (url) => {
if (isRemote) {
spin.stop("OAuth URL ready");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else {
spin.message("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
(msg) => spin.message(msg),
);
spin.stop("Antigravity OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("google-antigravity", oauthCreds);
// Set default model to Claude Opus 4.5 via Antigravity
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
model: "google-antigravity/claude-opus-4-5-thinking",
},
};
note(
"Default model set to google-antigravity/claude-opus-4-5-thinking",
"Model configured",
);
}
} catch (err) {
spin.stop("Antigravity OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "apiKey") {
const key = guardCancel(
await text({
+2 -4
View File
@@ -98,10 +98,8 @@ export async function runNonInteractiveOnboarding(
await setAnthropicApiKey(key);
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
} else if (authChoice === "oauth" || authChoice === "antigravity") {
runtime.error(
`${authChoice === "oauth" ? "OAuth" : "Antigravity"} requires interactive mode.`,
);
} else if (authChoice === "oauth") {
runtime.error("OAuth requires interactive mode.");
runtime.exit(1);
return;
}
+1 -6
View File
@@ -1,10 +1,5 @@
export type OnboardMode = "local" | "remote";
export type AuthChoice =
| "oauth"
| "antigravity"
| "apiKey"
| "minimax"
| "skip";
export type AuthChoice = "oauth" | "apiKey" | "minimax" | "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
+5 -68
View File
@@ -221,24 +221,6 @@ export type DiscordSlashCommandConfig = {
ephemeral?: boolean;
};
export type DiscordActionConfig = {
reactions?: boolean;
stickers?: boolean;
polls?: boolean;
permissions?: boolean;
messages?: boolean;
threads?: boolean;
pins?: boolean;
search?: boolean;
memberInfo?: boolean;
roleInfo?: boolean;
roles?: boolean;
channelInfo?: boolean;
voiceStatus?: boolean;
events?: boolean;
moderation?: boolean;
};
export type DiscordConfig = {
/** If false, do not start the Discord provider. Default: true. */
enabled?: boolean;
@@ -247,8 +229,8 @@ export type DiscordConfig = {
textChunkLimit?: number;
mediaMaxMb?: number;
historyLimit?: number;
/** Per-action tool gating (default: true for all). */
actions?: DiscordActionConfig;
/** Allow agent-triggered Discord reactions (default: true). */
enableReactions?: boolean;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
slashCommand?: DiscordSlashCommandConfig;
@@ -309,15 +291,7 @@ export type IMessageConfig = {
>;
};
export type QueueMode =
| "steer"
| "followup"
| "collect"
| "steer-backlog"
| "steer+backlog"
| "queue"
| "interrupt";
export type QueueDropPolicy = "old" | "new" | "summarize";
export type QueueMode = "queue" | "interrupt";
export type QueueModeBySurface = {
whatsapp?: QueueMode;
@@ -343,9 +317,6 @@ export type RoutingConfig = {
queue?: {
mode?: QueueMode;
bySurface?: QueueModeBySurface;
debounceMs?: number;
cap?: number;
drop?: QueueDropPolicy;
};
};
@@ -701,20 +672,7 @@ const GroupChatSchema = z
})
.optional();
const QueueModeSchema = z.union([
z.literal("steer"),
z.literal("followup"),
z.literal("collect"),
z.literal("steer-backlog"),
z.literal("steer+backlog"),
z.literal("queue"),
z.literal("interrupt"),
]);
const QueueDropSchema = z.union([
z.literal("old"),
z.literal("new"),
z.literal("summarize"),
]);
const QueueModeSchema = z.union([z.literal("queue"), z.literal("interrupt")]);
const ReplyToModeSchema = z.union([
z.literal("off"),
z.literal("first"),
@@ -803,9 +761,6 @@ const RoutingSchema = z
.object({
mode: QueueModeSchema.optional(),
bySurface: QueueModeBySurfaceSchema,
debounceMs: z.number().int().nonnegative().optional(),
cap: z.number().int().positive().optional(),
drop: QueueDropSchema.optional(),
})
.optional(),
})
@@ -1077,25 +1032,7 @@ const ClawdisSchema = z.object({
.optional(),
mediaMaxMb: z.number().positive().optional(),
historyLimit: z.number().int().min(0).optional(),
actions: z
.object({
reactions: z.boolean().optional(),
stickers: z.boolean().optional(),
polls: z.boolean().optional(),
permissions: z.boolean().optional(),
messages: z.boolean().optional(),
threads: z.boolean().optional(),
pins: z.boolean().optional(),
search: z.boolean().optional(),
memberInfo: z.boolean().optional(),
roleInfo: z.boolean().optional(),
roles: z.boolean().optional(),
channelInfo: z.boolean().optional(),
voiceStatus: z.boolean().optional(),
events: z.boolean().optional(),
moderation: z.boolean().optional(),
})
.optional(),
enableReactions: z.boolean().optional(),
replyToMode: ReplyToModeSchema.optional(),
dm: z
.object({
+1 -11
View File
@@ -34,17 +34,7 @@ export type SessionEntry = {
modelOverride?: string;
groupActivation?: "mention" | "always";
groupActivationNeedsSystemIntro?: boolean;
queueMode?:
| "steer"
| "followup"
| "collect"
| "steer-backlog"
| "steer+backlog"
| "queue"
| "interrupt";
queueDebounceMs?: number;
queueCap?: number;
queueDrop?: "old" | "new" | "summarize";
queueMode?: "queue" | "interrupt";
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
+2 -400
View File
@@ -1,27 +1,7 @@
import { PermissionsBitField, Routes } from "discord.js";
import { Routes } from "discord.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addRoleDiscord,
banMemberDiscord,
createThreadDiscord,
deleteMessageDiscord,
editMessageDiscord,
fetchChannelPermissionsDiscord,
fetchReactionsDiscord,
listGuildEmojisDiscord,
listThreadsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeRoleDiscord,
searchMessagesDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
timeoutMemberDiscord,
unpinMessageDiscord,
} from "./send.js";
import { sendMessageDiscord } from "./send.js";
vi.mock("../web/media.js", () => ({
loadWebMedia: vi.fn().mockResolvedValue({
@@ -34,23 +14,11 @@ vi.mock("../web/media.js", () => ({
const makeRest = () => {
const postMock = vi.fn();
const putMock = vi.fn();
const getMock = vi.fn();
const patchMock = vi.fn();
const deleteMock = vi.fn();
return {
rest: {
post: postMock,
put: putMock,
get: getMock,
patch: patchMock,
delete: deleteMock,
} as unknown as import("discord.js").REST,
postMock,
putMock,
getMock,
patchMock,
deleteMock,
};
};
@@ -148,369 +116,3 @@ describe("sendMessageDiscord", () => {
expect(secondBody?.message_reference).toBeUndefined();
});
});
describe("reactMessageDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("reacts with unicode emoji", async () => {
const { rest, putMock } = makeRest();
await reactMessageDiscord("chan1", "msg1", "✅", { rest, token: "t" });
expect(putMock).toHaveBeenCalledWith(
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"),
);
});
it("normalizes variation selectors in unicode emoji", async () => {
const { rest, putMock } = makeRest();
await reactMessageDiscord("chan1", "msg1", "⭐️", { rest, token: "t" });
expect(putMock).toHaveBeenCalledWith(
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%AD%90"),
);
});
it("reacts with custom emoji syntax", async () => {
const { rest, putMock } = makeRest();
await reactMessageDiscord("chan1", "msg1", "<:party_blob:123>", {
rest,
token: "t",
});
expect(putMock).toHaveBeenCalledWith(
Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"),
);
});
});
describe("fetchReactionsDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns reactions with users", async () => {
const { rest, getMock } = makeRest();
getMock
.mockResolvedValueOnce({
reactions: [
{ count: 2, emoji: { name: "✅", id: null } },
{ count: 1, emoji: { name: "party_blob", id: "123" } },
],
})
.mockResolvedValueOnce([
{ id: "u1", username: "alpha", discriminator: "0001" },
])
.mockResolvedValueOnce([{ id: "u2", username: "beta" }]);
const res = await fetchReactionsDiscord("chan1", "msg1", {
rest,
token: "t",
});
expect(res).toEqual([
{
emoji: { id: null, name: "✅", raw: "✅" },
count: 2,
users: [{ id: "u1", username: "alpha", tag: "alpha#0001" }],
},
{
emoji: { id: "123", name: "party_blob", raw: "party_blob:123" },
count: 1,
users: [{ id: "u2", username: "beta", tag: "beta" }],
},
]);
});
});
describe("fetchChannelPermissionsDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calculates permissions from guild roles", async () => {
const { rest, getMock } = makeRest();
const perms = new PermissionsBitField([
PermissionsBitField.Flags.ViewChannel,
PermissionsBitField.Flags.SendMessages,
]);
getMock
.mockResolvedValueOnce({
id: "chan1",
guild_id: "guild1",
permission_overwrites: [],
})
.mockResolvedValueOnce({ id: "bot1" })
.mockResolvedValueOnce({
id: "guild1",
roles: [
{ id: "guild1", permissions: perms.bitfield.toString() },
{ id: "role2", permissions: "0" },
],
})
.mockResolvedValueOnce({ roles: ["role2"] });
const res = await fetchChannelPermissionsDiscord("chan1", {
rest,
token: "t",
});
expect(res.guildId).toBe("guild1");
expect(res.permissions).toContain("ViewChannel");
expect(res.permissions).toContain("SendMessages");
expect(res.isDm).toBe(false);
});
});
describe("readMessagesDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("passes query params as URLSearchParams", async () => {
const { rest, getMock } = makeRest();
getMock.mockResolvedValue([]);
await readMessagesDiscord(
"chan1",
{ limit: 5, before: "10" },
{ rest, token: "t" },
);
const call = getMock.mock.calls[0];
const options = call?.[1] as { query?: URLSearchParams };
expect(options.query?.toString()).toBe("limit=5&before=10");
});
});
describe("edit/delete message helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("edits message content", async () => {
const { rest, patchMock } = makeRest();
patchMock.mockResolvedValue({ id: "m1" });
await editMessageDiscord(
"chan1",
"m1",
{ content: "hello" },
{ rest, token: "t" },
);
expect(patchMock).toHaveBeenCalledWith(
Routes.channelMessage("chan1", "m1"),
expect.objectContaining({ body: { content: "hello" } }),
);
});
it("deletes message", async () => {
const { rest, deleteMock } = makeRest();
deleteMock.mockResolvedValue({});
await deleteMessageDiscord("chan1", "m1", { rest, token: "t" });
expect(deleteMock).toHaveBeenCalledWith(
Routes.channelMessage("chan1", "m1"),
);
});
});
describe("pin helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("pins and unpins messages", async () => {
const { rest, putMock, deleteMock } = makeRest();
putMock.mockResolvedValue({});
deleteMock.mockResolvedValue({});
await pinMessageDiscord("chan1", "m1", { rest, token: "t" });
await unpinMessageDiscord("chan1", "m1", { rest, token: "t" });
expect(putMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
expect(deleteMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
});
});
describe("searchMessagesDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses URLSearchParams for search", async () => {
const { rest, getMock } = makeRest();
getMock.mockResolvedValue({ total_results: 0, messages: [] });
await searchMessagesDiscord(
{ guildId: "g1", content: "hello", limit: 5 },
{ rest, token: "t" },
);
const call = getMock.mock.calls[0];
const options = call?.[1] as { query?: URLSearchParams };
expect(options.query?.toString()).toBe("content=hello&limit=5");
});
it("supports channel/author arrays and clamps limit", async () => {
const { rest, getMock } = makeRest();
getMock.mockResolvedValue({ total_results: 0, messages: [] });
await searchMessagesDiscord(
{
guildId: "g1",
content: "hello",
channelIds: ["c1", "c2"],
authorIds: ["u1"],
limit: 99,
},
{ rest, token: "t" },
);
const call = getMock.mock.calls[0];
const options = call?.[1] as { query?: URLSearchParams };
expect(options.query?.toString()).toBe(
"content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25",
);
});
});
describe("threads and moderation helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a thread", async () => {
const { rest, postMock } = makeRest();
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord(
"chan1",
{ name: "thread", messageId: "m1" },
{ rest, token: "t" },
);
expect(postMock).toHaveBeenCalledWith(
Routes.threads("chan1", "m1"),
expect.objectContaining({ body: { name: "thread" } }),
);
});
it("lists active threads by guild", async () => {
const { rest, getMock } = makeRest();
getMock.mockResolvedValue({ threads: [] });
await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" });
expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1"));
});
it("times out a member", async () => {
const { rest, patchMock } = makeRest();
patchMock.mockResolvedValue({ id: "m1" });
await timeoutMemberDiscord(
{ guildId: "g1", userId: "u1", durationMinutes: 10 },
{ rest, token: "t" },
);
expect(patchMock).toHaveBeenCalledWith(
Routes.guildMember("g1", "u1"),
expect.objectContaining({
body: expect.objectContaining({
communication_disabled_until: expect.any(String),
}),
}),
);
});
it("adds and removes roles", async () => {
const { rest, putMock, deleteMock } = makeRest();
putMock.mockResolvedValue({});
deleteMock.mockResolvedValue({});
await addRoleDiscord(
{ guildId: "g1", userId: "u1", roleId: "r1" },
{ rest, token: "t" },
);
await removeRoleDiscord(
{ guildId: "g1", userId: "u1", roleId: "r1" },
{ rest, token: "t" },
);
expect(putMock).toHaveBeenCalledWith(
Routes.guildMemberRole("g1", "u1", "r1"),
);
expect(deleteMock).toHaveBeenCalledWith(
Routes.guildMemberRole("g1", "u1", "r1"),
);
});
it("bans a member", async () => {
const { rest, putMock } = makeRest();
putMock.mockResolvedValue({});
await banMemberDiscord(
{ guildId: "g1", userId: "u1", deleteMessageDays: 2 },
{ rest, token: "t" },
);
expect(putMock).toHaveBeenCalledWith(
Routes.guildBan("g1", "u1"),
expect.objectContaining({ body: { delete_message_days: 2 } }),
);
});
});
describe("listGuildEmojisDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("lists emojis for a guild", async () => {
const { rest, getMock } = makeRest();
getMock.mockResolvedValue([{ id: "e1", name: "party" }]);
await listGuildEmojisDiscord("g1", { rest, token: "t" });
expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1"));
});
});
describe("sendStickerDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends sticker payloads", async () => {
const { rest, postMock } = makeRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
const res = await sendStickerDiscord("channel:789", ["123"], {
rest,
token: "t",
content: "hiya",
});
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
body: {
content: "hiya",
sticker_ids: ["123"],
},
}),
);
});
});
describe("sendPollDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends polls with answers", async () => {
const { rest, postMock } = makeRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
const res = await sendPollDiscord(
"channel:789",
{
question: "Lunch?",
answers: ["Pizza", "Sushi"],
},
{
rest,
token: "t",
},
);
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
body: expect.objectContaining({
poll: {
question: { text: "Lunch?" },
answers: [
{ poll_media: { text: "Pizza" } },
{ poll_media: { text: "Sushi" } },
],
duration: 24,
allow_multiselect: false,
layout_type: 1,
},
}),
}),
);
});
});
+3 -632
View File
@@ -1,16 +1,4 @@
import { PermissionsBitField, REST, Routes } from "discord.js";
import { PollLayoutType } from "discord-api-types/payloads/v10";
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
import type {
APIChannel,
APIGuild,
APIGuildMember,
APIGuildScheduledEvent,
APIMessage,
APIRole,
APIVoiceState,
RESTPostAPIGuildScheduledEventJSONBody,
} from "discord-api-types/v10";
import { REST, Routes } from "discord.js";
import { chunkText } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
@@ -18,10 +6,6 @@ import { loadWebMedia } from "../web/media.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_TEXT_LIMIT = 2000;
const DISCORD_MAX_STICKERS = 3;
const DISCORD_POLL_MIN_ANSWERS = 2;
const DISCORD_POLL_MAX_ANSWERS = 10;
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
type DiscordRecipient =
| {
@@ -46,88 +30,11 @@ export type DiscordSendResult = {
channelId: string;
};
export type DiscordPollInput = {
question: string;
answers: string[];
allowMultiselect?: boolean;
durationHours?: number;
};
export type DiscordReactOpts = {
token?: string;
rest?: REST;
};
export type DiscordReactionUser = {
id: string;
username?: string;
tag?: string;
};
export type DiscordReactionSummary = {
emoji: { id?: string | null; name?: string | null; raw: string };
count: number;
users: DiscordReactionUser[];
};
export type DiscordPermissionsSummary = {
channelId: string;
guildId?: string;
permissions: string[];
raw: string;
isDm: boolean;
};
export type DiscordMessageQuery = {
limit?: number;
before?: string;
after?: string;
around?: string;
};
export type DiscordMessageEdit = {
content: string;
};
export type DiscordThreadCreate = {
name: string;
messageId?: string;
autoArchiveMinutes?: number;
};
export type DiscordThreadList = {
guildId: string;
channelId?: string;
includeArchived?: boolean;
before?: string;
limit?: number;
};
export type DiscordSearchQuery = {
guildId: string;
content: string;
channelIds?: string[];
authorIds?: string[];
limit?: number;
};
export type DiscordRoleChange = {
guildId: string;
userId: string;
roleId: string;
};
export type DiscordModerationTarget = {
guildId: string;
userId: string;
reason?: string;
};
export type DiscordTimeoutTarget = DiscordModerationTarget & {
durationMinutes?: number;
until?: string;
};
function resolveToken(explicit?: string) {
const cfgToken = loadConfig().discord?.token;
const token = normalizeDiscordToken(
@@ -149,7 +56,7 @@ function normalizeReactionEmoji(raw: string) {
const customMatch = trimmed.match(/^<a?:([^:>]+):(\d+)>$/);
const identifier = customMatch
? `${customMatch[1]}:${customMatch[2]}`
: trimmed.replace(/[\uFE0E\uFE0F]/g, "");
: trimmed;
return encodeURIComponent(identifier);
}
@@ -183,49 +90,6 @@ function parseRecipient(raw: string): DiscordRecipient {
return { kind: "channel", id: trimmed };
}
function normalizeStickerIds(raw: string[]) {
const ids = raw.map((entry) => entry.trim()).filter(Boolean);
if (ids.length === 0) {
throw new Error("At least one sticker id is required");
}
if (ids.length > DISCORD_MAX_STICKERS) {
throw new Error("Discord supports up to 3 stickers per message");
}
return ids;
}
function normalizePollInput(input: DiscordPollInput): RESTAPIPoll {
const question = input.question.trim();
if (!question) {
throw new Error("Poll question is required");
}
const answers = (input.answers ?? [])
.map((answer) => answer.trim())
.filter(Boolean);
if (answers.length < DISCORD_POLL_MIN_ANSWERS) {
throw new Error("Polls require at least 2 answers");
}
if (answers.length > DISCORD_POLL_MAX_ANSWERS) {
throw new Error("Polls support up to 10 answers");
}
const durationRaw =
typeof input.durationHours === "number" &&
Number.isFinite(input.durationHours)
? Math.floor(input.durationHours)
: 24;
const duration = Math.min(
Math.max(durationRaw, 1),
DISCORD_POLL_MAX_DURATION_HOURS,
);
return {
question: { text: question },
answers: answers.map((answer) => ({ poll_media: { text: answer } })),
duration,
allow_multiselect: input.allowMultiselect ?? false,
layout_type: PollLayoutType.Default,
};
}
async function resolveChannelId(
rest: REST,
recipient: DiscordRecipient,
@@ -312,31 +176,6 @@ async function sendDiscordMedia(
return res;
}
function buildReactionIdentifier(emoji: {
id?: string | null;
name?: string | null;
}) {
if (emoji.id && emoji.name) {
return `${emoji.name}:${emoji.id}`;
}
return emoji.name ?? "";
}
function formatReactionEmoji(emoji: {
id?: string | null;
name?: string | null;
}) {
return buildReactionIdentifier(emoji);
}
async function fetchBotUserId(rest: REST) {
const me = (await rest.get(Routes.user("@me"))) as { id?: string };
if (!me?.id) {
throw new Error("Failed to resolve bot user id");
}
return me.id;
}
export async function sendMessageDiscord(
to: string,
text: string,
@@ -368,52 +207,6 @@ export async function sendMessageDiscord(
};
}
export async function sendStickerDiscord(
to: string,
stickerIds: string[],
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const content = opts.content?.trim();
const stickers = normalizeStickerIds(stickerIds);
const res = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: content || undefined,
sticker_ids: stickers,
},
})) as { id: string; channel_id: string };
return {
messageId: res.id ? String(res.id) : "unknown",
channelId: String(res.channel_id ?? channelId),
};
}
export async function sendPollDiscord(
to: string,
poll: DiscordPollInput,
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const content = opts.content?.trim();
const payload = normalizePollInput(poll);
const res = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: content || undefined,
poll: payload,
},
})) as { id: string; channel_id: string };
return {
messageId: res.id ? String(res.id) : "unknown",
channelId: String(res.channel_id ?? channelId),
};
}
export async function reactMessageDiscord(
channelId: string,
messageId: string,
@@ -423,428 +216,6 @@ export async function reactMessageDiscord(
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const encoded = normalizeReactionEmoji(emoji);
await rest.put(
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
);
return { ok: true };
}
export async function fetchReactionsDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts & { limit?: number } = {},
): Promise<DiscordReactionSummary[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const message = (await rest.get(
Routes.channelMessage(channelId, messageId),
)) as {
reactions?: Array<{
count: number;
emoji: { id?: string | null; name?: string | null };
}>;
};
const reactions = message.reactions ?? [];
if (reactions.length === 0) return [];
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.min(Math.max(Math.floor(opts.limit), 1), 100)
: 100;
const summaries: DiscordReactionSummary[] = [];
for (const reaction of reactions) {
const identifier = buildReactionIdentifier(reaction.emoji);
if (!identifier) continue;
const encoded = encodeURIComponent(identifier);
const users = (await rest.get(
Routes.channelMessageReaction(channelId, messageId, encoded),
{ query: new URLSearchParams({ limit: String(limit) }) },
)) as Array<{ id: string; username?: string; discriminator?: string }>;
summaries.push({
emoji: {
id: reaction.emoji.id ?? null,
name: reaction.emoji.name ?? null,
raw: formatReactionEmoji(reaction.emoji),
},
count: reaction.count,
users: users.map((user) => ({
id: user.id,
username: user.username,
tag:
user.username && user.discriminator
? `${user.username}#${user.discriminator}`
: user.username,
})),
});
}
return summaries;
}
export async function fetchChannelPermissionsDiscord(
channelId: string,
opts: DiscordReactOpts = {},
): Promise<DiscordPermissionsSummary> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
if (!guildId) {
return {
channelId,
permissions: [],
raw: "0",
isDm: true,
};
}
const botId = await fetchBotUserId(rest);
const [guild, member] = await Promise.all([
rest.get(Routes.guild(guildId)) as Promise<APIGuild>,
rest.get(Routes.guildMember(guildId, botId)) as Promise<APIGuildMember>,
]);
const rolesById = new Map<string, APIRole>(
(guild.roles ?? []).map((role) => [role.id, role]),
);
const base = new PermissionsBitField();
const everyoneRole = rolesById.get(guildId);
if (everyoneRole?.permissions) {
base.add(BigInt(everyoneRole.permissions));
}
for (const roleId of member.roles ?? []) {
const role = rolesById.get(roleId);
if (role?.permissions) {
base.add(BigInt(role.permissions));
}
}
const permissions = new PermissionsBitField(base);
const overwrites =
"permission_overwrites" in channel
? (channel.permission_overwrites ?? [])
: [];
for (const overwrite of overwrites) {
if (overwrite.id === guildId) {
permissions.remove(BigInt(overwrite.deny ?? "0"));
permissions.add(BigInt(overwrite.allow ?? "0"));
}
}
for (const overwrite of overwrites) {
if (member.roles?.includes(overwrite.id)) {
permissions.remove(BigInt(overwrite.deny ?? "0"));
permissions.add(BigInt(overwrite.allow ?? "0"));
}
}
for (const overwrite of overwrites) {
if (overwrite.id === botId) {
permissions.remove(BigInt(overwrite.deny ?? "0"));
permissions.add(BigInt(overwrite.allow ?? "0"));
}
}
return {
channelId,
guildId,
permissions: permissions.toArray(),
raw: permissions.bitfield.toString(),
isDm: false,
};
}
export async function readMessagesDiscord(
channelId: string,
query: DiscordMessageQuery = {},
opts: DiscordReactOpts = {},
): Promise<APIMessage[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const limit =
typeof query.limit === "number" && Number.isFinite(query.limit)
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
: undefined;
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (query.before) params.set("before", query.before);
if (query.after) params.set("after", query.after);
if (query.around) params.set("around", query.around);
return (await rest.get(Routes.channelMessages(channelId), {
query: params,
})) as APIMessage[];
}
export async function editMessageDiscord(
channelId: string,
messageId: string,
payload: DiscordMessageEdit,
opts: DiscordReactOpts = {},
): Promise<APIMessage> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
body: { content: payload.content },
})) as APIMessage;
}
export async function deleteMessageDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
await rest.delete(Routes.channelMessage(channelId, messageId));
return { ok: true };
}
export async function pinMessageDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
await rest.put(Routes.channelPin(channelId, messageId));
return { ok: true };
}
export async function unpinMessageDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
await rest.delete(Routes.channelPin(channelId, messageId));
return { ok: true };
}
export async function listPinsDiscord(
channelId: string,
opts: DiscordReactOpts = {},
): Promise<APIMessage[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
}
export async function createThreadDiscord(
channelId: string,
payload: DiscordThreadCreate,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const body: Record<string, unknown> = { name: payload.name };
if (payload.autoArchiveMinutes) {
body.auto_archive_duration = payload.autoArchiveMinutes;
}
const route = Routes.threads(channelId, payload.messageId);
return await rest.post(route, { body });
}
export async function listThreadsDiscord(
payload: DiscordThreadList,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
if (payload.includeArchived) {
if (!payload.channelId) {
throw new Error("channelId required to list archived threads");
}
const params = new URLSearchParams();
if (payload.before) params.set("before", payload.before);
if (payload.limit) params.set("limit", String(payload.limit));
return await rest.get(Routes.channelThreads(payload.channelId, "public"), {
query: params,
});
}
return await rest.get(Routes.guildActiveThreads(payload.guildId));
}
export async function searchMessagesDiscord(
query: DiscordSearchQuery,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const params = new URLSearchParams();
params.set("content", query.content);
if (query.channelIds?.length) {
for (const channelId of query.channelIds) {
params.append("channel_id", channelId);
}
}
if (query.authorIds?.length) {
for (const authorId of query.authorIds) {
params.append("author_id", authorId);
}
}
if (query.limit) {
const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25);
params.set("limit", String(limit));
}
return await rest.get(`/guilds/${query.guildId}/messages/search`, {
query: params,
});
}
export async function listGuildEmojisDiscord(
guildId: string,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return await rest.get(Routes.guildEmojis(guildId));
}
export async function fetchMemberInfoDiscord(
guildId: string,
userId: string,
opts: DiscordReactOpts = {},
): Promise<APIGuildMember> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return (await rest.get(
Routes.guildMember(guildId, userId),
)) as APIGuildMember;
}
export async function fetchRoleInfoDiscord(
guildId: string,
opts: DiscordReactOpts = {},
): Promise<APIRole[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
}
export async function addRoleDiscord(
payload: DiscordRoleChange,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
await rest.put(
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
);
return { ok: true };
}
export async function removeRoleDiscord(
payload: DiscordRoleChange,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
await rest.delete(
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
);
return { ok: true };
}
export async function fetchChannelInfoDiscord(
channelId: string,
opts: DiscordReactOpts = {},
): Promise<APIChannel> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return (await rest.get(Routes.channel(channelId))) as APIChannel;
}
export async function listGuildChannelsDiscord(
guildId: string,
opts: DiscordReactOpts = {},
): Promise<APIChannel[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
}
export async function fetchVoiceStatusDiscord(
guildId: string,
userId: string,
opts: DiscordReactOpts = {},
): Promise<APIVoiceState> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return (await rest.get(
Routes.guildVoiceState(guildId, userId),
)) as APIVoiceState;
}
export async function listScheduledEventsDiscord(
guildId: string,
opts: DiscordReactOpts = {},
): Promise<APIGuildScheduledEvent[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return (await rest.get(
Routes.guildScheduledEvents(guildId),
)) as APIGuildScheduledEvent[];
}
export async function createScheduledEventDiscord(
guildId: string,
payload: RESTPostAPIGuildScheduledEventJSONBody,
opts: DiscordReactOpts = {},
): Promise<APIGuildScheduledEvent> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
return (await rest.post(Routes.guildScheduledEvents(guildId), {
body: payload,
})) as APIGuildScheduledEvent;
}
export async function timeoutMemberDiscord(
payload: DiscordTimeoutTarget,
opts: DiscordReactOpts = {},
): Promise<APIGuildMember> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
let until = payload.until;
if (!until && payload.durationMinutes) {
const ms = payload.durationMinutes * 60 * 1000;
until = new Date(Date.now() + ms).toISOString();
}
return (await rest.patch(
Routes.guildMember(payload.guildId, payload.userId),
{
body: { communication_disabled_until: until ?? null },
reason: payload.reason,
},
)) as APIGuildMember;
}
export async function kickMemberDiscord(
payload: DiscordModerationTarget,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
reason: payload.reason,
});
return { ok: true };
}
export async function banMemberDiscord(
payload: DiscordModerationTarget & { deleteMessageDays?: number },
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const deleteMessageDays =
typeof payload.deleteMessageDays === "number" &&
Number.isFinite(payload.deleteMessageDays)
? Math.min(Math.max(Math.floor(payload.deleteMessageDays), 0), 7)
: undefined;
await rest.put(Routes.guildBan(payload.guildId, payload.userId), {
body:
deleteMessageDays !== undefined
? { delete_message_days: deleteMessageDays }
: undefined,
reason: payload.reason,
});
await rest.put(Routes.channelMessageReaction(channelId, messageId, encoded));
return { ok: true };
}
+1 -2
View File
@@ -114,11 +114,11 @@ export function resolveHookMappings(
): HookMappingResolved[] {
const presets = hooks?.presets ?? [];
const mappings: HookMappingConfig[] = [];
if (hooks?.mappings) mappings.push(...hooks.mappings);
for (const preset of presets) {
const presetMappings = hookPresetMappings[preset];
if (presetMappings) mappings.push(...presetMappings);
}
if (hooks?.mappings) mappings.push(...hooks.mappings);
if (mappings.length === 0) return [];
const configDir = path.dirname(CONFIG_PATH_CLAWDIS);
@@ -151,7 +151,6 @@ export async function applyHookMappings(
}
}
if (!base.action) return { ok: true, action: null, skipped: true };
const merged = mergeAction(base.action, override, mapping.action);
if (!merged.ok) return merged;
return merged;
-20
View File
@@ -77,7 +77,6 @@ import {
} from "../discord/index.js";
import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
import { isVerbose } from "../globals.js";
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
import {
monitorIMessageProvider,
sendMessageIMessage,
@@ -6860,24 +6859,6 @@ export async function startGatewayServer(
logBrowser.error(`server failed to start: ${String(err)}`);
}
// Start Gmail watcher if configured (hooks.gmail.account).
if (process.env.CLAWDIS_SKIP_GMAIL_WATCHER !== "1") {
try {
const gmailResult = await startGmailWatcher(cfgAtStart);
if (gmailResult.started) {
logHooks.info("gmail watcher started");
} else if (
gmailResult.reason &&
gmailResult.reason !== "hooks not enabled" &&
gmailResult.reason !== "no gmail account configured"
) {
logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`);
}
} catch (err) {
logHooks.error(`gmail watcher failed to start: ${String(err)}`);
}
}
// Launch configured providers (WhatsApp Web, Discord, Telegram) so gateway replies via the
// surface the message came from. Tests can opt out via CLAWDIS_SKIP_PROVIDERS.
if (process.env.CLAWDIS_SKIP_PROVIDERS !== "1") {
@@ -6936,7 +6917,6 @@ export async function startGatewayServer(
await stopDiscordProvider();
await stopSignalProvider();
await stopIMessageProvider();
await stopGmailWatcher();
cron.stop();
heartbeatRunner.stop();
broadcast("shutdown", {
-219
View File
@@ -1,219 +0,0 @@
/**
* Gmail Watcher Service
*
* Automatically starts `gog gmail watch serve` when the gateway starts,
* if hooks.gmail is configured with an account.
*/
import { type ChildProcess, spawn } from "node:child_process";
import { hasBinary } from "../agents/skills.js";
import type { ClawdisConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import {
buildGogWatchServeArgs,
buildGogWatchStartArgs,
type GmailHookRuntimeConfig,
resolveGmailHookRuntimeConfig,
} from "./gmail.js";
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
const log = createSubsystemLogger("gmail-watcher");
let watcherProcess: ChildProcess | null = null;
let renewInterval: ReturnType<typeof setInterval> | null = null;
let shuttingDown = false;
let currentConfig: GmailHookRuntimeConfig | null = null;
/**
* Check if gog binary is available
*/
function isGogAvailable(): boolean {
return hasBinary("gog");
}
/**
* Start the Gmail watch (registers with Gmail API)
*/
async function startGmailWatch(
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
): Promise<boolean> {
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
try {
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
if (result.code !== 0) {
const message =
result.stderr || result.stdout || "gog watch start failed";
log.error(`watch start failed: ${message}`);
return false;
}
log.info(`watch started for ${cfg.account}`);
return true;
} catch (err) {
log.error(`watch start error: ${String(err)}`);
return false;
}
}
/**
* Spawn the gog gmail watch serve process
*/
function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
const args = buildGogWatchServeArgs(cfg);
log.info(`starting gog ${args.join(" ")}`);
const child = spawn("gog", args, {
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", (data: Buffer) => {
const line = data.toString().trim();
if (line) log.info(`[gog] ${line}`);
});
child.stderr?.on("data", (data: Buffer) => {
const line = data.toString().trim();
if (line) log.warn(`[gog] ${line}`);
});
child.on("error", (err) => {
log.error(`gog process error: ${String(err)}`);
});
child.on("exit", (code, signal) => {
if (shuttingDown) return;
log.warn(`gog exited (code=${code}, signal=${signal}); restarting in 5s`);
watcherProcess = null;
setTimeout(() => {
if (shuttingDown || !currentConfig) return;
watcherProcess = spawnGogServe(currentConfig);
}, 5000);
});
return child;
}
export type GmailWatcherStartResult = {
started: boolean;
reason?: string;
};
/**
* Start the Gmail watcher service.
* Called automatically by the gateway if hooks.gmail is configured.
*/
export async function startGmailWatcher(
cfg: ClawdisConfig,
): Promise<GmailWatcherStartResult> {
// Check if gmail hooks are configured
if (!cfg.hooks?.enabled) {
return { started: false, reason: "hooks not enabled" };
}
if (!cfg.hooks?.gmail?.account) {
return { started: false, reason: "no gmail account configured" };
}
// Check if gog is available
const gogAvailable = isGogAvailable();
if (!gogAvailable) {
return { started: false, reason: "gog binary not found" };
}
// Resolve the full runtime config
const resolved = resolveGmailHookRuntimeConfig(cfg, {});
if (!resolved.ok) {
return { started: false, reason: resolved.error };
}
const runtimeConfig = resolved.value;
currentConfig = runtimeConfig;
// Set up Tailscale endpoint if needed
if (runtimeConfig.tailscale.mode !== "off") {
try {
await ensureTailscaleEndpoint({
mode: runtimeConfig.tailscale.mode,
path: runtimeConfig.tailscale.path,
port: runtimeConfig.serve.port,
});
log.info(
`tailscale ${runtimeConfig.tailscale.mode} configured for port ${runtimeConfig.serve.port}`,
);
} catch (err) {
log.error(`tailscale setup failed: ${String(err)}`);
return {
started: false,
reason: `tailscale setup failed: ${String(err)}`,
};
}
}
// Start the Gmail watch (register with Gmail API)
const watchStarted = await startGmailWatch(runtimeConfig);
if (!watchStarted) {
log.warn("gmail watch start failed, but continuing with serve");
}
// Spawn the gog serve process
shuttingDown = false;
watcherProcess = spawnGogServe(runtimeConfig);
// Set up renewal interval
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
renewInterval = setInterval(() => {
if (shuttingDown) return;
void startGmailWatch(runtimeConfig);
}, renewMs);
log.info(
`gmail watcher started for ${runtimeConfig.account} (renew every ${runtimeConfig.renewEveryMinutes}m)`,
);
return { started: true };
}
/**
* Stop the Gmail watcher service.
*/
export async function stopGmailWatcher(): Promise<void> {
shuttingDown = true;
if (renewInterval) {
clearInterval(renewInterval);
renewInterval = null;
}
if (watcherProcess) {
log.info("stopping gmail watcher");
watcherProcess.kill("SIGTERM");
// Wait a bit for graceful shutdown
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (watcherProcess) {
watcherProcess.kill("SIGKILL");
}
resolve();
}, 3000);
watcherProcess?.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
watcherProcess = null;
}
currentConfig = null;
log.info("gmail watcher stopped");
}
/**
* Check if the Gmail watcher is running.
*/
export function isGmailWatcherRunning(): boolean {
return watcherProcess !== null && !shuttingDown;
}
-106
View File
@@ -1,106 +0,0 @@
import { convertTools } from "@mariozechner/pi-ai/dist/providers/google-shared.js";
import type { Tool } from "@mariozechner/pi-ai/dist/types.js";
import { describe, expect, it } from "vitest";
const asRecord = (value: unknown): Record<string, unknown> => {
expect(value).toBeTruthy();
expect(typeof value).toBe("object");
expect(Array.isArray(value)).toBe(false);
return value as Record<string, unknown>;
};
describe("google-shared convertTools", () => {
it("strips unsupported JSON Schema keywords", () => {
const tools: Tool[] = [
{
name: "example",
description: "Example tool",
parameters: {
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
additionalProperties: false,
properties: {
mode: {
type: "string",
const: "fast",
},
options: {
anyOf: [{ type: "string" }, { type: "number" }],
},
list: {
type: "array",
items: {
type: "string",
const: "item",
},
},
},
required: ["mode"],
},
},
];
const converted = convertTools(tools);
const params = asRecord(
converted?.[0]?.functionDeclarations?.[0]?.parameters,
);
const properties = asRecord(params.properties);
const mode = asRecord(properties.mode);
const options = asRecord(properties.options);
const list = asRecord(properties.list);
const items = asRecord(list.items);
expect(params).not.toHaveProperty("patternProperties");
expect(params).not.toHaveProperty("additionalProperties");
expect(mode).not.toHaveProperty("const");
expect(options).not.toHaveProperty("anyOf");
expect(items).not.toHaveProperty("const");
expect(params.required).toEqual(["mode"]);
});
it("keeps supported schema fields", () => {
const tools: Tool[] = [
{
name: "settings",
description: "Settings tool",
parameters: {
type: "object",
properties: {
config: {
type: "object",
properties: {
retries: { type: "number", minimum: 1 },
tags: {
type: "array",
items: { type: "string" },
},
},
required: ["retries"],
},
},
required: ["config"],
},
},
];
const converted = convertTools(tools);
const params = asRecord(
converted?.[0]?.functionDeclarations?.[0]?.parameters,
);
const config = asRecord(asRecord(params.properties).config);
const configProps = asRecord(config.properties);
const retries = asRecord(configProps.retries);
const tags = asRecord(configProps.tags);
const items = asRecord(tags.items);
expect(params.type).toBe("object");
expect(config.type).toBe("object");
expect(retries.minimum).toBe(1);
expect(tags.type).toBe("array");
expect(items.type).toBe("string");
expect(config.required).toEqual(["retries"]);
expect(params.required).toEqual(["config"]);
});
});
-160
View File
@@ -1,160 +0,0 @@
import { randomUUID } from "node:crypto";
import { loadConfig } from "../config/config.js";
import { GatewayClient } from "../gateway/client.js";
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
import { VERSION } from "../version.js";
export type GatewayConnectionOptions = {
url?: string;
token?: string;
password?: string;
};
export type ChatSendOptions = {
sessionKey: string;
message: string;
thinking?: string;
deliver?: boolean;
timeoutMs?: number;
};
export type GatewayEvent = {
event: string;
payload?: unknown;
};
export class GatewayChatClient {
private client: GatewayClient;
private readyPromise: Promise<void>;
private resolveReady?: () => void;
readonly connection: { url: string; token?: string; password?: string };
onEvent?: (evt: GatewayEvent) => void;
onConnected?: () => void;
onDisconnected?: (reason: string) => void;
onGap?: (info: { expected: number; received: number }) => void;
constructor(opts: GatewayConnectionOptions) {
const resolved = resolveGatewayConnection(opts);
this.connection = resolved;
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
this.client = new GatewayClient({
url: resolved.url,
token: resolved.token,
password: resolved.password,
clientName: "clawdis-tui",
clientVersion: VERSION,
platform: process.platform,
mode: "tui",
instanceId: randomUUID(),
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
onHelloOk: () => {
this.resolveReady?.();
this.onConnected?.();
},
onEvent: (evt) => {
this.onEvent?.({ event: evt.event, payload: evt.payload });
},
onClose: (_code, reason) => {
this.onDisconnected?.(reason);
},
onGap: (info) => {
this.onGap?.(info);
},
});
}
start() {
this.client.start();
}
stop() {
this.client.stop();
}
async waitForReady() {
await this.readyPromise;
}
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> {
const runId = randomUUID();
await this.client.request("chat.send", {
sessionKey: opts.sessionKey,
message: opts.message,
thinking: opts.thinking,
deliver: opts.deliver,
timeoutMs: opts.timeoutMs,
idempotencyKey: runId,
});
return { runId };
}
async abortChat(opts: { sessionKey: string; runId: string }) {
return await this.client.request<{ ok: boolean; aborted: boolean }>(
"chat.abort",
{
sessionKey: opts.sessionKey,
runId: opts.runId,
},
);
}
async loadHistory(opts: { sessionKey: string; limit?: number }) {
return await this.client.request("chat.history", {
sessionKey: opts.sessionKey,
limit: opts.limit,
});
}
async listSessions(opts?: { limit?: number; activeMinutes?: number }) {
return await this.client.request("sessions.list", {
limit: opts?.limit,
activeMinutes: opts?.activeMinutes,
});
}
}
export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
const config = loadConfig();
const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode ? config.gateway?.remote : undefined;
const authToken = config.gateway?.auth?.token;
const url =
(typeof opts.url === "string" && opts.url.trim().length > 0
? opts.url.trim()
: undefined) ||
(typeof remote?.url === "string" && remote.url.trim().length > 0
? remote.url.trim()
: undefined) ||
"ws://127.0.0.1:18789";
const token =
(typeof opts.token === "string" && opts.token.trim().length > 0
? opts.token.trim()
: undefined) ||
(isRemoteMode
? typeof remote?.token === "string" && remote.token.trim().length > 0
? remote.token.trim()
: undefined
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
(typeof authToken === "string" && authToken.trim().length > 0
? authToken.trim()
: undefined));
const password =
(typeof opts.password === "string" && opts.password.trim().length > 0
? opts.password.trim()
: undefined) ||
process.env.CLAWDIS_GATEWAY_PASSWORD?.trim() ||
(typeof remote?.password === "string" && remote.password.trim().length > 0
? remote.password.trim()
: undefined);
return { url, token, password };
}
-46
View File
@@ -1,46 +0,0 @@
import type { Component } from "@mariozechner/pi-tui";
export class ChatLayout implements Component {
constructor(
private header: Component,
private messages: Component,
private status: Component,
private input: Component,
) {}
invalidate(): void {
this.header.invalidate?.();
this.messages.invalidate?.();
this.status.invalidate?.();
this.input.invalidate?.();
}
render(width: number): string[] {
const rows = process.stdout.rows ?? 24;
const headerLines = this.header.render(width);
const statusLines = this.status.render(width);
const inputLines = this.input.render(width);
const reserved =
headerLines.length + statusLines.length + inputLines.length;
const available = Math.max(rows - reserved, 0);
const messageLines = this.messages.render(width);
const slicedMessages =
available > 0
? messageLines.slice(Math.max(0, messageLines.length - available))
: [];
const lines = [
...headerLines,
...slicedMessages,
...statusLines,
...inputLines,
];
if (lines.length < rows) {
const padding = Array.from({ length: rows - lines.length }, () => "");
return [...lines, ...padding];
}
return lines.slice(0, rows);
}
}
-83
View File
@@ -1,83 +0,0 @@
import crypto from "node:crypto";
import type { DefaultTextStyle, MarkdownTheme } from "@mariozechner/pi-tui";
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "./theme.js";
type MessageRole = "user" | "system" | "tool";
export class MessageList extends Container {
private assistantById = new Map<string, Markdown>();
constructor(
private markdownTheme: MarkdownTheme,
private styles: {
user: DefaultTextStyle;
assistant: DefaultTextStyle;
system: DefaultTextStyle;
tool: DefaultTextStyle;
},
) {
super();
}
clearAll(): void {
this.assistantById.clear();
this.clear();
}
addSystem(text: string): void {
this.addMessage("system", text, this.styles.system);
}
addTool(text: string): void {
this.addMessage("tool", text, this.styles.tool);
}
addUser(text: string): void {
this.addMessage("user", text, this.styles.user);
}
addAssistant(text: string, id?: string): string {
const messageId = id ?? crypto.randomUUID();
const label = new Text(theme.assistant("clawd"), 1, 0);
const body = new Markdown(
text,
1,
0,
this.markdownTheme,
this.styles.assistant,
);
const group = new Container();
group.addChild(label);
group.addChild(body);
this.addChild(group);
this.addChild(new Spacer(1));
this.assistantById.set(messageId, body);
return messageId;
}
updateAssistant(id: string, text: string): void {
const component = this.assistantById.get(id);
if (!component) return;
component.setText(text);
}
private addMessage(role: MessageRole, text: string, style: DefaultTextStyle) {
const label = new Text(
role === "user"
? theme.user("you")
: role === "system"
? theme.system("system")
: theme.dim("tool"),
1,
0,
);
const body = new Markdown(text, 1, 0, this.markdownTheme, style);
const group = new Container();
group.addChild(label);
group.addChild(body);
this.addChild(group);
this.addChild(new Spacer(1));
}
}
-28
View File
@@ -1,28 +0,0 @@
import type { MarkdownTheme } from "@mariozechner/pi-tui";
import chalk from "chalk";
export const markdownTheme: MarkdownTheme = {
heading: (text) => chalk.bold.cyan(text),
link: (text) => chalk.blue(text),
linkUrl: (text) => chalk.gray(text),
code: (text) => chalk.yellow(text),
codeBlock: (text) => chalk.yellow(text),
codeBlockBorder: (text) => chalk.gray(text),
quote: (text) => chalk.gray(text),
quoteBorder: (text) => chalk.gray(text),
hr: (text) => chalk.gray(text),
listBullet: (text) => chalk.cyan(text),
bold: (text) => chalk.bold(text),
italic: (text) => chalk.italic(text),
strikethrough: (text) => chalk.strikethrough(text),
underline: (text) => chalk.underline(text),
};
export const theme = {
header: (text: string) => chalk.bold.cyan(text),
dim: (text: string) => chalk.gray(text),
user: (text: string) => chalk.cyan(text),
assistant: (text: string) => chalk.green(text),
system: (text: string) => chalk.magenta(text),
error: (text: string) => chalk.red(text),
};
-347
View File
@@ -1,347 +0,0 @@
import {
type Component,
Input,
isCtrlC,
isEscape,
ProcessTerminal,
Text,
TUI,
} from "@mariozechner/pi-tui";
import { loadConfig } from "../config/config.js";
import { GatewayChatClient } from "./gateway-chat.js";
import { ChatLayout } from "./layout.js";
import { MessageList } from "./message-list.js";
import { markdownTheme, theme } from "./theme.js";
export type TuiOptions = {
url?: string;
token?: string;
password?: string;
session?: string;
deliver?: boolean;
thinking?: string;
timeoutMs?: number;
historyLimit?: number;
};
type ChatEvent = {
runId: string;
sessionKey: string;
state: "delta" | "final" | "aborted" | "error";
message?: unknown;
errorMessage?: string;
};
class InputWrapper implements Component {
constructor(
private input: Input,
private onAbort: () => void,
private onExit: () => void,
) {}
handleInput(data: string): void {
if (isCtrlC(data)) {
this.onExit();
return;
}
if (isEscape(data)) {
this.onAbort();
return;
}
this.input.handleInput(data);
}
render(width: number): string[] {
return this.input.render(width);
}
invalidate(): void {
this.input.invalidate();
}
}
function extractText(message?: unknown): string {
if (!message || typeof message !== "object") return "";
const record = message as Record<string, unknown>;
const content = Array.isArray(record.content) ? record.content : [];
const parts = content
.map((block) => {
if (!block || typeof block !== "object") return "";
const b = block as Record<string, unknown>;
if (b.type === "text" && typeof b.text === "string") return b.text;
return "";
})
.filter(Boolean);
return parts.join("\n").trim();
}
function renderHistoryEntry(
entry: unknown,
): { role: "user" | "assistant"; text: string } | null {
if (!entry || typeof entry !== "object") return null;
const record = entry as Record<string, unknown>;
const role =
record.role === "user" || record.role === "assistant" ? record.role : null;
if (!role) return null;
const text = extractText(record);
if (!text) return null;
return { role, text };
}
export async function runTui(opts: TuiOptions) {
const config = loadConfig();
const defaultSession =
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
let currentSession = defaultSession;
let activeRunId: string | null = null;
let streamingMessageId: string | null = null;
let historyLoaded = false;
const messages = new MessageList(markdownTheme, {
user: { color: theme.user },
assistant: { color: theme.assistant },
system: { color: theme.system, italic: true },
tool: { color: theme.dim, italic: true },
});
const header = new Text("", 1, 0);
const status = new Text("", 1, 0);
const input = new Input();
const tui = new TUI(new ProcessTerminal());
const inputWrapper = new InputWrapper(
input,
async () => {
if (!activeRunId) return;
try {
await client.abortChat({
sessionKey: currentSession,
runId: activeRunId,
});
} catch (err) {
messages.addSystem(`Abort failed: ${String(err)}`);
}
activeRunId = null;
streamingMessageId = null;
setStatus("aborted");
tui.requestRender();
},
() => {
client.stop();
tui.stop();
process.exit(0);
},
);
const layout = new ChatLayout(header, messages, status, inputWrapper);
tui.addChild(layout);
tui.setFocus(inputWrapper);
const client = new GatewayChatClient({
url: opts.url,
token: opts.token,
password: opts.password,
});
const updateHeader = () => {
header.setText(
theme.header(
`clawdis tui - ${client.connection.url} - session ${currentSession}`,
),
);
};
const setStatus = (text: string) => {
status.setText(theme.dim(text));
};
const loadHistory = async () => {
try {
const history = await client.loadHistory({
sessionKey: currentSession,
limit: opts.historyLimit ?? 200,
});
const historyRecord = history as { messages?: unknown[] } | undefined;
messages.clearAll();
messages.addSystem(`session ${currentSession}`);
for (const entry of historyRecord?.messages ?? []) {
const parsed = renderHistoryEntry(entry);
if (!parsed) continue;
if (parsed.role === "user") messages.addUser(parsed.text);
if (parsed.role === "assistant") messages.addAssistant(parsed.text);
}
historyLoaded = true;
tui.requestRender();
} catch (err) {
messages.addSystem(`history failed: ${String(err)}`);
tui.requestRender();
}
};
const handleChatEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as ChatEvent;
if (evt.sessionKey !== currentSession) return;
if (evt.state === "delta") {
const text = extractText(evt.message);
if (!text) return;
if (!streamingMessageId || activeRunId !== evt.runId) {
streamingMessageId = messages.addAssistant(text, evt.runId);
activeRunId = evt.runId;
} else {
messages.updateAssistant(streamingMessageId, text);
}
setStatus("streaming");
}
if (evt.state === "final") {
const text = extractText(evt.message);
if (streamingMessageId && activeRunId === evt.runId) {
messages.updateAssistant(streamingMessageId, text || "(no output)");
} else if (text) {
messages.addAssistant(text, evt.runId);
}
activeRunId = null;
streamingMessageId = null;
setStatus("idle");
}
if (evt.state === "aborted") {
messages.addSystem("run aborted");
activeRunId = null;
streamingMessageId = null;
setStatus("aborted");
}
if (evt.state === "error") {
messages.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
activeRunId = null;
streamingMessageId = null;
setStatus("error");
}
tui.requestRender();
};
const handleCommand = async (raw: string) => {
const [command, ...rest] = raw.slice(1).trim().split(/\s+/);
const arg = rest.join(" ").trim();
switch (command) {
case "help": {
messages.addSystem("/help /session <key> /abort /exit");
break;
}
case "session": {
if (!arg) {
messages.addSystem("missing session key");
break;
}
currentSession = arg;
activeRunId = null;
streamingMessageId = null;
historyLoaded = false;
updateHeader();
await loadHistory();
break;
}
case "abort": {
if (!activeRunId) {
messages.addSystem("no active run");
break;
}
await client.abortChat({
sessionKey: currentSession,
runId: activeRunId,
});
break;
}
case "exit": {
client.stop();
tui.stop();
process.exit(0);
break;
}
case "quit": {
client.stop();
tui.stop();
process.exit(0);
break;
}
default:
messages.addSystem(`unknown command: /${command}`);
break;
}
tui.requestRender();
};
const sendMessage = async (text: string) => {
try {
messages.addUser(text);
tui.requestRender();
setStatus("sending");
const { runId } = await client.sendChat({
sessionKey: currentSession,
message: text,
thinking: opts.thinking,
deliver: opts.deliver,
timeoutMs: opts.timeoutMs,
});
activeRunId = runId;
streamingMessageId = null;
setStatus("waiting");
} catch (err) {
messages.addSystem(`send failed: ${String(err)}`);
setStatus("error");
}
tui.requestRender();
};
input.onSubmit = (value) => {
const text = value.trim();
input.setValue("");
if (!text) return;
if (text.startsWith("/")) {
void handleCommand(text);
return;
}
void sendMessage(text);
};
client.onEvent = (evt) => {
if (evt.event === "chat") handleChatEvent(evt.payload);
};
client.onConnected = () => {
setStatus("connected");
updateHeader();
if (!historyLoaded) {
void loadHistory().then(() => {
messages.addSystem("gateway connected");
tui.requestRender();
});
} else {
messages.addSystem("gateway reconnected");
}
tui.requestRender();
};
client.onDisconnected = (reason) => {
messages.addSystem(`gateway disconnected: ${reason || "closed"}`);
setStatus("disconnected");
tui.requestRender();
};
client.onGap = (info) => {
messages.addSystem(
`event gap: expected ${info.expected}, got ${info.received}`,
);
tui.requestRender();
};
updateHeader();
setStatus("connecting");
messages.addSystem("connecting...");
tui.start();
client.start();
}
-2
View File
@@ -8,8 +8,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
+7 -8
View File
@@ -26,13 +26,12 @@ import type {
SkillStatusReport,
StatusSummary,
} from "./types";
import {
defaultDiscordActions,
type CronFormState,
type DiscordForm,
type IMessageForm,
type SignalForm,
type TelegramForm,
import type {
CronFormState,
DiscordForm,
IMessageForm,
SignalForm,
TelegramForm,
} from "./ui-types";
import { loadChatHistory, sendChat, handleChatEvent } from "./controllers/chat";
import { loadNodes } from "./controllers/nodes";
@@ -144,7 +143,7 @@ export class ClawdisApp extends LitElement {
groupChannels: "",
mediaMaxMb: "",
historyLimit: "",
actions: { ...defaultDiscordActions },
enableReactions: true,
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
+3 -30
View File
@@ -1,13 +1,6 @@
import type { GatewayBrowserClient } from "../gateway";
import type { ConfigSnapshot } from "../types";
import {
defaultDiscordActions,
type DiscordActionForm,
type DiscordForm,
type IMessageForm,
type SignalForm,
type TelegramForm,
} from "../ui-types";
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
export type ConfigState = {
client: GatewayBrowserClient | null;
@@ -95,11 +88,6 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
const discordDm = (discord.dm ?? {}) as Record<string, unknown>;
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
const discordActions = (discord.actions ?? {}) as Record<string, unknown>;
const readAction = (key: keyof DiscordActionForm) =>
typeof discordActions[key] === "boolean"
? (discordActions[key] as boolean)
: defaultDiscordActions[key];
state.discordForm = {
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
token: typeof discord.token === "string" ? discord.token : "",
@@ -111,23 +99,8 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "",
historyLimit:
typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "",
actions: {
reactions: readAction("reactions"),
stickers: readAction("stickers"),
polls: readAction("polls"),
permissions: readAction("permissions"),
messages: readAction("messages"),
threads: readAction("threads"),
pins: readAction("pins"),
search: readAction("search"),
memberInfo: readAction("memberInfo"),
roleInfo: readAction("roleInfo"),
channelInfo: readAction("channelInfo"),
voiceStatus: readAction("voiceStatus"),
events: readAction("events"),
roles: readAction("roles"),
moderation: readAction("moderation"),
},
enableReactions:
typeof discord.enableReactions === "boolean" ? discord.enableReactions : true,
slashEnabled: typeof slash.enabled === "boolean" ? slash.enabled : false,
slashName: typeof slash.name === "string" ? slash.name : "",
slashSessionPrefix:
+4 -39
View File
@@ -1,14 +1,7 @@
import type { GatewayBrowserClient } from "../gateway";
import { parseList } from "../format";
import type { ConfigSnapshot, ProvidersStatusSnapshot } from "../types";
import {
defaultDiscordActions,
type DiscordActionForm,
type DiscordForm,
type IMessageForm,
type SignalForm,
type TelegramForm,
} from "../ui-types";
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
export type ConnectionsState = {
client: GatewayBrowserClient | null;
@@ -123,14 +116,6 @@ export function updateDiscordForm(
state: ConnectionsState,
patch: Partial<DiscordForm>,
) {
if (patch.actions) {
state.discordForm = {
...state.discordForm,
...patch,
actions: { ...state.discordForm.actions, ...patch.actions },
};
return;
}
state.discordForm = { ...state.discordForm, ...patch };
}
@@ -261,30 +246,10 @@ export async function saveDiscordConfig(state: ConnectionsState) {
}
}
const actions: Partial<DiscordActionForm> = {};
const applyAction = (key: keyof DiscordActionForm) => {
const value = form.actions[key];
if (value !== defaultDiscordActions[key]) actions[key] = value;
};
applyAction("reactions");
applyAction("stickers");
applyAction("polls");
applyAction("permissions");
applyAction("messages");
applyAction("threads");
applyAction("pins");
applyAction("search");
applyAction("memberInfo");
applyAction("roleInfo");
applyAction("channelInfo");
applyAction("voiceStatus");
applyAction("events");
applyAction("roles");
applyAction("moderation");
if (Object.keys(actions).length > 0) {
discord.actions = actions;
if (form.enableReactions) {
delete discord.enableReactions;
} else {
delete discord.actions;
discord.enableReactions = false;
}
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
+1 -37
View File
@@ -16,49 +16,13 @@ export type DiscordForm = {
groupChannels: string;
mediaMaxMb: string;
historyLimit: string;
actions: DiscordActionForm;
enableReactions: boolean;
slashEnabled: boolean;
slashName: string;
slashSessionPrefix: string;
slashEphemeral: boolean;
};
export type DiscordActionForm = {
reactions: boolean;
stickers: boolean;
polls: boolean;
permissions: boolean;
messages: boolean;
threads: boolean;
pins: boolean;
search: boolean;
memberInfo: boolean;
roleInfo: boolean;
channelInfo: boolean;
voiceStatus: boolean;
events: boolean;
roles: boolean;
moderation: boolean;
};
export const defaultDiscordActions: DiscordActionForm = {
reactions: true,
stickers: true,
polls: true,
permissions: true,
messages: true,
threads: true,
pins: true,
search: true,
memberInfo: true,
roleInfo: true,
channelInfo: true,
voiceStatus: true,
events: true,
roles: false,
moderation: false,
};
export type SignalForm = {
enabled: boolean;
account: string;
+1 -5
View File
@@ -43,10 +43,7 @@ export function renderChat(props: ChatProps) {
props.onSessionKeyChange((e.target as HTMLSelectElement).value)}
>
${sessionOptions.map(
(entry) =>
html`<option value=${entry.key}>
${entry.displayName ?? entry.key}
</option>`,
(entry) => html`<option value=${entry.key}>${entry.key}</option>`,
)}
</select>
</label>
@@ -118,7 +115,6 @@ export function renderChat(props: ChatProps) {
type SessionOption = {
key: string;
updatedAt?: number | null;
displayName?: string;
};
function resolveSessionOptions(
+14 -47
View File
@@ -2,13 +2,7 @@ import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { ProvidersStatusSnapshot } from "../types";
import type {
DiscordActionForm,
DiscordForm,
IMessageForm,
SignalForm,
TelegramForm,
} from "../ui-types";
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
export type ConnectionsProps = {
connected: boolean;
@@ -54,24 +48,6 @@ export function renderConnections(props: ConnectionsProps) {
const discord = props.snapshot?.discord ?? null;
const signal = props.snapshot?.signal ?? null;
const imessage = props.snapshot?.imessage ?? null;
const discordActionOptions: Array<{ key: keyof DiscordActionForm; label: string }> =
[
{ key: "reactions", label: "Reactions" },
{ key: "stickers", label: "Stickers" },
{ key: "polls", label: "Polls" },
{ key: "permissions", label: "Permissions" },
{ key: "messages", label: "Messages" },
{ key: "threads", label: "Threads" },
{ key: "pins", label: "Pins" },
{ key: "search", label: "Search" },
{ key: "memberInfo", label: "Member info" },
{ key: "roleInfo", label: "Role info" },
{ key: "channelInfo", label: "Channel info" },
{ key: "voiceStatus", label: "Voice status" },
{ key: "events", label: "Events" },
{ key: "roles", label: "Role changes" },
{ key: "moderation", label: "Moderation" },
];
const providerOrder: ProviderKey[] = [
"whatsapp",
"telegram",
@@ -546,6 +522,19 @@ function renderProvider(
placeholder="20"
/>
</label>
<label class="field">
<span>Reactions</span>
<select
.value=${props.discordForm.enableReactions ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
enableReactions: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Slash command</span>
<select
@@ -596,28 +585,6 @@ function renderProvider(
</label>
</div>
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
<div class="form-grid" style="margin-top: 8px;">
${discordActionOptions.map(
(action) => html`<label class="field">
<span>${action.label}</span>
<select
.value=${props.discordForm.actions[action.key] ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
actions: {
...props.discordForm.actions,
[action.key]: (e.target as HTMLSelectElement).value === "yes",
},
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>`,
)}
</div>
${props.discordTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
DISCORD_BOT_TOKEN is set in the environment. Config edits will not override it.