Compare commits

..
Author SHA1 Message Date
Peter Steinberger fc4affb949 docs: update changelog for gog skill 2026-01-03 11:18:08 +01:00
Mariano Belinky b219ad5fda docs: add Sheets/Docs examples to gog skill 2026-01-03 11:17:34 +01:00
Peter Steinberger c583e64bb7 chore: update changelog 2026-01-03 11:17:00 +01:00
Peter Steinberger 9df63b008d docs: move telegram chunking fix to beta6 2026-01-03 11:15:57 +01:00
Peter Steinberger 3daecc092c docs: add changelog entry for telegram block replies 2026-01-03 11:12:15 +01:00
Muhammed Mukhthar CM 4d42811ecf fix(telegram): add textLimit to block reply chunking
Block streaming replies were missing the textLimit parameter in
deliverReplies(), causing long messages to fail with 'message is too
long' error instead of being chunked properly.

The final reply path already included textLimit, but the onBlockReply
callback path did not.
2026-01-03 11:12:15 +01:00
Peter Steinberger 1bebcf8033 chore: update appcast and TUI streaming handling 2026-01-03 11:06:49 +01:00
Peter Steinberger 45c555a4bd fix: use x86_64 bun for relay builds 2026-01-03 11:06:49 +01:00
Peter Steinberger 5986a83e80 fix: skip duplicate arch merge for Sparkle 2026-01-03 11:06:49 +01:00
Peter Steinberger 732de4acf0 fix: make Sparkle builds numeric + universal 2026-01-03 11:06:48 +01:00
Shadow 7400c0946e Discord: update UIs to use the new config 2026-01-03 01:02:22 -06:00
Peter Steinberger 14ee2b2c11 FAQ: Add common questions from Discord (Jan 3)
- Linux/VPS installation without Homebrew
- Minimum system requirements (runs on 1GB RAM!)
- Enterprise OAuth status (not supported yet)
- Discord DM allowlist config
- Model switching with /model
- Message queue modes with /queue
2026-01-03 06:09:51 +00:00
Peter Steinberger c3e1b8cfd9 chore: update protocol swift models 2026-01-03 06:44:21 +01:00
Peter Steinberger 67a67df35a fix: avoid unsafe string coercion in tui 2026-01-03 06:44:17 +01:00
Peter Steinberger 0f0578b268 docs: check off tui gate 2026-01-03 06:37:44 +01:00
Peter Steinberger 662208949f fix: align sessions.patch and tui typing 2026-01-03 06:37:40 +01:00
Peter Steinberger e41821342b docs: refresh tui guide 2026-01-03 06:28:36 +01:00
Peter Steinberger d3458a4fc3 feat: overhaul tui controller 2026-01-03 06:27:38 +01:00
Peter Steinberger 32c91bbb25 feat: add tui ui kit 2026-01-03 06:22:20 +01:00
Peter Steinberger aee13507f9 feat: expand tui gateway client 2026-01-03 06:17:33 +01:00
Peter Steinberger 61b67f6301 feat: extend gateway session patch 2026-01-03 06:16:49 +01:00
Peter Steinberger b86619bcd0 docs: fix appcast to only ship beta5 2026-01-03 06:12:01 +01:00
Peter Steinberger 31b5b45581 docs: refresh appcast for notarized beta5 2026-01-03 06:04:20 +01:00
Peter Steinberger 33cdb16b9e docs: update appcast for 2.0.0-beta5 2026-01-03 05:55:31 +01:00
Peter Steinberger 53fd7a4473 chore: fix lint ordering 2026-01-03 05:38:29 +01:00
Peter Steinberger 10d56d31e9 docs: date 2.0.0-beta5 changelog 2026-01-03 05:37:04 +01:00
Peter Steinberger 3633c829ae fix: repair discord action typing 2026-01-03 05:33:57 +01:00
Peter Steinberger 6cda84432e fix: stabilize pi-ai patch + tests 2026-01-03 05:22:09 +01:00
Peter Steinberger b914eaa6fa chore: apply biome lint fixes 2026-01-03 05:10:09 +01:00
Peter Steinberger 988b67aa30 test: refresh auto-reply expectations 2026-01-03 05:09:59 +01:00
Peter Steinberger 0ed5b82389 fix: prefer explicit hook mappings 2026-01-03 05:09:54 +01:00
Peter Steinberger b417fe5727 fix: show rich session names in chat UIs 2026-01-03 05:07:13 +01:00
Peter Steinberger fabad7aa7a docs: update changelog for antigravity oauth 2026-01-03 05:01:42 +01:00
Peter Steinberger 3c54da952a chore: refresh pi-ai patch hash 2026-01-03 05:01:42 +01:00
Peter Steinberger 2ef2646b31 chore: note lossy google schema scrub 2026-01-03 05:01:42 +01:00
mukhtharcm 82ad7e29a6 fix: reject antigravity auth in non-interactive onboarding mode 2026-01-03 05:01:42 +01:00
mukhtharcm 2290a3c8af feat: add VPS-aware Antigravity OAuth with manual URL paste fallback
Detects SSH/VPS/headless environments and prompts user to paste
the OAuth callback URL instead of relying on localhost server.

- Add antigravity-oauth.ts with VPS detection and manual OAuth flow
- Update onboard-interactive.ts to use VPS-aware flow
- Update configure.ts to use VPS-aware flow
2026-01-03 05:01:42 +01:00
mukhtharcm d216cebff5 fix: use claude-opus-4-5-thinking as default antigravity model 2026-01-03 05:01:42 +01:00
mukhtharcm 05bd345828 feat: add Google Antigravity authentication support
- Add 'antigravity' as new auth choice in onboard and configure wizards
- Implement Google Antigravity OAuth flow using loginAntigravity from pi-ai
- Update writeOAuthCredentials to accept any OAuthProvider (not just 'anthropic')
- Add schema sanitization for Google Cloud Code Assist API to fix tool call errors
- Default model set to google-antigravity/claude-opus-4-5 after successful auth

The schema sanitization removes unsupported JSON Schema keywords (patternProperties,
const, anyOf, etc.) that Google's Cloud Code Assist API doesn't understand.
2026-01-03 05:01:42 +01:00
61 changed files with 2891 additions and 488 deletions

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ vendor/
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
.bundle.hash
# fastlane (iOS)
apps/ios/fastlane/README.md
+12 -2
View File
@@ -1,6 +1,14 @@
# Changelog
## 2.0.0-beta5 — Unreleased
## Unreleased
### Fixes
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
### Docs
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
## 2.0.0-beta5 — 2026-01-03
### Fixed
- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).
@@ -49,7 +57,8 @@
- 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).
- CLI: enhance `clawdis tui` with model/session pickers, tool cards, and slash commands (local or remote).
- Gateway: allow `sessions.patch` to set per-session model overrides (used by the TUI `/model` flow).
- 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,6 +71,7 @@
- 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).
+198 -17
View File
@@ -3,29 +3,210 @@
<channel>
<title>Clawdis</title>
<item>
<title>2.0.0-beta3</title>
<pubDate>Sat, 27 Dec 2025 19:02:02 +0100</pubDate>
<title>2.0.0-beta5</title>
<pubDate>Sat, 03 Jan 2026 07:15:16 +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:version>2765</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta5</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, 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>
<description><![CDATA[<h2>Clawdis 2.0.0-beta4</h2>
<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: enhance <code>clawdis tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</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>
<h3>Fixes</h3>
<ul>
<li>Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.</li>
<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>
</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-beta4/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
</item>
</channel>
</rss>
</rss>
@@ -148,7 +148,7 @@ fun ChatComposer(
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
Spacer(modifier = Modifier.weight(1f))
if (pendingRunCount > 0) {
@@ -186,7 +186,7 @@ fun ChatComposer(
}
@Composable
private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -201,7 +201,7 @@ private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
shape = androidx.compose.foundation.shape.CircleShape,
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
) {}
Text(sessionKey, style = MaterialTheme.typography.labelSmall)
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
Text(
if (healthOk) "Connected" else "Connecting…",
style = MaterialTheme.typography.labelSmall,
@@ -289,6 +289,12 @@ struct ConnectionsSettings: View {
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("DMs enabled")
Toggle("", isOn: self.$store.discordDmEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Group DMs")
Toggle("", isOn: self.$store.discordGroupEnabled)
@@ -310,6 +316,20 @@ struct ConnectionsSettings: View {
TextField("20", text: self.$store.discordHistoryLimit)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Text chunk limit")
TextField("2000", text: self.$store.discordTextChunkLimit)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Reply to mode")
Picker("", selection: self.$store.discordReplyToMode) {
Text("off").tag("off")
Text("first").tag("first")
Text("all").tag("all")
}
.labelsHidden()
}
GridRow {
self.gridLabel("Slash command")
Toggle("", isOn: self.$store.discordSlashEnabled)
@@ -336,6 +356,79 @@ struct ConnectionsSettings: View {
Divider().padding(.vertical, 2)
Text("Guilds")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 12) {
ForEach($store.discordGuilds) { $guild in
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("guild id or slug", text: $guild.key)
.textFieldStyle(.roundedBorder)
Button("Remove") {
self.store.discordGuilds.removeAll { $0.id == guild.id }
}
.buttonStyle(.bordered)
}
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Slug")
TextField("optional slug", text: $guild.slug)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: $guild.requireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Users allowlist")
TextField("123456789, username#1234", text: $guild.users)
.textFieldStyle(.roundedBorder)
}
}
Text("Channels")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
ForEach($guild.channels) { $channel in
HStack(spacing: 10) {
TextField("channel id or slug", text: $channel.key)
.textFieldStyle(.roundedBorder)
Toggle("Allow", isOn: $channel.allow)
.toggleStyle(.checkbox)
Toggle("Require mention", isOn: $channel.requireMention)
.toggleStyle(.checkbox)
Button("Remove") {
guild.channels.removeAll { $0.id == channel.id }
}
.buttonStyle(.bordered)
}
}
Button("Add channel") {
guild.channels.append(DiscordGuildChannelForm())
}
.buttonStyle(.bordered)
}
}
.padding(10)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button("Add guild") {
self.store.discordGuilds.append(DiscordGuildForm())
}
.buttonStyle(.bordered)
}
Divider().padding(.vertical, 2)
Text("Tool actions")
.font(.caption)
.foregroundStyle(.secondary)
@@ -144,6 +144,42 @@ struct ConfigSnapshot: Codable {
let issues: [Issue]?
}
struct DiscordGuildChannelForm: Identifiable {
let id = UUID()
var key: String
var allow: Bool
var requireMention: Bool
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
self.key = key
self.allow = allow
self.requireMention = requireMention
}
}
struct DiscordGuildForm: Identifiable {
let id = UUID()
var key: String
var slug: String
var requireMention: Bool
var users: String
var channels: [DiscordGuildChannelForm]
init(
key: String = "",
slug: String = "",
requireMention: Bool = false,
users: String = "",
channels: [DiscordGuildChannelForm] = []
) {
self.key = key
self.slug = slug
self.requireMention = requireMention
self.users = users
self.channels = channels
}
}
@MainActor
@Observable
final class ConnectionsStore {
@@ -169,11 +205,15 @@ final class ConnectionsStore {
var telegramBusy = false
var discordEnabled = true
var discordToken: String = ""
var discordDmEnabled = true
var discordAllowFrom: String = ""
var discordGroupEnabled = false
var discordGroupChannels: String = ""
var discordMediaMaxMb: String = ""
var discordHistoryLimit: String = ""
var discordTextChunkLimit: String = ""
var discordReplyToMode: String = "off"
var discordGuilds: [DiscordGuildForm] = []
var discordActionReactions = true
var discordActionStickers = true
var discordActionPolls = true
@@ -401,6 +441,7 @@ final class ConnectionsStore {
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? ""
let discordDm = discord?["dm"]?.dictionaryValue
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
if let allow = discordDm?["allowFrom"]?.arrayValue {
let strings = allow.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
@@ -434,6 +475,56 @@ final class ConnectionsStore {
} else {
self.discordHistoryLimit = ""
}
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) {
self.discordTextChunkLimit = String(Int(limit))
} else {
self.discordTextChunkLimit = ""
}
if let mode = discord?["replyToMode"]?.stringValue, ["off", "first", "all"].contains(mode) {
self.discordReplyToMode = mode
} else {
self.discordReplyToMode = "off"
}
if let guilds = discord?["guilds"]?.dictionaryValue {
self.discordGuilds = guilds
.map { key, value in
let entry = value.dictionaryValue ?? [:]
let slug = entry["slug"]?.stringValue ?? ""
let requireMention = entry["requireMention"]?.boolValue ?? false
let users = entry["users"]?.arrayValue?
.compactMap { item -> String? in
if let str = item.stringValue { return str }
if let intVal = item.intValue { return String(intVal) }
if let doubleVal = item.doubleValue { return String(Int(doubleVal)) }
return nil
}
.joined(separator: ", ") ?? ""
let channels: [DiscordGuildChannelForm]
if let channelMap = entry["channels"]?.dictionaryValue {
channels = channelMap.map { channelKey, channelValue in
let channelEntry = channelValue.dictionaryValue ?? [:]
let allow = channelEntry["allow"]?.boolValue ?? true
let channelRequireMention =
channelEntry["requireMention"]?.boolValue ?? false
return DiscordGuildChannelForm(
key: channelKey,
allow: allow,
requireMention: channelRequireMention)
}
} else {
channels = []
}
return DiscordGuildForm(
key: key,
slug: slug,
requireMention: requireMention,
users: users,
channels: channels)
}
.sorted { $0.key < $1.key }
} else {
self.discordGuilds = []
}
let discordActions = discord?["actions"]?.dictionaryValue
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
@@ -625,6 +716,11 @@ final class ConnectionsStore {
}
var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:]
if self.discordDmEnabled {
dm.removeValue(forKey: "enabled")
} else {
dm["enabled"] = false
}
let allow = self.discordAllowFrom
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
@@ -673,6 +769,53 @@ final class ConnectionsStore {
discord.removeValue(forKey: "historyLimit")
}
let chunkLimit = self.discordTextChunkLimit.trimmingCharacters(in: .whitespacesAndNewlines)
if chunkLimit.isEmpty {
discord.removeValue(forKey: "textChunkLimit")
} else if let value = Int(chunkLimit), value > 0 {
discord["textChunkLimit"] = value
} else {
discord.removeValue(forKey: "textChunkLimit")
}
let replyToMode = self.discordReplyToMode.trimmingCharacters(in: .whitespacesAndNewlines)
if replyToMode.isEmpty || replyToMode == "off" {
discord.removeValue(forKey: "replyToMode")
} else if ["first", "all"].contains(replyToMode) {
discord["replyToMode"] = replyToMode
} else {
discord.removeValue(forKey: "replyToMode")
}
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var payload: [String: Any] = [:]
let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines)
if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true }
let users = entry.users
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if !users.isEmpty { payload["users"] = users }
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
let channelKey = channel.key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !channelKey.isEmpty else { return }
var channelPayload: [String: Any] = [:]
if !channel.allow { channelPayload["allow"] = false }
if channel.requireMention { channelPayload["requireMention"] = true }
channelsResult[channelKey] = channelPayload
}
if !channels.isEmpty { payload["channels"] = channels }
result[key] = payload
}
if guilds.isEmpty {
discord.removeValue(forKey: "guilds")
} else {
discord["guilds"] = guilds
}
var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:]
func setAction(_ key: String, value: Bool, defaultValue: Bool) {
if value == defaultValue {
@@ -613,23 +613,27 @@ public struct SessionsPatchParams: Codable {
public let key: String
public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable?
public let model: AnyCodable?
public let groupactivation: AnyCodable?
public init(
key: String,
thinkinglevel: AnyCodable?,
verboselevel: AnyCodable?,
model: AnyCodable?,
groupactivation: AnyCodable?
) {
self.key = key
self.thinkinglevel = thinkinglevel
self.verboselevel = verboselevel
self.model = model
self.groupactivation = groupactivation
}
private enum CodingKeys: String, CodingKey {
case key
case thinkinglevel = "thinkingLevel"
case verboselevel = "verboseLevel"
case model
case groupactivation = "groupActivation"
}
}
@@ -209,7 +209,7 @@ struct ClawdisChatComposer: View {
Circle()
.fill(self.viewModel.healthOK ? .green : .orange)
.frame(width: 7, height: 7)
Text(self.viewModel.sessionKey)
Text(self.activeSessionLabel)
.font(.caption2.weight(.semibold))
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
.font(.caption2)
@@ -221,6 +221,12 @@ 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
@@ -37,6 +37,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
- [ ] Generate the Sparkle appcast (HTML notes via `scripts/make_appcast.sh`) and update `appcast.xml`.
- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release.
- [ ] Follow `docs/mac/release.md` for the exact commands and required env vars.
- `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly.
- If notarizing, use the `clawdis-notary` keychain profile created from App Store Connect API env vars (see `docs/mac/release.md`).
6) **Publish (npm)**
+109 -1
View File
@@ -32,6 +32,33 @@ Some features are platform-specific:
- **iMessage** — macOS only (uses `imsg` CLI)
- **Clawdis.app** — macOS native app (optional, gateway works without it)
### What are the minimum system requirements?
**Basically nothing!** The gateway is very lightweight — all heavy compute happens on Anthropic's servers.
- **RAM:** 512MB-1GB is enough (community member runs on 1GB VPS!)
- **CPU:** 1 core is fine for personal use
- **Disk:** ~500MB for Clawdis + deps, plus space for logs/media
The gateway is just shuffling messages around. A Raspberry Pi 4 can run it. You can also use **Bun** instead of Node for even lower memory footprint:
```bash
bun clawdis gateway
```
### How do I install on Linux without Homebrew?
Build CLIs from source! Example for `gogcli`:
```bash
git clone https://github.com/steipete/gogcli.git
cd gogcli
make
sudo mv bin/gog /usr/local/bin/
```
Most of Peter's tools are Go binaries — clone, build, move to PATH. No brew needed.
### I'm getting "unauthorized" errors on health check
You need a config file. Run the onboarding wizard:
@@ -83,10 +110,20 @@ The macOS app onboarding is still being polished and can have quirks (e.g., What
They're **separate billing**! An API key does NOT use your subscription.
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back.
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back. Or just run:
```bash
pnpm clawdis login
```
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdis/` to your server. The auth is just a JSON file.
### Does enterprise OAuth work?
**Not currently.** Enterprise accounts use SSO which requires a different auth flow that pi-coding-agent doesn't support yet.
**Workaround:** Ask your enterprise admin to provision an API key via the Anthropic console, then use that with `ANTHROPIC_API_KEY`.
### OAuth callback not working (containers/headless)?
OAuth needs the callback to reach the machine running the CLI. Options:
@@ -271,6 +308,33 @@ cat ~/.clawdis/clawdis.json | grep workspace
- **Signal** — Via `signal-cli` (see [signal.md](./signal.md)).
- **WebChat** — Browser-based chat UI.
### Discord: Bot works in channels but not DMs?
Discord has **separate allowlists** for channels vs DMs:
- `discord.guilds.*.users` — controls who can talk in server channels
- `discord.dm.allowFrom` — controls who can DM the bot
If channels work but DMs don't, add `discord.dm.allowFrom` to your config:
```json
{
"discord": {
"dm": {
"enabled": true,
"allowFrom": ["YOUR_DISCORD_USER_ID"]
},
"guilds": {
"your-server": {
"users": ["YOUR_DISCORD_USER_ID"]
}
}
}
}
```
Find your user ID: Discord Settings → Advanced → Developer Mode → right-click yourself → Copy User ID.
### Can I use multiple platforms at once?
Yes! One Clawdis gateway can connect to WhatsApp, Telegram, Discord, and more simultaneously. Each platform maintains its own sessions.
@@ -399,6 +463,50 @@ Quick reference (send these in chat):
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
| `/verbose on\|off` | Toggle verbose mode |
| `/activation mention\|always` | Group activation (owner-only) |
| `/model <name>` | Switch AI model (see below) |
| `/queue instant\|batch\|serial` | Message queuing mode |
### How do I switch models on the fly?
Use `/model` to switch without restarting:
```
/model sonnet
/model haiku
/model opus
```
**Setup:** Configure allowed models and aliases in `clawdis.json`:
```json
{
"agent": {
"model": "anthropic/claude-opus-4-5-20251022",
"allowedModels": [
"anthropic/claude-opus-4-5-20251022",
"anthropic/claude-sonnet-4-5-20251022",
"anthropic/claude-haiku-4-5-20251001"
],
"modelAliases": {
"opus": "anthropic/claude-opus-4-5-20251022",
"sonnet": "anthropic/claude-sonnet-4-5-20251022",
"haiku": "anthropic/claude-haiku-4-5-20251001"
}
}
}
```
**Tip:** `/model` is processed at the gateway level — it works even if you're rate-limited (429) on the current model!
### How do rapid-fire messages work?
Use `/queue` to control how messages sent in quick succession are handled:
- **`/queue instant`** — New messages interrupt/steer the current response
- **`/queue batch`** — Messages queue up, processed after current turn
- **`/queue serial`** — One at a time, in order
If you tend to send multiple short messages, `/queue instant` feels most natural.
---
+8 -3
View File
@@ -21,11 +21,16 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.).
## Build & package
Notes:
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
- Universal by default (`arm64 x86_64`). Override with `BUILD_ARCHS="arm64"` if you need arm-only.
```bash
# From repo root; set release IDs so Sparkle feed is enabled
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.steipete.clawdis \
APP_VERSION=0.1.0 \
APP_BUILD=0.1.0 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
scripts/package-mac-app.sh
@@ -43,7 +48,7 @@ scripts/create-dmg.sh dist/Clawdis.app dist/Clawdis-0.1.0.dmg
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdis-notary \
BUNDLE_ID=com.steipete.clawdis \
APP_VERSION=0.1.0 \
APP_BUILD=0.1.0 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
scripts/package-mac-dist.sh
+26
View File
@@ -0,0 +1,26 @@
---
summary: "Refactor plan: Gateway TUI parity with pi-mono interactive UI"
read_when:
- Building or refactoring the Gateway TUI
- Syncing TUI slash commands with Clawdis behavior
---
# Gateway TUI refactor plan
Updated: 2026-01-03
## Goals
- Match pi-mono interactive TUI feel (editor, streaming, tool cards, selectors).
- Keep Clawdis semantics: Gateway WS only, session store owns state, no branching/export.
- Work locally or remotely via Gateway URL/token.
## Non-goals
- Branching, export, OAuth flows, or hook UIs.
- File-system operations on the Gateway host from the TUI.
## Checklist
- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads).
- [x] Gateway TUI client: add session/model helpers + stricter typing.
- [x] TUI UI kit: theme + components (editor, message feed, tool cards, selectors).
- [x] TUI controller: keybindings + Clawdis slash commands + history/stream wiring.
- [x] Docs + changelog updated for the new TUI behavior.
- [x] Gate: lint, build, tests, docs list.
+18 -4
View File
@@ -10,6 +10,7 @@ Updated: 2026-01-03
## What it is
- A terminal UI that connects to the Gateway WebSocket and speaks the same chat APIs as WebChat.
- Uses Gateway agent events for tool cards while streaming responses.
- Works locally (loopback) or remotely (Tailscale/SSH tunnel) without running a separate agent process.
## Run
@@ -28,7 +29,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `--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.
- `--deliver`: Deliver assistant replies to the provider (default off).
- `--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).
@@ -36,16 +37,29 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
## Controls
- Enter: send message
- Esc: abort active run
- Ctrl+C: exit
- Ctrl+C: clear input (press twice to exit)
- Ctrl+D: exit
- Ctrl+L: model picker
- Ctrl+P: session picker
- Ctrl+O: toggle tool output expansion
- Ctrl+T: toggle thinking visibility
## Slash commands
- `/help`
- `/session <key>`
- `/status`
- `/session <key>` (or `/sessions`)
- `/model <provider/model>` (or `/models`)
- `/think <off|minimal|low|medium|high>`
- `/verbose <on|off>`
- `/activation <mention|always>`
- `/deliver <on|off>`
- `/new` or `/reset`
- `/abort`
- `/settings`
- `/exit`
## Notes
- The TUI shows Gateway chat deltas (`event: chat`) and final responses.
- The TUI shows Gateway chat deltas (`event: chat`) and agent tool events.
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
## Files
+1
View File
@@ -86,6 +86,7 @@
"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 -1
View File
@@ -2,7 +2,7 @@ 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,71 @@ export function convertMessages(model, context) {
@@ -140,6 +140,72 @@ export function convertMessages(model, context) {
}
return contents;
}
+8 -5
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
'@mariozechner/pi-ai':
hash: 717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4
hash: 969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae
path: patches/@mariozechner__pi-ai.patch
'@mariozechner/pi-coding-agent@0.31.1':
hash: d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745
@@ -33,7 +33,7 @@ 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=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
version: 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(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)
@@ -70,6 +70,9 @@ 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
@@ -3350,7 +3353,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=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(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-tui': 0.31.1
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -3360,7 +3363,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(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)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.4)
'@google/genai': 1.34.0
@@ -3383,7 +3386,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=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(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-tui': 0.31.1
chalk: 5.6.2
cli-highlight: 2.1.11
+145 -17
View File
@@ -6,7 +6,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
APP_ROOT="$ROOT_DIR/dist/Clawdis.app"
BUILD_PATH="$ROOT_DIR/apps/macos/.build"
BUILD_ROOT="$ROOT_DIR/apps/macos/.build"
PRODUCT="Clawdis"
BUNDLE_ID="${BUNDLE_ID:-com.steipete.clawdis.debug}"
PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")"
@@ -16,6 +16,9 @@ GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || ec
APP_VERSION="${APP_VERSION:-$PKG_VERSION}"
APP_BUILD="${APP_BUILD:-$GIT_BUILD_NUMBER}"
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-arm64 x86_64}"
IFS=' ' read -r -a BUILD_ARCHS <<< "$BUILD_ARCHS_VALUE"
PRIMARY_ARCH="${BUILD_ARCHS[0]}"
SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}"
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml}"
AUTO_CHECKS=true
@@ -23,6 +26,104 @@ if [[ "$BUNDLE_ID" == *.debug ]]; then
SPARKLE_FEED_URL=""
AUTO_CHECKS=false
fi
if [[ "$AUTO_CHECKS" == "true" && ! "$APP_BUILD" =~ ^[0-9]+$ ]]; then
echo "ERROR: APP_BUILD must be numeric for Sparkle compare (CFBundleVersion). Got: $APP_BUILD" >&2
exit 1
fi
build_path_for_arch() {
echo "$BUILD_ROOT/$1"
}
bin_for_arch() {
echo "$(build_path_for_arch "$1")/$BUILD_CONFIG/$PRODUCT"
}
sparkle_framework_for_arch() {
echo "$(build_path_for_arch "$1")/$BUILD_CONFIG/Sparkle.framework"
}
merge_framework_machos() {
local primary="$1"
local dest="$2"
shift 2
local others=("$@")
archs_for() {
/usr/bin/lipo -info "$1" | /usr/bin/sed -E 's/.*are: //; s/.*architecture: //'
}
arch_in_list() {
local needle="$1"
shift
for item in "$@"; do
if [[ "$item" == "$needle" ]]; then
return 0
fi
done
return 1
}
while IFS= read -r -d '' file; do
if /usr/bin/file "$file" | /usr/bin/grep -q "Mach-O"; then
local rel="${file#$primary/}"
local primary_archs
primary_archs=$(archs_for "$file")
IFS=' ' read -r -a primary_arch_array <<< "$primary_archs"
local missing_files=()
local tmp_dir
tmp_dir=$(mktemp -d)
for fw in "${others[@]}"; do
local other_file="$fw/$rel"
if [[ ! -f "$other_file" ]]; then
echo "ERROR: Missing $rel in $fw" >&2
rm -rf "$tmp_dir"
exit 1
fi
if /usr/bin/file "$other_file" | /usr/bin/grep -q "Mach-O"; then
local other_archs
other_archs=$(archs_for "$other_file")
IFS=' ' read -r -a other_arch_array <<< "$other_archs"
for arch in "${other_arch_array[@]}"; do
if ! arch_in_list "$arch" "${primary_arch_array[@]}"; then
local thin_file="$tmp_dir/$(echo "$rel" | tr '/' '_')-$arch"
/usr/bin/lipo -thin "$arch" "$other_file" -output "$thin_file"
missing_files+=("$thin_file")
primary_arch_array+=("$arch")
fi
done
fi
done
if [[ "${#missing_files[@]}" -gt 0 ]]; then
/usr/bin/lipo -create "$file" "${missing_files[@]}" -output "$dest/$rel"
fi
rm -rf "$tmp_dir"
fi
done < <(find "$primary" -type f -print0)
}
build_relay_binary() {
local arch="$1"
local out="$2"
local define_arg="__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\""
local bun_bin="bun"
local -a cmd=("$bun_bin" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
if [[ "$arch" == "x86_64" ]]; then
if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
echo "ERROR: Rosetta is required to build the x86_64 relay. Install Rosetta and retry." >&2
exit 1
fi
local bun_x86="${BUN_X86_64_BIN:-$HOME/.bun-x64/bin/bun}"
if [[ -x "$bun_x86" ]]; then
cmd=("$bun_x86" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
fi
arch -x86_64 "${cmd[@]}"
else
"${cmd[@]}"
fi
}
echo "📦 Ensuring deps (pnpm install)"
(cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted)
@@ -42,11 +143,14 @@ fi
cd "$ROOT_DIR/apps/macos"
echo "🔨 Building $PRODUCT ($BUILD_CONFIG)"
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [${BUILD_ARCHS[*]}]"
for arch in "${BUILD_ARCHS[@]}"; do
BUILD_PATH="$(build_path_for_arch "$arch")"
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" --arch "$arch" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
done
BIN="$BUILD_PATH/$BUILD_CONFIG/$PRODUCT"
echo "pkg: binary $BIN" >&2
BIN_PRIMARY="$(bin_for_arch "$PRIMARY_ARCH")"
echo "pkg: binary $BIN_PRIMARY" >&2
echo "🧹 Cleaning old app bundle"
rm -rf "$APP_ROOT"
mkdir -p "$APP_ROOT/Contents/MacOS"
@@ -77,15 +181,32 @@ else
fi
echo "🚚 Copying binary"
cp "$BIN" "$APP_ROOT/Contents/MacOS/Clawdis"
cp "$BIN_PRIMARY" "$APP_ROOT/Contents/MacOS/Clawdis"
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
BIN_INPUTS=()
for arch in "${BUILD_ARCHS[@]}"; do
BIN_INPUTS+=("$(bin_for_arch "$arch")")
done
/usr/bin/lipo -create "${BIN_INPUTS[@]}" -output "$APP_ROOT/Contents/MacOS/Clawdis"
fi
chmod +x "$APP_ROOT/Contents/MacOS/Clawdis"
# SwiftPM outputs ad-hoc signed binaries; strip the signature before install_name_tool to avoid warnings.
/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/Clawdis" 2>/dev/null || true
SPARKLE_FRAMEWORK="$BUILD_PATH/$BUILD_CONFIG/Sparkle.framework"
if [ -d "$SPARKLE_FRAMEWORK" ]; then
SPARKLE_FRAMEWORK_PRIMARY="$(sparkle_framework_for_arch "$PRIMARY_ARCH")"
if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then
echo "✨ Embedding Sparkle.framework"
cp -R "$SPARKLE_FRAMEWORK" "$APP_ROOT/Contents/Frameworks/"
cp -R "$SPARKLE_FRAMEWORK_PRIMARY" "$APP_ROOT/Contents/Frameworks/"
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
OTHER_FRAMEWORKS=()
for arch in "${BUILD_ARCHS[@]}"; do
if [[ "$arch" == "$PRIMARY_ARCH" ]]; then
continue
fi
OTHER_FRAMEWORKS+=("$(sparkle_framework_for_arch "$arch")")
done
merge_framework_machos "$SPARKLE_FRAMEWORK_PRIMARY" "$APP_ROOT/Contents/Frameworks/Sparkle.framework" "${OTHER_FRAMEWORKS[@]}"
fi
chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework"
fi
@@ -106,14 +227,21 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
echo "🧰 Building bundled relay (bun --compile)"
mkdir -p "$RELAY_DIR"
RELAY_OUT="$RELAY_DIR/clawdis"
bun build "$ROOT_DIR/dist/macos/relay.js" \
--compile \
--bytecode \
--outfile "$RELAY_OUT" \
-e electron \
--define "__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\""
chmod +x "$RELAY_OUT"
RELAY_OUT="$RELAY_DIR/clawdis"
RELAY_BUILD_DIR="$RELAY_DIR/.relay-build"
rm -rf "$RELAY_BUILD_DIR"
mkdir -p "$RELAY_BUILD_DIR"
for arch in "${BUILD_ARCHS[@]}"; do
RELAY_ARCH_OUT="$RELAY_BUILD_DIR/clawdis-$arch"
build_relay_binary "$arch" "$RELAY_ARCH_OUT"
chmod +x "$RELAY_ARCH_OUT"
done
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
/usr/bin/lipo -create "$RELAY_BUILD_DIR"/clawdis-* -output "$RELAY_OUT"
else
cp "$RELAY_BUILD_DIR/clawdis-${BUILD_ARCHS[0]}" "$RELAY_OUT"
fi
rm -rf "$RELAY_BUILD_DIR"
echo "🎨 Copying gateway A2UI host assets"
rm -rf "$RELAY_DIR/a2ui"
+15 -5
View File
@@ -1,26 +1,36 @@
---
name: gog
description: Google Workspace CLI for Gmail, Calendar, Drive, and Contacts.
description: Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
homepage: https://gogcli.sh
metadata: {"clawdis":{"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}}
---
# gog
Use `gog` for Gmail/Calendar/Drive/Contacts. Requires OAuth setup.
Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
Setup (once)
- `gog auth credentials /path/to/client_secret.json`
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts`
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,sheets,docs`
- `gog auth list`
Common commands
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
- Gmail send: `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
- Calendar: `gog calendar events <calendarId> --from <rfc3339> --to <rfc3339>`
- Drive: `gog drive search "query" --max 10`
- Calendar: `gog calendar events <calendarId> --from <iso> --to <iso>`
- Drive search: `gog drive search "query" --max 10`
- Contacts: `gog contacts list --max 20`
- Sheets get: `gog sheets get <sheetId> "Tab!A1:D10" --json`
- Sheets update: `gog sheets update <sheetId> "Tab!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED`
- Sheets append: `gog sheets append <sheetId> "Tab!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS`
- Sheets clear: `gog sheets clear <sheetId> "Tab!A2:Z"`
- Sheets metadata: `gog sheets metadata <sheetId> --json`
- Docs export: `gog docs export <docId> --format txt --out /tmp/doc.txt`
- Docs cat: `gog docs cat <docId>`
Notes
- Set `GOG_ACCOUNT=you@gmail.com` to avoid repeating `--account`.
- For scripting, prefer `--json` plus `--no-input`.
- Sheets values can be passed via `--values-json` (recommended) or as inline rows.
- Docs supports export/cat/copy. In-place edits require a Docs API client (not in gog).
- Confirm before sending mail or creating events.
+4 -1
View File
@@ -197,7 +197,10 @@ export function createBashTool(
rows: 30,
});
} catch (error) {
if (ptyShell !== DEFAULT_SHELL_PATH && existsSync(DEFAULT_SHELL_PATH)) {
if (
ptyShell !== DEFAULT_SHELL_PATH &&
existsSync(DEFAULT_SHELL_PATH)
) {
try {
pty = ptyModule.spawn(
DEFAULT_SHELL_PATH,
+2 -2
View File
@@ -40,7 +40,7 @@ import {
writeScreenRecordToFile,
} from "../cli/nodes-screen.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { loadConfig } from "../config/config.js";
import { type DiscordActionConfig, loadConfig } from "../config/config.js";
import {
addRoleDiscord,
banMemberDiscord,
@@ -1745,7 +1745,7 @@ function createDiscordTool(): AnyAgentTool {
const action = readStringParam(params, "action", { required: true });
const cfg = loadConfig();
const isActionEnabled = (
key: keyof NonNullable<typeof cfg.discord>["actions"],
key: keyof DiscordActionConfig,
defaultValue = true,
) => {
const value = cfg.discord?.actions?.[key];
+63
View File
@@ -16,6 +16,36 @@ import {
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
const TOOL_RESULT_MAX_CHARS = 8000;
function truncateToolText(text: string): string {
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
}
function sanitizeToolResult(result: unknown): unknown {
if (!result || typeof result !== "object") return result;
const record = result as Record<string, unknown>;
const content = Array.isArray(record.content) ? record.content : null;
if (!content) return record;
const sanitized = content.map((item) => {
if (!item || typeof item !== "object") return item;
const entry = item as Record<string, unknown>;
const type = typeof entry.type === "string" ? entry.type : undefined;
if (type === "text" && typeof entry.text === "string") {
return { ...entry, text: truncateToolText(entry.text) };
}
if (type === "image") {
const data = typeof entry.data === "string" ? entry.data : undefined;
const bytes = data ? data.length : undefined;
const cleaned = { ...entry };
delete cleaned.data;
return { ...cleaned, bytes, omitted: true };
}
return entry;
});
return { ...record, content: sanitized };
}
function stripThinkingSegments(text: string): string {
if (!text || !THINKING_TAG_RE.test(text)) return text;
@@ -187,6 +217,36 @@ export function subscribeEmbeddedPiSession(params: {
});
}
if (evt.type === "tool_execution_update") {
const toolName = String(
(evt as AgentEvent & { toolName: string }).toolName,
);
const toolCallId = String(
(evt as AgentEvent & { toolCallId: string }).toolCallId,
);
const partial = (evt as AgentEvent & { partialResult?: unknown })
.partialResult;
const sanitized = sanitizeToolResult(partial);
emitAgentEvent({
runId: params.runId,
stream: "tool",
data: {
phase: "update",
name: toolName,
toolCallId,
partialResult: sanitized,
},
});
params.onAgentEvent?.({
stream: "tool",
data: {
phase: "update",
name: toolName,
toolCallId,
},
});
}
if (evt.type === "tool_execution_end") {
const toolName = String(
(evt as AgentEvent & { toolName: string }).toolName,
@@ -197,6 +257,8 @@ export function subscribeEmbeddedPiSession(params: {
const isError = Boolean(
(evt as AgentEvent & { isError: boolean }).isError,
);
const result = (evt as AgentEvent & { result?: unknown }).result;
const sanitizedResult = sanitizeToolResult(result);
const meta = toolMetaById.get(toolCallId);
toolMetas.push({ toolName, meta });
toolDebouncer.push(toolName, meta);
@@ -210,6 +272,7 @@ export function subscribeEmbeddedPiSession(params: {
toolCallId,
meta,
isError,
result: sanitizedResult,
},
});
params.onAgentEvent?.({
+5 -1
View File
@@ -116,7 +116,11 @@ describe("directive parsing", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{ Body: "/queue collect debounce:bogus cap:zero drop:maybe", From: "+1222", To: "+1222" },
{
Body: "/queue collect debounce:bogus cap:zero drop:maybe",
From: "+1222",
To: "+1222",
},
{},
{
agent: {
+3 -6
View File
@@ -42,9 +42,8 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
process.env.HOME = previousHome;
try {
await fs.rm(base, { recursive: true, force: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes("ENOTEMPTY")) throw err;
} catch {
// ignore cleanup failures in tests
}
}
}
@@ -105,9 +104,7 @@ describe("queue followups", () => {
cfg,
);
const secondText = Array.isArray(second)
? second[0]?.text
: second?.text;
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
expect(secondText).toBe("main");
await vi.runAllTimersAsync();
+5 -3
View File
@@ -450,6 +450,8 @@ 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({
@@ -479,7 +481,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). 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). ${groupParticipationNote} Address the specific sender noted in the message context.`,
);
});
});
@@ -512,7 +514,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). 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). ${groupParticipationNote} Address the specific sender noted in the message context.`,
);
});
});
@@ -545,7 +547,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). 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). ${groupParticipationNote} Address the specific sender noted in the message context.`,
);
});
});
+17 -12
View File
@@ -30,6 +30,7 @@ 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,
@@ -54,7 +55,6 @@ import {
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { normalizeE164 } from "../utils.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
import {
@@ -192,10 +192,18 @@ function normalizeQueueMode(raw?: string): QueueMode | undefined {
if (!raw) return undefined;
const cleaned = raw.trim().toLowerCase();
if (cleaned === "queue" || cleaned === "queued") return "steer";
if (cleaned === "interrupt" || cleaned === "interrupts" || cleaned === "abort")
if (
cleaned === "interrupt" ||
cleaned === "interrupts" ||
cleaned === "abort"
)
return "interrupt";
if (cleaned === "steer" || cleaned === "steering") return "steer";
if (cleaned === "followup" || cleaned === "follow-ups" || cleaned === "followups")
if (
cleaned === "followup" ||
cleaned === "follow-ups" ||
cleaned === "followups"
)
return "followup";
if (cleaned === "collect" || cleaned === "coalesce") return "collect";
if (
@@ -536,10 +544,7 @@ function buildSummaryPrompt(queue: FollowupQueueState): string | undefined {
return lines.join("\n");
}
function buildCollectPrompt(
items: FollowupRun[],
summary?: string,
): string {
function buildCollectPrompt(items: FollowupRun[], summary?: string): string {
const blocks: string[] = ["[Queued messages while agent was busy]"];
if (summary) {
blocks.push(summary);
@@ -706,7 +711,8 @@ function resolveQueueSettings(params: {
mode: resolvedMode,
debounceMs:
typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
cap: typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
cap:
typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
dropPolicy: dropRaw,
};
}
@@ -1954,9 +1960,7 @@ export async function getReplyFromConfig(
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.error?.(
`Followup agent failed before reply: ${message}`,
);
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
return;
}
@@ -2010,7 +2014,8 @@ export async function getReplyFromConfig(
...entry,
inputTokens: input,
outputTokens: output,
totalTokens: promptTokens > 0 ? promptTokens : usage.total ?? input,
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
+1 -1
View File
@@ -1 +1 @@
debc115fd2b264d3b3090e28b733551ca5f7532287d827c13fc579e5a7a95b9d
549aa1fff40f95b4a899940ad2cde0adc3991423136ba02c23bcdbd621765ad7
+8 -2
View File
@@ -22,8 +22,14 @@ export function registerTuiCli(program: Command) {
.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);
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,
+15 -11
View File
@@ -5,16 +5,20 @@
* On VPS/SSH/headless: Shows URL and prompts user to paste the callback URL manually.
*/
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { randomBytes, createHash } from "node:crypto";
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 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";
@@ -98,9 +102,7 @@ export function shouldUseManualOAuthFlow(): boolean {
*/
function generatePKCESync(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256")
.update(verifier)
.digest("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
@@ -196,7 +198,7 @@ async function exchangeCodeForTokens(
// Fetch user email
const email = await getUserEmail(data.access_token);
// Fetch project ID
const projectId = await fetchProjectId(data.access_token);
@@ -288,7 +290,7 @@ async function fetchProjectId(accessToken: string): Promise<string> {
return data.cloudaicompanionProject.id;
}
} catch {
continue;
// ignore failed endpoint, try next
}
}
@@ -370,7 +372,9 @@ export async function loginAntigravityManual(
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(
"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:");
+5 -10
View File
@@ -10,16 +10,7 @@ import {
spinner,
text,
} from "@clack/prompts";
import {
loginAnthropic,
type OAuthCredentials,
} from "@mariozechner/pi-ai";
import {
loginAntigravityVpsAware,
isRemoteEnvironment,
} from "./antigravity-oauth.js";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
import type { ClawdisConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDIS,
@@ -32,6 +23,10 @@ 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,
+5 -10
View File
@@ -9,16 +9,7 @@ import {
spinner,
text,
} from "@clack/prompts";
import {
loginAnthropic,
type OAuthCredentials,
} from "@mariozechner/pi-ai";
import {
loginAntigravityVpsAware,
isRemoteEnvironment,
} from "./antigravity-oauth.js";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
import type { ClawdisConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDIS,
@@ -31,6 +22,10 @@ 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,
+3 -1
View File
@@ -99,7 +99,9 @@ export async function runNonInteractiveOnboarding(
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
} else if (authChoice === "oauth" || authChoice === "antigravity") {
runtime.error(`${authChoice === "oauth" ? "OAuth" : "Antigravity"} requires interactive mode.`);
runtime.error(
`${authChoice === "oauth" ? "OAuth" : "Antigravity"} requires interactive mode.`,
);
runtime.exit(1);
return;
}
+6 -1
View File
@@ -1,5 +1,10 @@
export type OnboardMode = "local" | "remote";
export type AuthChoice = "oauth" | "antigravity" | "apiKey" | "minimax" | "skip";
export type AuthChoice =
| "oauth"
| "antigravity"
| "apiKey"
| "minimax"
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
+1 -1
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);
+1
View File
@@ -297,6 +297,7 @@ export const SessionsPatchParamsSchema = Type.Object(
key: NonEmptyString,
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
groupActivation: Type.Optional(
Type.Union([
Type.Literal("mention"),
+10
View File
@@ -4074,6 +4074,16 @@ describe("gateway server", () => {
expect(main2?.thinkingLevel).toBe("medium");
expect(main2?.verboseLevel).toBeUndefined();
piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const modelPatched = await rpcReq<{
ok: true;
entry: { modelOverride?: string; providerOverride?: string };
}>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" });
expect(modelPatched.ok).toBe(true);
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
ws,
"sessions.compact",
+158 -6
View File
@@ -20,7 +20,13 @@ import {
type ModelCatalogEntry,
resetModelCatalogCacheForTest,
} from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import {
buildAllowedModelSet,
buildModelAliasIndex,
modelKey,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../agents/model-selection.js";
import { installSkill } from "../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
@@ -77,6 +83,7 @@ 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,
@@ -174,10 +181,6 @@ import {
type HookMappingResolved,
resolveHookMappings,
} from "./hooks-mapping.js";
import {
startGmailWatcher,
stopGmailWatcher,
} from "../hooks/gmail-watcher.js";
ensureClawdisCliOnPath();
@@ -2961,6 +2964,74 @@ export async function startGatewayServer(
}
}
if ("model" in p) {
const raw = p.model;
if (raw === null) {
delete next.providerOverride;
delete next.modelOverride;
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid model: empty",
},
};
}
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: resolvedDefault.provider,
});
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: resolvedDefault.provider,
aliasIndex,
});
if (!resolved) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid model: ${trimmed}`,
},
};
}
const catalog = await loadGatewayModelCatalog();
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: resolvedDefault.provider,
});
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `model not allowed: ${key}`,
},
};
}
if (
resolved.ref.provider === resolvedDefault.provider &&
resolved.ref.model === resolvedDefault.model
) {
delete next.providerOverride;
delete next.modelOverride;
} else {
next.providerOverride = resolved.ref.provider;
next.modelOverride = resolved.ref.model;
}
}
}
if ("groupActivation" in p) {
const raw = p.groupActivation;
if (raw === null) {
@@ -5507,6 +5578,83 @@ export async function startGatewayServer(
}
}
if ("model" in p) {
const raw = p.model;
if (raw === null) {
delete next.providerOverride;
delete next.modelOverride;
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid model: empty",
),
);
break;
}
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: resolvedDefault.provider,
});
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: resolvedDefault.provider,
aliasIndex,
});
if (!resolved) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid model: ${trimmed}`,
),
);
break;
}
const catalog = await loadGatewayModelCatalog();
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: resolvedDefault.provider,
});
const key = modelKey(
resolved.ref.provider,
resolved.ref.model,
);
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`model not allowed: ${key}`,
),
);
break;
}
if (
resolved.ref.provider === resolvedDefault.provider &&
resolved.ref.model === resolvedDefault.model
) {
delete next.providerOverride;
delete next.modelOverride;
} else {
next.providerOverride = resolved.ref.provider;
next.modelOverride = resolved.ref.model;
}
}
}
if ("groupActivation" in p) {
const raw = p.groupActivation;
if (raw === null) {
@@ -6869,7 +7017,11 @@ export async function startGatewayServer(
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") {
} 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) {
+10 -6
View File
@@ -5,7 +5,7 @@
* if hooks.gmail is configured with an account.
*/
import { spawn, type ChildProcess } from "node:child_process";
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";
@@ -13,8 +13,8 @@ import { runCommandWithTimeout } from "../process/exec.js";
import {
buildGogWatchServeArgs,
buildGogWatchStartArgs,
resolveGmailHookRuntimeConfig,
type GmailHookRuntimeConfig,
resolveGmailHookRuntimeConfig,
} from "./gmail.js";
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
@@ -42,7 +42,8 @@ async function startGmailWatch(
try {
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
if (result.code !== 0) {
const message = result.stderr || result.stdout || "gog watch start failed";
const message =
result.stderr || result.stdout || "gog watch start failed";
log.error(`watch start failed: ${message}`);
return false;
}
@@ -60,7 +61,7 @@ async function startGmailWatch(
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,
@@ -142,7 +143,10 @@ export async function startGmailWatcher(
);
} catch (err) {
log.error(`tailscale setup failed: ${String(err)}`);
return { started: false, reason: `tailscale setup failed: ${String(err)}` };
return {
started: false,
reason: `tailscale setup failed: ${String(err)}`,
};
}
}
@@ -184,7 +188,7 @@ export async function stopGmailWatcher(): Promise<void> {
if (watcherProcess) {
log.info("stopping gmail watcher");
watcherProcess.kill("SIGTERM");
// Wait a bit for graceful shutdown
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
+106
View File
@@ -0,0 +1,106 @@
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"]);
});
});
+1
View File
@@ -217,6 +217,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
runtime,
bot,
replyToMode,
textLimit,
});
})
.catch((err) => {
+88
View File
@@ -0,0 +1,88 @@
import type { SlashCommand } from "@mariozechner/pi-tui";
const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"];
const VERBOSE_LEVELS = ["on", "off"];
const ACTIVATION_LEVELS = ["mention", "always"];
const TOGGLE = ["on", "off"];
export type ParsedCommand = {
name: string;
args: string;
};
export function parseCommand(input: string): ParsedCommand {
const trimmed = input.replace(/^\//, "").trim();
if (!trimmed) return { name: "", args: "" };
const [name, ...rest] = trimmed.split(/\s+/);
return { name: name.toLowerCase(), args: rest.join(" ").trim() };
}
export function getSlashCommands(): SlashCommand[] {
return [
{ name: "help", description: "Show slash command help" },
{ name: "status", description: "Show gateway status summary" },
{ name: "session", description: "Switch session (or open picker)" },
{ name: "sessions", description: "Open session picker" },
{
name: "model",
description: "Set model (or open picker)",
},
{ name: "models", description: "Open model picker" },
{
name: "think",
description: "Set thinking level",
getArgumentCompletions: (prefix) =>
THINK_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
(value) => ({ value, label: value }),
),
},
{
name: "verbose",
description: "Set verbose on/off",
getArgumentCompletions: (prefix) =>
VERBOSE_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
(value) => ({ value, label: value }),
),
},
{
name: "activation",
description: "Set group activation",
getArgumentCompletions: (prefix) =>
ACTIVATION_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
(value) => ({ value, label: value }),
),
},
{
name: "deliver",
description: "Toggle delivery of assistant replies",
getArgumentCompletions: (prefix) =>
TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map(
(value) => ({ value, label: value }),
),
},
{ name: "abort", description: "Abort active run" },
{ name: "new", description: "Reset the session" },
{ name: "reset", description: "Reset the session" },
{ name: "settings", description: "Open settings" },
{ name: "exit", description: "Exit the TUI" },
{ name: "quit", description: "Exit the TUI" },
];
}
export function helpText(): string {
return [
"Slash commands:",
"/help",
"/status",
"/session <key> (or /sessions)",
"/model <provider/model> (or /models)",
"/think <off|minimal|low|medium|high>",
"/verbose <on|off>",
"/activation <mention|always>",
"/deliver <on|off>",
"/new or /reset",
"/abort",
"/settings",
"/exit",
].join("\n");
}
+19
View File
@@ -0,0 +1,19 @@
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
import { markdownTheme, theme } from "../theme/theme.js";
export class AssistantMessageComponent extends Container {
private body: Markdown;
constructor(text: string) {
super();
this.body = new Markdown(text, 1, 0, markdownTheme, {
color: (line) => theme.fg(line),
});
this.addChild(new Spacer(1));
this.addChild(this.body);
}
setText(text: string) {
this.body.setText(text);
}
}
+108
View File
@@ -0,0 +1,108 @@
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js";
import { ToolExecutionComponent } from "./tool-execution.js";
import { UserMessageComponent } from "./user-message.js";
export class ChatLog extends Container {
private toolById = new Map<string, ToolExecutionComponent>();
private streamingAssistant: AssistantMessageComponent | null = null;
private streamingRunId: string | null = null;
private streamingText: string | null = null;
private toolsExpanded = false;
clearAll() {
this.clear();
this.toolById.clear();
this.streamingAssistant = null;
this.streamingRunId = null;
this.streamingText = null;
}
addSystem(text: string) {
this.addChild(new Spacer(1));
this.addChild(new Text(theme.system(text), 1, 0));
}
addUser(text: string) {
this.addChild(new UserMessageComponent(text));
}
startAssistant(text: string, runId?: string) {
const component = new AssistantMessageComponent(text);
this.streamingAssistant = component;
this.streamingRunId = runId ?? null;
this.streamingText = text;
this.addChild(component);
return component;
}
updateAssistant(text: string, runId?: string) {
if (
!this.streamingAssistant ||
(runId && this.streamingRunId && runId !== this.streamingRunId)
) {
this.startAssistant(text, runId);
return;
}
this.streamingText = text;
this.streamingAssistant.setText(text);
}
finalizeAssistant(text: string, runId?: string) {
if (
this.streamingAssistant &&
(!runId || runId === this.streamingRunId || text === this.streamingText)
) {
this.streamingText = text;
this.streamingAssistant.setText(text);
} else {
this.startAssistant(text, runId);
}
this.streamingAssistant = null;
this.streamingRunId = null;
this.streamingText = null;
}
startTool(toolCallId: string, toolName: string, args: unknown) {
const existing = this.toolById.get(toolCallId);
if (existing) {
existing.setArgs(args);
return existing;
}
const component = new ToolExecutionComponent(toolName, args);
component.setExpanded(this.toolsExpanded);
this.toolById.set(toolCallId, component);
this.addChild(component);
return component;
}
updateToolArgs(toolCallId: string, args: unknown) {
const existing = this.toolById.get(toolCallId);
if (!existing) return;
existing.setArgs(args);
}
updateToolResult(
toolCallId: string,
result: unknown,
opts?: { isError?: boolean; partial?: boolean },
) {
const existing = this.toolById.get(toolCallId);
if (!existing) return;
if (opts?.partial) {
existing.setPartialResult(result as Record<string, unknown>);
return;
}
existing.setResult(result as Record<string, unknown>, {
isError: opts?.isError,
});
}
setToolsExpanded(expanded: boolean) {
this.toolsExpanded = expanded;
for (const tool of this.toolById.values()) {
tool.setExpanded(expanded);
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import {
Editor,
isAltEnter,
isCtrlC,
isCtrlD,
isCtrlL,
isCtrlO,
isCtrlP,
isCtrlT,
isEscape,
isShiftTab,
} from "@mariozechner/pi-tui";
export class CustomEditor extends Editor {
onEscape?: () => void;
onCtrlC?: () => void;
onCtrlD?: () => void;
onCtrlL?: () => void;
onCtrlO?: () => void;
onCtrlP?: () => void;
onCtrlT?: () => void;
onShiftTab?: () => void;
onAltEnter?: () => void;
handleInput(data: string): void {
if (isAltEnter(data) && this.onAltEnter) {
this.onAltEnter();
return;
}
if (isCtrlL(data) && this.onCtrlL) {
this.onCtrlL();
return;
}
if (isCtrlO(data) && this.onCtrlO) {
this.onCtrlO();
return;
}
if (isCtrlP(data) && this.onCtrlP) {
this.onCtrlP();
return;
}
if (isCtrlT(data) && this.onCtrlT) {
this.onCtrlT();
return;
}
if (isShiftTab(data) && this.onShiftTab) {
this.onShiftTab();
return;
}
if (isEscape(data) && this.onEscape && !this.isShowingAutocomplete()) {
this.onEscape();
return;
}
if (isCtrlC(data) && this.onCtrlC) {
this.onCtrlC();
return;
}
if (isCtrlD(data)) {
if (this.getText().length === 0 && this.onCtrlD) {
this.onCtrlD();
}
return;
}
super.handleInput(data);
}
}
+26
View File
@@ -0,0 +1,26 @@
import {
type SelectItem,
SelectList,
type SettingItem,
SettingsList,
} from "@mariozechner/pi-tui";
import { selectListTheme, settingsListTheme } from "../theme/theme.js";
export function createSelectList(items: SelectItem[], maxVisible = 7) {
return new SelectList(items, maxVisible, selectListTheme);
}
export function createSettingsList(
items: SettingItem[],
onChange: (id: string, value: string) => void,
onCancel: () => void,
maxVisible = 7,
) {
return new SettingsList(
items,
maxVisible,
settingsListTheme,
onChange,
onCancel,
);
}
+130
View File
@@ -0,0 +1,130 @@
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { markdownTheme, theme } from "../theme/theme.js";
type ToolResultContent = {
type?: string;
text?: string;
mimeType?: string;
bytes?: number;
omitted?: boolean;
};
type ToolResult = {
content?: ToolResultContent[];
details?: Record<string, unknown>;
};
const PREVIEW_LINES = 12;
function formatArgs(toolName: string, args: unknown): string {
if (!args || typeof args !== "object") return "";
const record = args as Record<string, unknown>;
if (toolName === "bash" && typeof record.command === "string") {
return record.command;
}
const path = typeof record.path === "string" ? record.path : undefined;
if (path) return path;
try {
return JSON.stringify(args);
} catch {
return "";
}
}
function extractText(result?: ToolResult): string {
if (!result?.content) return "";
const lines: string[] = [];
for (const entry of result.content) {
if (entry.type === "text" && entry.text) {
lines.push(entry.text);
} else if (entry.type === "image") {
const mime = entry.mimeType ?? "image";
const size = entry.bytes ? ` ${Math.round(entry.bytes / 1024)}kb` : "";
const omitted = entry.omitted ? " (omitted)" : "";
lines.push(`[${mime}${size}${omitted}]`);
}
}
return lines.join("\n").trim();
}
export class ToolExecutionComponent extends Container {
private box: Box;
private header: Text;
private argsLine: Text;
private output: Markdown;
private toolName: string;
private args: unknown;
private result?: ToolResult;
private expanded = false;
private isError = false;
private isPartial = true;
constructor(toolName: string, args: unknown) {
super();
this.toolName = toolName;
this.args = args;
this.box = new Box(1, 1, (line) => theme.toolPendingBg(line));
this.header = new Text("", 0, 0);
this.argsLine = new Text("", 0, 0);
this.output = new Markdown("", 0, 0, markdownTheme, {
color: (line) => theme.toolOutput(line),
});
this.addChild(new Spacer(1));
this.addChild(this.box);
this.box.addChild(this.header);
this.box.addChild(this.argsLine);
this.box.addChild(this.output);
this.refresh();
}
setArgs(args: unknown) {
this.args = args;
this.refresh();
}
setExpanded(expanded: boolean) {
this.expanded = expanded;
this.refresh();
}
setResult(result: ToolResult | undefined, opts?: { isError?: boolean }) {
this.result = result;
this.isPartial = false;
this.isError = Boolean(opts?.isError);
this.refresh();
}
setPartialResult(result: ToolResult | undefined) {
this.result = result;
this.isPartial = true;
this.refresh();
}
private refresh() {
const bg = this.isPartial
? theme.toolPendingBg
: this.isError
? theme.toolErrorBg
: theme.toolSuccessBg;
this.box.setBgFn((line) => bg(line));
const title = `${this.toolName}${this.isPartial ? " (running)" : ""}`;
this.header.setText(theme.toolTitle(theme.bold(title)));
const argLine = formatArgs(this.toolName, this.args);
this.argsLine.setText(argLine ? theme.dim(argLine) : theme.dim(" "));
const raw = extractText(this.result);
const text = raw || (this.isPartial ? "…" : "");
if (!this.expanded && text) {
const lines = text.split("\n");
const preview =
lines.length > PREVIEW_LINES
? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n…`
: text;
this.output.setText(preview);
} else {
this.output.setText(text);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
import { markdownTheme, theme } from "../theme/theme.js";
export class UserMessageComponent extends Container {
private body: Markdown;
constructor(text: string) {
super();
this.body = new Markdown(text, 1, 1, markdownTheme, {
bgColor: (line) => theme.userBg(line),
color: (line) => theme.userText(line),
});
this.addChild(new Spacer(1));
this.addChild(this.body);
}
setText(text: string) {
this.body.setText(text);
}
}
+64 -7
View File
@@ -1,7 +1,12 @@
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 {
type HelloOk,
PROTOCOL_VERSION,
type SessionsListParams,
type SessionsPatchParams,
} from "../gateway/protocol/index.js";
import { VERSION } from "../version.js";
export type GatewayConnectionOptions = {
@@ -21,6 +26,32 @@ export type ChatSendOptions = {
export type GatewayEvent = {
event: string;
payload?: unknown;
seq?: number;
};
export type GatewaySessionList = {
ts: number;
path: string;
count: number;
defaults?: { model?: string | null; contextTokens?: number | null };
sessions: Array<{
key: string;
sessionId?: string;
updatedAt?: number | null;
thinkingLevel?: string;
verboseLevel?: string;
model?: string;
contextTokens?: number | null;
totalTokens?: number | null;
displayName?: string;
}>;
};
export type GatewayModelChoice = {
id: string;
name: string;
provider: string;
contextWindow?: number;
};
export class GatewayChatClient {
@@ -28,6 +59,7 @@ export class GatewayChatClient {
private readyPromise: Promise<void>;
private resolveReady?: () => void;
readonly connection: { url: string; token?: string; password?: string };
hello?: HelloOk;
onEvent?: (evt: GatewayEvent) => void;
onConnected?: () => void;
@@ -53,12 +85,17 @@ export class GatewayChatClient {
instanceId: randomUUID(),
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
onHelloOk: () => {
onHelloOk: (hello) => {
this.hello = hello;
this.resolveReady?.();
this.onConnected?.();
},
onEvent: (evt) => {
this.onEvent?.({ event: evt.event, payload: evt.payload });
this.onEvent?.({
event: evt.event,
payload: evt.payload,
seq: evt.seq,
});
},
onClose: (_code, reason) => {
this.onDisconnected?.(reason);
@@ -81,8 +118,7 @@ export class GatewayChatClient {
await this.readyPromise;
}
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }>
{
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> {
const runId = randomUUID();
await this.client.request("chat.send", {
sessionKey: opts.sessionKey,
@@ -112,12 +148,33 @@ export class GatewayChatClient {
});
}
async listSessions(opts?: { limit?: number; activeMinutes?: number }) {
return await this.client.request("sessions.list", {
async listSessions(opts?: SessionsListParams) {
return await this.client.request<GatewaySessionList>("sessions.list", {
limit: opts?.limit,
activeMinutes: opts?.activeMinutes,
includeGlobal: opts?.includeGlobal,
includeUnknown: opts?.includeUnknown,
});
}
async patchSession(opts: SessionsPatchParams) {
return await this.client.request("sessions.patch", opts);
}
async resetSession(key: string) {
return await this.client.request("sessions.reset", { key });
}
async getStatus() {
return await this.client.request("status");
}
async listModels(): Promise<GatewayModelChoice[]> {
const res = await this.client.request<{ models?: GatewayModelChoice[] }>(
"models.list",
);
return Array.isArray(res?.models) ? res.models : [];
}
}
export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
-40
View File
@@ -1,40 +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);
}
}
-81
View File
@@ -1,81 +0,0 @@
import crypto from "node:crypto";
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import type { MarkdownTheme, DefaultTextStyle } from "@mariozechner/pi-tui";
import { theme } from "./theme.js";
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: MessageEntry["role"],
text: string,
style: DefaultTextStyle,
) {
const messageId = crypto.randomUUID();
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 chalk from "chalk";
import type { MarkdownTheme } from "@mariozechner/pi-tui";
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),
};
+95
View File
@@ -0,0 +1,95 @@
import type {
EditorTheme,
MarkdownTheme,
SelectListTheme,
SettingsListTheme,
} from "@mariozechner/pi-tui";
import chalk from "chalk";
const palette = {
text: "#E8E3D5",
dim: "#7B7F87",
accent: "#F6C453",
accentSoft: "#F2A65A",
border: "#3C414B",
userBg: "#2B2F36",
userText: "#F3EEE0",
systemText: "#9BA3B2",
toolPendingBg: "#1F2A2F",
toolSuccessBg: "#1E2D23",
toolErrorBg: "#2F1F1F",
toolTitle: "#F6C453",
toolOutput: "#E1DACB",
quote: "#8CC8FF",
quoteBorder: "#3B4D6B",
code: "#F0C987",
codeBlock: "#1E232A",
codeBorder: "#343A45",
link: "#7DD3A5",
error: "#F97066",
success: "#7DD3A5",
};
const fg = (hex: string) => (text: string) => chalk.hex(hex)(text);
const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text);
export const theme = {
fg: fg(palette.text),
dim: fg(palette.dim),
accent: fg(palette.accent),
accentSoft: fg(palette.accentSoft),
success: fg(palette.success),
error: fg(palette.error),
header: (text: string) => chalk.bold(fg(palette.accent)(text)),
system: fg(palette.systemText),
userBg: bg(palette.userBg),
userText: fg(palette.userText),
toolTitle: fg(palette.toolTitle),
toolOutput: fg(palette.toolOutput),
toolPendingBg: bg(palette.toolPendingBg),
toolSuccessBg: bg(palette.toolSuccessBg),
toolErrorBg: bg(palette.toolErrorBg),
border: fg(palette.border),
bold: (text: string) => chalk.bold(text),
italic: (text: string) => chalk.italic(text),
};
export const markdownTheme: MarkdownTheme = {
heading: (text) => chalk.bold(fg(palette.accent)(text)),
link: (text) => fg(palette.link)(text),
linkUrl: (text) => chalk.dim(text),
code: (text) => fg(palette.code)(text),
codeBlock: (text) => fg(palette.code)(text),
codeBlockBorder: (text) => fg(palette.codeBorder)(text),
quote: (text) => fg(palette.quote)(text),
quoteBorder: (text) => fg(palette.quoteBorder)(text),
hr: (text) => fg(palette.border)(text),
listBullet: (text) => fg(palette.accentSoft)(text),
bold: (text) => chalk.bold(text),
italic: (text) => chalk.italic(text),
strikethrough: (text) => chalk.strikethrough(text),
underline: (text) => chalk.underline(text),
};
export const selectListTheme: SelectListTheme = {
selectedPrefix: (text) => fg(palette.accent)(text),
selectedText: (text) => chalk.bold(fg(palette.accent)(text)),
description: (text) => fg(palette.dim)(text),
scrollInfo: (text) => fg(palette.dim)(text),
noMatch: (text) => fg(palette.dim)(text),
};
export const settingsListTheme: SettingsListTheme = {
label: (text, selected) =>
selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text),
value: (text, selected) =>
selected ? fg(palette.accentSoft)(text) : fg(palette.dim)(text),
description: (text) => fg(palette.systemText)(text),
cursor: fg(palette.accent)("→ "),
hint: (text) => fg(palette.dim)(text),
};
export const editorTheme: EditorTheme = {
borderColor: (text) => fg(palette.border)(text),
selectList: selectListTheme,
};
+602 -171
View File
@@ -1,17 +1,21 @@
import {
CombinedAutocompleteProvider,
type Component,
Input,
Container,
ProcessTerminal,
Text,
TUI,
isCtrlC,
isEscape,
} from "@mariozechner/pi-tui";
import { loadConfig } from "../config/config.js";
import { getSlashCommands, helpText, parseCommand } from "./commands.js";
import { ChatLog } from "./components/chat-log.js";
import { CustomEditor } from "./components/custom-editor.js";
import {
createSelectList,
createSettingsList,
} from "./components/selectors.js";
import { GatewayChatClient } from "./gateway-chat.js";
import { ChatLayout } from "./layout.js";
import { MessageList } from "./message-list.js";
import { markdownTheme, theme } from "./theme.js";
import { editorTheme, theme } from "./theme/theme.js";
export type TuiOptions = {
url?: string;
@@ -32,105 +36,88 @@ type ChatEvent = {
errorMessage?: string;
};
class InputWrapper implements Component {
constructor(
private input: Input,
private onAbort: () => void,
private onExit: () => void,
) {
}
type AgentEvent = {
runId: string;
stream: string;
data?: Record<string, unknown>;
};
handleInput(data: string): void {
if (isCtrlC(data)) {
this.onExit();
return;
type SessionInfo = {
thinkingLevel?: string;
verboseLevel?: string;
model?: string;
contextTokens?: number | null;
totalTokens?: number | null;
updatedAt?: number | null;
displayName?: string;
};
function extractTextBlocks(
content: unknown,
opts?: { includeThinking?: boolean },
): string {
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const record = block as Record<string, unknown>;
if (record.type === "text" && typeof record.text === "string") {
parts.push(record.text);
}
if (isEscape(data)) {
this.onAbort();
return;
if (
opts?.includeThinking &&
record.type === "thinking" &&
typeof record.thinking === "string"
) {
parts.push(`[thinking]\n${record.thinking}`);
}
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 };
function extractTextFromMessage(
message: unknown,
opts?: { includeThinking?: boolean },
): string {
if (!message || typeof message !== "object") return "";
const record = message as Record<string, unknown>;
return extractTextBlocks(record.content, opts);
}
function formatTokens(total?: number | null, context?: number | null) {
if (!total && !context) return "tokens ?";
if (!context) return `tokens ${total ?? 0}`;
const pct =
typeof total === "number" && context > 0
? Math.min(999, Math.round((total / context) * 100))
: null;
return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`;
}
function asString(value: unknown, fallback = ""): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return fallback;
}
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 currentSessionKey = defaultSession;
let currentSessionId: string | null = null;
let activeChatRunId: string | null = null;
const finalizedRuns = new Map<string, number>();
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);
let isConnected = false;
let toolsExpanded = false;
let showThinking = false;
let deliverDefault = Boolean(opts.deliver);
let sessionInfo: SessionInfo = {};
let lastCtrlCAt = 0;
const client = new GatewayChatClient({
url: opts.url,
@@ -138,10 +125,28 @@ export async function runTui(opts: TuiOptions) {
password: opts.password,
});
const header = new Text("", 1, 0);
const status = new Text("", 1, 0);
const footer = new Text("", 1, 0);
const chatLog = new ChatLog();
const editor = new CustomEditor(editorTheme);
const overlay = new Container();
const root = new Container();
root.addChild(header);
root.addChild(overlay);
root.addChild(chatLog);
root.addChild(status);
root.addChild(footer);
root.addChild(editor);
const tui = new TUI(new ProcessTerminal());
tui.addChild(root);
tui.setFocus(editor);
const updateHeader = () => {
header.setText(
theme.header(
`clawdis tui - ${client.connection.url} - session ${currentSession}`,
`clawdis tui - ${client.connection.url} - session ${currentSessionKey}`,
),
);
};
@@ -150,112 +155,486 @@ export async function runTui(opts: TuiOptions) {
status.setText(theme.dim(text));
};
const updateFooter = () => {
const connection = isConnected ? "connected" : "disconnected";
const sessionLabel = sessionInfo.displayName
? `${currentSessionKey} (${sessionInfo.displayName})`
: currentSessionKey;
const modelLabel = sessionInfo.model ?? "unknown";
const tokens = formatTokens(
sessionInfo.totalTokens ?? null,
sessionInfo.contextTokens ?? null,
);
const think = sessionInfo.thinkingLevel ?? "off";
const verbose = sessionInfo.verboseLevel ?? "off";
const deliver = deliverDefault ? "on" : "off";
footer.setText(
theme.dim(
`${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | ${tokens} | deliver ${deliver}`,
),
);
};
const closeOverlay = () => {
overlay.clear();
tui.setFocus(editor);
};
const openOverlay = (component: Component) => {
overlay.clear();
overlay.addChild(component);
tui.setFocus(component);
};
const refreshSessionInfo = async () => {
try {
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
});
const entry = result.sessions.find(
(row) => row.key === currentSessionKey,
);
sessionInfo = {
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
model: entry?.model ?? result.defaults?.model ?? undefined,
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
totalTokens: entry?.totalTokens ?? null,
updatedAt: entry?.updatedAt ?? null,
displayName: entry?.displayName,
};
} catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`);
}
updateFooter();
tui.requestRender();
};
const loadHistory = async () => {
try {
const history = await client.loadHistory({
sessionKey: currentSession,
sessionKey: currentSessionKey,
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);
const record = history as {
messages?: unknown[];
sessionId?: string;
thinkingLevel?: string;
};
currentSessionId =
typeof record.sessionId === "string" ? record.sessionId : null;
sessionInfo.thinkingLevel =
record.thinkingLevel ?? sessionInfo.thinkingLevel;
chatLog.clearAll();
chatLog.addSystem(`session ${currentSessionKey}`);
for (const entry of record.messages ?? []) {
if (!entry || typeof entry !== "object") continue;
const message = entry as Record<string, unknown>;
if (message.role === "user") {
const text = extractTextFromMessage(message);
if (text) chatLog.addUser(text);
continue;
}
if (message.role === "assistant") {
const text = extractTextFromMessage(message, {
includeThinking: showThinking,
});
if (text) chatLog.finalizeAssistant(text);
continue;
}
if (message.role === "toolResult") {
const toolCallId = asString(message.toolCallId, "");
const toolName = asString(message.toolName, "tool");
const component = chatLog.startTool(toolCallId, toolName, {});
component.setResult(
{
content: Array.isArray(message.content)
? (message.content as Record<string, unknown>[])
: [],
details:
typeof message.details === "object" && message.details
? (message.details as Record<string, unknown>)
: undefined,
},
{ isError: Boolean(message.isError) },
);
}
}
historyLoaded = true;
tui.requestRender();
} catch (err) {
messages.addSystem(`history failed: ${String(err)}`);
chatLog.addSystem(`history failed: ${String(err)}`);
}
await refreshSessionInfo();
tui.requestRender();
};
const setSession = async (key: string) => {
currentSessionKey = key;
activeChatRunId = null;
currentSessionId = null;
historyLoaded = false;
updateHeader();
await loadHistory();
};
const abortActive = async () => {
if (!activeChatRunId) {
chatLog.addSystem("no active run");
tui.requestRender();
return;
}
try {
await client.abortChat({
sessionKey: currentSessionKey,
runId: activeChatRunId,
});
setStatus("aborted");
} catch (err) {
chatLog.addSystem(`abort failed: ${String(err)}`);
setStatus("abort failed");
}
tui.requestRender();
};
const noteFinalizedRun = (runId: string) => {
finalizedRuns.set(runId, Date.now());
if (finalizedRuns.size <= 200) return;
const keepUntil = Date.now() - 10 * 60 * 1000;
for (const [key, ts] of finalizedRuns) {
if (finalizedRuns.size <= 150) break;
if (ts < keepUntil) finalizedRuns.delete(key);
}
if (finalizedRuns.size > 200) {
for (const key of finalizedRuns.keys()) {
finalizedRuns.delete(key);
if (finalizedRuns.size <= 150) break;
}
}
};
const handleChatEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as ChatEvent;
if (evt.sessionKey !== currentSession) return;
if (evt.sessionKey !== currentSessionKey) return;
if (finalizedRuns.has(evt.runId)) {
if (evt.state === "delta") return;
if (evt.state === "final") return;
}
if (evt.state === "delta") {
const text = extractText(evt.message);
const text = extractTextFromMessage(evt.message, {
includeThinking: showThinking,
});
if (!text) return;
if (!streamingMessageId || activeRunId !== evt.runId) {
streamingMessageId = messages.addAssistant(text, evt.runId);
activeRunId = evt.runId;
} else {
messages.updateAssistant(streamingMessageId, text);
}
chatLog.updateAssistant(text, evt.runId);
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;
const text = extractTextFromMessage(evt.message, {
includeThinking: showThinking,
});
chatLog.finalizeAssistant(text || "(no output)", evt.runId);
noteFinalizedRun(evt.runId);
activeChatRunId = null;
setStatus("idle");
}
if (evt.state === "aborted") {
messages.addSystem("run aborted");
activeRunId = null;
streamingMessageId = null;
chatLog.addSystem("run aborted");
activeChatRunId = null;
setStatus("aborted");
}
if (evt.state === "error") {
messages.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
activeRunId = null;
streamingMessageId = null;
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
activeChatRunId = null;
setStatus("error");
}
tui.requestRender();
};
const handleAgentEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as AgentEvent;
if (!currentSessionId || evt.runId !== currentSessionId) return;
if (evt.stream === "tool") {
const data = evt.data ?? {};
const phase = asString(data.phase, "");
const toolCallId = asString(data.toolCallId, "");
const toolName = asString(data.name, "tool");
if (!toolCallId) return;
if (phase === "start") {
chatLog.startTool(toolCallId, toolName, data.args);
} else if (phase === "update") {
chatLog.updateToolResult(toolCallId, data.partialResult, {
partial: true,
});
} else if (phase === "result") {
chatLog.updateToolResult(toolCallId, data.result, {
isError: Boolean(data.isError),
});
}
tui.requestRender();
return;
}
if (evt.stream === "job") {
const state = typeof evt.data?.state === "string" ? evt.data.state : "";
if (state === "started") setStatus("running");
if (state === "done") setStatus("idle");
if (state === "error") setStatus("error");
tui.requestRender();
}
};
const openModelSelector = async () => {
try {
const models = await client.listModels();
if (models.length === 0) {
chatLog.addSystem("no models available");
tui.requestRender();
return;
}
const items = models.map((model) => ({
value: `${model.provider}/${model.id}`,
label: `${model.provider}/${model.id}`,
description: model.name && model.name !== model.id ? model.name : "",
}));
const selector = createSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
try {
await client.patchSession({
key: currentSessionKey,
model: item.value,
});
chatLog.addSystem(`model set to ${item.value}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`);
}
closeOverlay();
tui.requestRender();
})();
};
selector.onCancel = () => {
closeOverlay();
tui.requestRender();
};
openOverlay(selector);
tui.requestRender();
} catch (err) {
chatLog.addSystem(`model list failed: ${String(err)}`);
tui.requestRender();
}
};
const openSessionSelector = async () => {
try {
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
});
const items = result.sessions.map((session) => ({
value: session.key,
label: session.displayName ?? session.key,
description: session.updatedAt
? new Date(session.updatedAt).toLocaleString()
: "",
}));
const selector = createSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
closeOverlay();
await setSession(item.value);
tui.requestRender();
})();
};
selector.onCancel = () => {
closeOverlay();
tui.requestRender();
};
openOverlay(selector);
tui.requestRender();
} catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`);
tui.requestRender();
}
};
const openSettings = () => {
const items = [
{
id: "deliver",
label: "Deliver replies",
currentValue: deliverDefault ? "on" : "off",
values: ["off", "on"],
},
{
id: "tools",
label: "Tool output",
currentValue: toolsExpanded ? "expanded" : "collapsed",
values: ["collapsed", "expanded"],
},
{
id: "thinking",
label: "Show thinking",
currentValue: showThinking ? "on" : "off",
values: ["off", "on"],
},
];
const settings = createSettingsList(
items,
(id, value) => {
if (id === "deliver") {
deliverDefault = value === "on";
updateFooter();
}
if (id === "tools") {
toolsExpanded = value === "expanded";
chatLog.setToolsExpanded(toolsExpanded);
}
if (id === "thinking") {
showThinking = value === "on";
void loadHistory();
}
tui.requestRender();
},
() => {
closeOverlay();
tui.requestRender();
},
);
openOverlay(settings);
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");
const { name, args } = parseCommand(raw);
if (!name) return;
switch (name) {
case "help":
chatLog.addSystem(helpText());
break;
}
case "session": {
if (!arg) {
messages.addSystem("missing session key");
case "status":
try {
const status = await client.getStatus();
chatLog.addSystem(
typeof status === "string"
? status
: JSON.stringify(status, null, 2),
);
} catch (err) {
chatLog.addSystem(`status failed: ${String(err)}`);
}
break;
case "session":
if (!args) {
await openSessionSelector();
} else {
await setSession(args);
}
break;
case "sessions":
await openSessionSelector();
break;
case "model":
if (!args) {
await openModelSelector();
} else {
try {
await client.patchSession({
key: currentSessionKey,
model: args,
});
chatLog.addSystem(`model set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`);
}
}
break;
case "models":
await openModelSelector();
break;
case "think":
if (!args) {
chatLog.addSystem("usage: /think <off|minimal|low|medium|high>");
break;
}
currentSession = arg;
activeRunId = null;
streamingMessageId = null;
historyLoaded = false;
updateHeader();
await loadHistory();
try {
await client.patchSession({
key: currentSessionKey,
thinkingLevel: args,
});
chatLog.addSystem(`thinking set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`think failed: ${String(err)}`);
}
break;
}
case "abort": {
if (!activeRunId) {
messages.addSystem("no active run");
case "verbose":
if (!args) {
chatLog.addSystem("usage: /verbose <on|off>");
break;
}
await client.abortChat({ sessionKey: currentSession, runId: activeRunId });
try {
await client.patchSession({
key: currentSessionKey,
verboseLevel: args,
});
chatLog.addSystem(`verbose set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`verbose failed: ${String(err)}`);
}
break;
case "activation":
if (!args) {
chatLog.addSystem("usage: /activation <mention|always>");
break;
}
try {
await client.patchSession({
key: currentSessionKey,
groupActivation: args === "always" ? "always" : "mention",
});
chatLog.addSystem(`activation set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`activation failed: ${String(err)}`);
}
break;
case "deliver":
if (!args) {
chatLog.addSystem("usage: /deliver <on|off>");
break;
}
deliverDefault = args === "on";
updateFooter();
chatLog.addSystem(`deliver ${deliverDefault ? "on" : "off"}`);
break;
case "new":
case "reset":
try {
await client.resetSession(currentSessionKey);
chatLog.addSystem(`session ${currentSessionKey} reset`);
await loadHistory();
} catch (err) {
chatLog.addSystem(`reset failed: ${String(err)}`);
}
break;
case "abort":
await abortActive();
break;
case "settings":
openSettings();
break;
}
case "exit":
case "quit": {
case "quit":
client.stop();
tui.stop();
process.exit(0);
}
break;
default:
messages.addSystem(`unknown command: /${command}`);
chatLog.addSystem(`unknown command: /${name}`);
break;
}
tui.requestRender();
@@ -263,69 +642,121 @@ export async function runTui(opts: TuiOptions) {
const sendMessage = async (text: string) => {
try {
messages.addUser(text);
chatLog.addUser(text);
tui.requestRender();
setStatus("sending");
const { runId } = await client.sendChat({
sessionKey: currentSession,
sessionKey: currentSessionKey,
message: text,
thinking: opts.thinking,
deliver: opts.deliver,
deliver: deliverDefault,
timeoutMs: opts.timeoutMs,
});
activeRunId = runId;
streamingMessageId = null;
activeChatRunId = runId;
setStatus("waiting");
} catch (err) {
messages.addSystem(`send failed: ${String(err)}`);
chatLog.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);
editor.setAutocompleteProvider(
new CombinedAutocompleteProvider(getSlashCommands(), process.cwd()),
);
editor.onSubmit = (text) => {
const value = text.trim();
editor.setText("");
if (!value) return;
if (value.startsWith("/")) {
void handleCommand(value);
return;
}
void sendMessage(text);
void sendMessage(value);
};
editor.onEscape = () => {
void abortActive();
};
editor.onCtrlC = () => {
const now = Date.now();
if (editor.getText().trim().length > 0) {
editor.setText("");
setStatus("cleared input");
tui.requestRender();
return;
}
if (now - lastCtrlCAt < 1000) {
client.stop();
tui.stop();
process.exit(0);
}
lastCtrlCAt = now;
setStatus("press ctrl+c again to exit");
tui.requestRender();
};
editor.onCtrlD = () => {
client.stop();
tui.stop();
process.exit(0);
};
editor.onCtrlO = () => {
toolsExpanded = !toolsExpanded;
chatLog.setToolsExpanded(toolsExpanded);
setStatus(toolsExpanded ? "tools expanded" : "tools collapsed");
tui.requestRender();
};
editor.onCtrlL = () => {
void openModelSelector();
};
editor.onCtrlP = () => {
void openSessionSelector();
};
editor.onCtrlT = () => {
showThinking = !showThinking;
void loadHistory();
};
client.onEvent = (evt) => {
if (evt.event === "chat") handleChatEvent(evt.payload);
if (evt.event === "agent") handleAgentEvent(evt.payload);
};
client.onConnected = () => {
isConnected = true;
setStatus("connected");
updateHeader();
if (!historyLoaded) {
void loadHistory().then(() => {
messages.addSystem("gateway connected");
chatLog.addSystem("gateway connected");
tui.requestRender();
});
} else {
messages.addSystem("gateway reconnected");
chatLog.addSystem("gateway reconnected");
}
updateFooter();
tui.requestRender();
};
client.onDisconnected = (reason) => {
messages.addSystem(`gateway disconnected: ${reason || "closed"}`);
isConnected = false;
chatLog.addSystem(`gateway disconnected: ${reason || "closed"}`);
setStatus("disconnected");
updateFooter();
tui.requestRender();
};
client.onGap = (info) => {
messages.addSystem(`event gap: expected ${info.expected}, got ${info.received}`);
chatLog.addSystem(
`event gap: expected ${info.expected}, got ${info.received}`,
);
tui.requestRender();
};
updateHeader();
setStatus("connecting");
messages.addSystem("connecting...");
updateFooter();
chatLog.addSystem("connecting...");
tui.start();
client.start();
}
+2
View File
@@ -8,6 +8,8 @@ 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) =>
+4
View File
@@ -210,6 +210,10 @@
gap: 6px;
}
.field.full {
grid-column: 1 / -1;
}
.field span {
color: var(--muted);
font-size: 11px;
+24 -8
View File
@@ -34,7 +34,12 @@ import {
type SignalForm,
type TelegramForm,
} from "./ui-types";
import { loadChatHistory, sendChat, handleChatEvent } from "./controllers/chat";
import {
loadChatHistory,
sendChat,
handleChatEvent,
type ChatEventPayload,
} from "./controllers/chat";
import { loadNodes } from "./controllers/nodes";
import { loadConfig } from "./controllers/config";
import {
@@ -139,11 +144,15 @@ export class ClawdisApp extends LitElement {
@state() discordForm: DiscordForm = {
enabled: true,
token: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
historyLimit: "",
textChunkLimit: "",
replyToMode: "off",
guilds: [],
actions: { ...defaultDiscordActions },
slashEnabled: false,
slashName: "",
@@ -350,7 +359,8 @@ export class ClawdisApp extends LitElement {
].slice(0, 250);
if (evt.event === "chat") {
const state = handleChatEvent(this, evt.payload as unknown);
const payload = evt.payload as ChatEventPayload | undefined;
const state = handleChatEvent(this, payload);
if (state === "final") void loadChatHistory(this);
return;
}
@@ -469,20 +479,26 @@ export class ClawdisApp extends LitElement {
if (this.theme !== "system") return;
this.applyResolvedTheme(event.matches ? "dark" : "light");
};
if ("addEventListener" in this.themeMedia) {
if (typeof this.themeMedia.addEventListener === "function") {
this.themeMedia.addEventListener("change", this.themeMediaHandler);
} else {
this.themeMedia.addListener(this.themeMediaHandler);
return;
}
const legacy = this.themeMedia as MediaQueryList & {
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
};
legacy.addListener(this.themeMediaHandler);
}
private detachThemeListener() {
if (!this.themeMedia || !this.themeMediaHandler) return;
if ("removeEventListener" in this.themeMedia) {
if (typeof this.themeMedia.removeEventListener === "function") {
this.themeMedia.removeEventListener("change", this.themeMediaHandler);
} else {
this.themeMedia.removeListener(this.themeMediaHandler);
return;
}
const legacy = this.themeMedia as MediaQueryList & {
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
};
legacy.removeListener(this.themeMediaHandler);
this.themeMedia = null;
this.themeMediaHandler = null;
}
+1 -1
View File
@@ -14,7 +14,7 @@ export type ChatState = {
lastError: string | null;
};
type ChatEventPayload = {
export type ChatEventPayload = {
runId: string;
sessionKey: string;
state: "delta" | "final" | "aborted" | "error";
+55
View File
@@ -4,6 +4,8 @@ import {
defaultDiscordActions,
type DiscordActionForm,
type DiscordForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
type IMessageForm,
type SignalForm,
type TelegramForm,
@@ -96,6 +98,7 @@ 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 discordGuilds = discord.guilds;
const readAction = (key: keyof DiscordActionForm) =>
typeof discordActions[key] === "boolean"
? (discordActions[key] as boolean)
@@ -103,6 +106,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
state.discordForm = {
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
token: typeof discord.token === "string" ? discord.token : "",
dmEnabled: typeof discordDm.enabled === "boolean" ? discordDm.enabled : true,
allowFrom: toList(discordDm.allowFrom),
groupEnabled:
typeof discordDm.groupEnabled === "boolean" ? discordDm.groupEnabled : false,
@@ -111,6 +115,57 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "",
historyLimit:
typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "",
textChunkLimit:
typeof discord.textChunkLimit === "number"
? String(discord.textChunkLimit)
: "",
replyToMode:
discord.replyToMode === "first" || discord.replyToMode === "all"
? discord.replyToMode
: "off",
guilds: Array.isArray(discordGuilds)
? []
: typeof discordGuilds === "object" && discordGuilds
? Object.entries(discordGuilds as Record<string, unknown>).map(
([key, value]): DiscordGuildForm => {
const entry =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
const channelsRaw =
entry.channels && typeof entry.channels === "object"
? (entry.channels as Record<string, unknown>)
: {};
const channels = Object.entries(channelsRaw).map(
([channelKey, channelValue]): DiscordGuildChannelForm => {
const channel =
channelValue && typeof channelValue === "object"
? (channelValue as Record<string, unknown>)
: {};
return {
key: channelKey,
allow:
typeof channel.allow === "boolean" ? channel.allow : true,
requireMention:
typeof channel.requireMention === "boolean"
? channel.requireMention
: false,
};
},
);
return {
key,
slug: typeof entry.slug === "string" ? entry.slug : "",
requireMention:
typeof entry.requireMention === "boolean"
? entry.requireMention
: false,
users: toList(entry.users),
channels,
};
},
)
: [],
actions: {
reactions: readAction("reactions"),
stickers: readAction("stickers"),
+49
View File
@@ -5,6 +5,8 @@ import {
defaultDiscordActions,
type DiscordActionForm,
type DiscordForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
type IMessageForm,
type SignalForm,
type TelegramForm,
@@ -233,6 +235,8 @@ export async function saveDiscordConfig(state: ConnectionsState) {
const allowFrom = parseList(form.allowFrom);
const groupChannels = parseList(form.groupChannels);
const dm = { ...(discord.dm ?? {}) } as Record<string, unknown>;
if (form.dmEnabled) delete dm.enabled;
else dm.enabled = false;
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
else delete dm.allowFrom;
if (form.groupEnabled) dm.groupEnabled = true;
@@ -261,6 +265,51 @@ export async function saveDiscordConfig(state: ConnectionsState) {
}
}
const chunkLimitRaw = form.textChunkLimit.trim();
if (chunkLimitRaw.length === 0) {
delete discord.textChunkLimit;
} else {
const chunkLimit = Number(chunkLimitRaw);
if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
discord.textChunkLimit = chunkLimit;
} else {
delete discord.textChunkLimit;
}
}
if (form.replyToMode === "off") {
delete discord.replyToMode;
} else {
discord.replyToMode = form.replyToMode;
}
const guildsForm = Array.isArray(form.guilds) ? form.guilds : [];
const guilds: Record<string, unknown> = {};
guildsForm.forEach((guild: DiscordGuildForm) => {
const key = String(guild.key ?? "").trim();
if (!key) return;
const entry: Record<string, unknown> = {};
const slug = String(guild.slug ?? "").trim();
if (slug) entry.slug = slug;
if (guild.requireMention) entry.requireMention = true;
const users = parseList(guild.users);
if (users.length > 0) entry.users = users;
const channels: Record<string, unknown> = {};
const channelForms = Array.isArray(guild.channels) ? guild.channels : [];
channelForms.forEach((channel: DiscordGuildChannelForm) => {
const channelKey = String(channel.key ?? "").trim();
if (!channelKey) return;
const channelEntry: Record<string, unknown> = {};
if (channel.allow === false) channelEntry.allow = false;
if (channel.requireMention) channelEntry.requireMention = true;
channels[channelKey] = channelEntry;
});
if (Object.keys(channels).length > 0) entry.channels = channels;
guilds[key] = entry;
});
if (Object.keys(guilds).length > 0) discord.guilds = guilds;
else delete discord.guilds;
const actions: Partial<DiscordActionForm> = {};
const applyAction = (key: keyof DiscordActionForm) => {
const value = form.actions[key];
+18
View File
@@ -11,11 +11,15 @@ export type TelegramForm = {
export type DiscordForm = {
enabled: boolean;
token: string;
dmEnabled: boolean;
allowFrom: string;
groupEnabled: boolean;
groupChannels: string;
mediaMaxMb: string;
historyLimit: string;
textChunkLimit: string;
replyToMode: "off" | "first" | "all";
guilds: DiscordGuildForm[];
actions: DiscordActionForm;
slashEnabled: boolean;
slashName: string;
@@ -23,6 +27,20 @@ export type DiscordForm = {
slashEphemeral: boolean;
};
export type DiscordGuildForm = {
key: string;
slug: string;
requireMention: boolean;
users: string;
channels: DiscordGuildChannelForm[];
};
export type DiscordGuildChannelForm = {
key: string;
allow: boolean;
requireMention: boolean;
};
export type DiscordActionForm = {
reactions: boolean;
stickers: boolean;
+5 -1
View File
@@ -43,7 +43,10 @@ export function renderChat(props: ChatProps) {
props.onSessionKeyChange((e.target as HTMLSelectElement).value)}
>
${sessionOptions.map(
(entry) => html`<option value=${entry.key}>${entry.key}</option>`,
(entry) =>
html`<option value=${entry.key}>
${entry.displayName ?? entry.key}
</option>`,
)}
</select>
</label>
@@ -115,6 +118,7 @@ export function renderChat(props: ChatProps) {
type SessionOption = {
key: string;
updatedAt?: number | null;
displayName?: string;
};
function resolveSessionOptions(
+293 -18
View File
@@ -10,6 +10,24 @@ import type {
TelegramForm,
} from "../ui-types";
const discordActionOptions = [
{ 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" },
] satisfies Array<{ key: keyof DiscordActionForm; label: string }>;
export type ConnectionsProps = {
connected: boolean;
loading: boolean;
@@ -54,24 +72,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",
@@ -500,6 +500,19 @@ function renderProvider(
placeholder="123456789, username#1234"
/>
</label>
<label class="field">
<span>DMs enabled</span>
<select
.value=${props.discordForm.dmEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group DMs</span>
<select
@@ -546,6 +559,268 @@ function renderProvider(
placeholder="20"
/>
</label>
<label class="field">
<span>Text chunk limit</span>
<input
.value=${props.discordForm.textChunkLimit}
@input=${(e: Event) =>
props.onDiscordChange({
textChunkLimit: (e.target as HTMLInputElement).value,
})}
placeholder="2000"
/>
</label>
<label class="field">
<span>Reply to mode</span>
<select
.value=${props.discordForm.replyToMode}
@change=${(e: Event) =>
props.onDiscordChange({
replyToMode: (e.target as HTMLSelectElement).value as
| "off"
| "first"
| "all",
})}
>
<option value="off">Off</option>
<option value="first">First</option>
<option value="all">All</option>
</select>
</label>
<div class="field full">
<span>Guilds</span>
<div class="card-sub">
Add each guild (id or slug) and optional channel rules. Empty channel
entries still allow that channel.
</div>
<div class="list">
${props.discordForm.guilds.map(
(guild, guildIndex) => html`
<div class="list-item">
<div class="list-main">
<div class="form-grid">
<label class="field">
<span>Guild id / slug</span>
<input
.value=${guild.key}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
key: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Slug</span>
<input
.value=${guild.slug}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
slug: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${guild.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
requireMention:
(e.target as HTMLSelectElement).value === "yes",
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Users allowlist</span>
<input
.value=${guild.users}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
users: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
placeholder="123456789, username#1234"
/>
</label>
</div>
${guild.channels.length
? html`
<div class="form-grid" style="margin-top: 8px;">
${guild.channels.map(
(channel, channelIndex) => html`
<label class="field">
<span>Channel id / slug</span>
<input
.value=${channel.key}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
key: (e.target as HTMLInputElement).value,
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Allow</span>
<select
.value=${channel.allow ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
allow:
(e.target as HTMLSelectElement).value ===
"yes",
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${channel.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
requireMention:
(e.target as HTMLSelectElement).value ===
"yes",
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>&nbsp;</span>
<button
class="btn"
@click=${() => {
const next = [
...props.discordForm.guilds,
];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels.splice(channelIndex, 1);
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
Remove
</button>
</label>
`,
)}
</div>
`
: nothing}
</div>
<div class="list-meta">
<span>Channels</span>
<button
class="btn"
@click=${() => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
{ key: "", allow: true, requireMention: false },
];
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
Add channel
</button>
<button
class="btn danger"
@click=${() => {
const next = [...props.discordForm.guilds];
next.splice(guildIndex, 1);
props.onDiscordChange({ guilds: next });
}}
>
Remove guild
</button>
</div>
</div>
`,
)}
</div>
<button
class="btn"
style="margin-top: 8px;"
@click=${() =>
props.onDiscordChange({
guilds: [
...props.discordForm.guilds,
{
key: "",
slug: "",
requireMention: false,
users: "",
channels: [],
},
],
})}
>
Add guild
</button>
</div>
<label class="field">
<span>Slash command</span>
<select