diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..5d2837a6c --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,174 @@ +"channel: bluebubbles": + - changed-files: + - any-glob-to-any-file: + - "extensions/bluebubbles/**" + - "docs/channels/bluebubbles.md" +"channel: discord": + - changed-files: + - any-glob-to-any-file: + - "src/discord/**" + - "extensions/discord/**" + - "docs/channels/discord.md" +"channel: googlechat": + - changed-files: + - any-glob-to-any-file: + - "extensions/googlechat/**" + - "docs/channels/googlechat.md" +"channel: imessage": + - changed-files: + - any-glob-to-any-file: + - "src/imessage/**" + - "extensions/imessage/**" + - "docs/channels/imessage.md" +"channel: line": + - changed-files: + - any-glob-to-any-file: + - "extensions/line/**" +"channel: matrix": + - changed-files: + - any-glob-to-any-file: + - "extensions/matrix/**" + - "docs/channels/matrix.md" +"channel: mattermost": + - changed-files: + - any-glob-to-any-file: + - "extensions/mattermost/**" + - "docs/channels/mattermost.md" +"channel: msteams": + - changed-files: + - any-glob-to-any-file: + - "extensions/msteams/**" + - "docs/channels/msteams.md" +"channel: nextcloud-talk": + - changed-files: + - any-glob-to-any-file: + - "extensions/nextcloud-talk/**" + - "docs/channels/nextcloud-talk.md" +"channel: nostr": + - changed-files: + - any-glob-to-any-file: + - "extensions/nostr/**" + - "docs/channels/nostr.md" +"channel: signal": + - changed-files: + - any-glob-to-any-file: + - "src/signal/**" + - "extensions/signal/**" + - "docs/channels/signal.md" +"channel: slack": + - changed-files: + - any-glob-to-any-file: + - "src/slack/**" + - "extensions/slack/**" + - "docs/channels/slack.md" +"channel: telegram": + - changed-files: + - any-glob-to-any-file: + - "src/telegram/**" + - "extensions/telegram/**" + - "docs/channels/telegram.md" +"channel: tlon": + - changed-files: + - any-glob-to-any-file: + - "extensions/tlon/**" + - "docs/channels/tlon.md" +"channel: voice-call": + - changed-files: + - any-glob-to-any-file: + - "extensions/voice-call/**" +"channel: whatsapp-web": + - changed-files: + - any-glob-to-any-file: + - "src/web/**" + - "extensions/whatsapp/**" + - "docs/channels/whatsapp.md" +"channel: zalo": + - changed-files: + - any-glob-to-any-file: + - "extensions/zalo/**" + - "docs/channels/zalo.md" +"channel: zalouser": + - changed-files: + - any-glob-to-any-file: + - "extensions/zalouser/**" + - "docs/channels/zalouser.md" + +"app: android": + - changed-files: + - any-glob-to-any-file: + - "apps/android/**" + - "docs/platforms/android.md" +"app: ios": + - changed-files: + - any-glob-to-any-file: + - "apps/ios/**" + - "docs/platforms/ios.md" +"app: macos": + - changed-files: + - any-glob-to-any-file: + - "apps/macos/**" + - "docs/platforms/macos.md" + - "docs/platforms/mac/**" +"app: web-ui": + - changed-files: + - any-glob-to-any-file: + - "ui/**" + - "src/gateway/control-ui.ts" + - "src/gateway/control-ui-shared.ts" + - "src/gateway/protocol/**" + - "src/gateway/server-methods/chat.ts" + - "src/infra/control-ui-assets.ts" + +"gateway": + - changed-files: + - any-glob-to-any-file: + - "src/gateway/**" + - "src/daemon/**" + - "docs/gateway/**" + +"docs": + - changed-files: + - any-glob-to-any-file: + - "docs/**" + - "docs.acp.md" + +"extensions: copilot-proxy": + - changed-files: + - any-glob-to-any-file: + - "extensions/copilot-proxy/**" +"extensions: diagnostics-otel": + - changed-files: + - any-glob-to-any-file: + - "extensions/diagnostics-otel/**" +"extensions: google-antigravity-auth": + - changed-files: + - any-glob-to-any-file: + - "extensions/google-antigravity-auth/**" +"extensions: google-gemini-cli-auth": + - changed-files: + - any-glob-to-any-file: + - "extensions/google-gemini-cli-auth/**" +"extensions: llm-task": + - changed-files: + - any-glob-to-any-file: + - "extensions/llm-task/**" +"extensions: lobster": + - changed-files: + - any-glob-to-any-file: + - "extensions/lobster/**" +"extensions: memory-core": + - changed-files: + - any-glob-to-any-file: + - "extensions/memory-core/**" +"extensions: memory-lancedb": + - changed-files: + - any-glob-to-any-file: + - "extensions/memory-lancedb/**" +"extensions: open-prose": + - changed-files: + - any-glob-to-any-file: + - "extensions/open-prose/**" +"extensions: qwen-portal-auth": + - changed-files: + - any-glob-to-any-file: + - "extensions/qwen-portal-auth/**" diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml new file mode 100644 index 000000000..7f242a094 --- /dev/null +++ b/.github/workflows/auto-response.yml @@ -0,0 +1,59 @@ +name: Auto response + +on: + issues: + types: [labeled] + pull_request: + types: [labeled] + +permissions: + issues: write + pull-requests: write + +jobs: + auto-response: + runs-on: ubuntu-latest + steps: + - name: Handle labeled items + uses: actions/github-script@v7 + with: + script: | + const rules = [ + { + label: "skill-clawdhub", + close: true, + message: + "Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.", + }, + ]; + + const labelName = context.payload.label?.name; + if (!labelName) { + return; + } + + const rule = rules.find((item) => item.label === labelName); + if (!rule) { + return; + } + + const issueNumber = context.payload.issue?.number ?? context.payload.pull_request?.number; + if (!issueNumber) { + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: rule.message, + }); + + if (rule.close) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + state: "closed", + }); + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcd8e457c..8cc86bd63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,6 +342,8 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true - name: Run ${{ matrix.task }} + env: + NODE_OPTIONS: --max-old-space-size=4096 run: ${{ matrix.command }} macos-app: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..8d078774b --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,23 @@ +name: Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - uses: actions/labeler@v5 + with: + configuration-path: .github/labeler.yml + repo-token: ${{ steps.app-token.outputs.token }} diff --git a/AGENTS.md b/AGENTS.md index deed6d9bd..ac85a00d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ - Core channel docs: `docs/channels/` - Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing` - Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`) +- When adding channels/extensions/apps/docs, review `.github/labeler.yml` for label coverage. ## Docs Linking (Mintlify) - Docs are hosted on Mintlify (docs.clawd.bot). diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eda32488..9742150a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,47 @@ Docs: https://docs.clawd.bot +## 2026.1.25 +Status: unreleased. + +### Changes +- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. +- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. +- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. +- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger. +- Docs: add Render deployment guide. (#1975) Thanks @anurag. +- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou. +- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto. +- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto. +- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev. +- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. +- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub. +- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a. +- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst. +- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7. +- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi. +- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn. +- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev. +- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg. +- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. +- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. +- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. +- Routing: precompile session key regexes. (#1697) Thanks @Ray0907. +- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein. +- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc. +- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918. +- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. +- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. + +### Fixes +- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. + ## 2026.1.24-3 ### Fixes - Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie. - Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie. +- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse. ## 2026.1.24-2 @@ -34,7 +70,7 @@ Docs: https://docs.clawd.bot - Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal. - Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram - Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web -- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg. +- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg. - Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands - Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. - Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags diff --git a/README.md b/README.md index ebbdc43d5..217a4b61c 100644 --- a/README.md +++ b/README.md @@ -479,31 +479,32 @@ Thanks to all clawtributors:

steipete plum-dawg bohdanpodvirnyi iHildy joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall xadenryan rodrigouroz - juanpablodlc hsrvc magimetal meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub abhisekbasu1 zerone0x - jamesgroat claude JustYannicc SocialNerd42069 Hyaxia dantelex daveonkels google-labs-jules[bot] lc0rp mousberg - vignesh07 mteam88 Eng. Juan Combetto Mariano Belinky dbhurley TSavo julianengel bradleypriest benithors rohannagpal - timolins benostein f-trycua nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino - Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet - peschee rafaelreis-r thewilloftheshadow ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski CashWilliams rdev - osolmaz joshrad-dev kiranjd adityashaw2 sheeek artuskg Takhoffman onutc pauloportella neooriginal - manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood - timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg azade-c bjesuiter cheeeee Josh Phillips - robbyczgw-cla dlauer pookNast Whoaa512 YuriNachos chriseidhof ngutman ysqander aj47 superman32432432 - Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman - jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik - Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo - iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain - suminhthanh svkozak VACInc wes-davis zats 24601 adam91holt ameno- Chris Taylor Django Navarro - evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account Syhids Aaron Konyer aaronveklabs - andreabadesso Andrii cash-echo-bot Clawd ClawdFx dguido EnzeD erik-agens Evizero fcatuhe - itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell - odnxe p6l-richard philipp-spiess robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker - abhaymundhara alejandro maza andrewting19 anpoirier arthyn Asleep123 bolismauro conhecendoia dasilva333 Developer - Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna - Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin levifig Lloyd loukotal louzhixian martinpucik Matt mini - Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 prathamdby ptn1411 reeltimeapps RLTCmpe - Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 siraht snopoke testingabc321 - The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 Zach Knickerbocker - aaronn Alphonse-arianee Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik + juanpablodlc hsrvc magimetal meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub zerone0x abhisekbasu1 + jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg + vignesh07 mteam88 dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal + timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino + Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko denysvitali orlyjamie + thewilloftheshadow sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski rdev + joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg Takhoffman onutc pauloportella + neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] + John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter + cheeeee Josh Phillips robbyczgw-cla dlauer pookNast Whoaa512 YuriNachos chriseidhof ngutman ysqander + aj47 superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy + imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 + Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz + Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal ogulcancelik pasogott petradonka + rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats 24601 adam91holt ameno- + Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account rmorse + Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx dguido EnzeD + erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi + longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess robaxelsen Sash Catanzarite T5-AndyML travisp + VAC william arzt zknicker abhaymundhara alejandro maza andrewting19 anpoirier arthyn Asleep123 bolismauro + conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim grrowl + gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig + Lloyd loukotal louzhixian martinpucik Matt mini Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman + nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann + Seredeep sergical shiv19 shiyuanhai siraht snopoke testingabc321 The Admiral thesash Ubuntu + voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee + atalovesyou Azade carlulsoe ddyo Erik hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index d8d77ebe1..a015c0e36 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "com.clawdbot.android" minSdk = 31 targetSdk = 36 - versionCode = 202601240 - versionName = "2026.1.24" + versionCode = 202601250 + versionName = "2026.1.25" } buildTypes { diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 9dd7a0315..e1cf2b71d 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.24 + 2026.1.25 CFBundleVersion - 20260124 + 20260125 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 798a77421..6ff977b05 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.1.24 + 2026.1.25 CFBundleVersion - 20260124 + 20260125 diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 52faeb9d0..0073b4ef9 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -81,8 +81,8 @@ targets: properties: CFBundleDisplayName: Clawdbot CFBundleIconName: AppIcon - CFBundleShortVersionString: "2026.1.24" - CFBundleVersion: "20260124" + CFBundleShortVersionString: "2026.1.25" + CFBundleVersion: "20260125" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -130,5 +130,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: ClawdbotTests - CFBundleShortVersionString: "2026.1.24" - CFBundleVersion: "20260124" + CFBundleShortVersionString: "2026.1.25" + CFBundleVersion: "20260125" diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index ffc524d1c..ef9609649 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/textual", "state" : { - "revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3", - "version" : "0.2.0" + "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", + "version" : "0.3.1" } } ], diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index eeaf034d0..6ccb83369 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -413,10 +413,17 @@ final class AppState { } private func updateRemoteTarget(host: String) { - let parsed = CommandResolver.parseSSHTarget(self.remoteTarget) - let user = parsed?.user ?? NSUserName() - let port = parsed?.port ?? 22 - let assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)" + let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines) + guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return } + let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines) + let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser + let port = parsed.port + let assembled: String + if let user { + assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)" + } else { + assembled = port == 22 ? host : "\(host):\(port)" + } if assembled != self.remoteTarget { self.remoteTarget = assembled } diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist index 1c7d9619f..ee9e3113d 100644 --- a/apps/macos/Sources/Clawdbot/Resources/Info.plist +++ b/apps/macos/Sources/Clawdbot/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.24 + 2026.1.25 CFBundleVersion - 202601240 + 202601250 CFBundleIconFile Clawdbot CFBundleURLTypes diff --git a/apps/shared/ClawdbotKit/Package.swift b/apps/shared/ClawdbotKit/Package.swift index 076842fce..88dc28b5c 100644 --- a/apps/shared/ClawdbotKit/Package.swift +++ b/apps/shared/ClawdbotKit/Package.swift @@ -15,7 +15,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), - .package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"), + .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"), ], targets: [ .target( diff --git a/docs/docs.json b/docs/docs.json index 09b248990..b0f0ee802 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -788,6 +788,14 @@ { "source": "/install/railway/", "destination": "/railway" + }, + { + "source": "/gcp", + "destination": "/platforms/gcp" + }, + { + "source": "/gcp/", + "destination": "/platforms/gcp" } ], "navigation": { @@ -827,6 +835,7 @@ "install/nix", "install/docker", "railway", + "render", "install/bun" ] }, @@ -983,6 +992,7 @@ "bedrock", "providers/moonshot", "providers/minimax", + "providers/vercel-ai-gateway", "providers/openrouter", "providers/synthetic", "providers/opencode", @@ -1055,6 +1065,7 @@ "platforms/linux", "platforms/fly", "platforms/hetzner", + "platforms/gcp", "platforms/exe-dev" ] }, diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 917145cc2..092533c2e 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -182,6 +182,7 @@ Clawdbot ships a default for `claude-cli`: - `command: "claude"` - `args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"]` +- `resumeArgs: ["-p", "--output-format", "json", "--dangerously-skip-permissions", "--resume", "{sessionId}"]` - `modelArg: "--model"` - `systemPromptArg: "--append-system-prompt"` - `sessionArg: "--session-id"` diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md new file mode 100644 index 000000000..1b8e1d90d --- /dev/null +++ b/docs/platforms/digitalocean.md @@ -0,0 +1,239 @@ +--- +summary: "Clawdbot on DigitalOcean (cheapest paid VPS option)" +read_when: + - Setting up Clawdbot on DigitalOcean + - Looking for cheap VPS hosting for Clawdbot +--- + +# Clawdbot on DigitalOcean + +## Goal + +Run a persistent Clawdbot Gateway on DigitalOcean for **$6/month** (or $4/mo with reserved pricing). + +If you want something even cheaper, see [Oracle Cloud (Free Tier)](#oracle-cloud-free-alternative) at the bottom — it's **actually free forever**. + +## Cost Comparison (2026) + +| Provider | Plan | Specs | Price/mo | Notes | +|----------|------|-------|----------|-------| +| **Oracle Cloud** | Always Free ARM | 4 OCPU, 24GB RAM | **$0** | Best value, requires ARM-compatible setup | +| **Hetzner** | CX22 | 2 vCPU, 4GB RAM | €3.79 (~$4) | Cheapest paid, EU datacenters | +| **DigitalOcean** | Basic | 1 vCPU, 1GB RAM | $6 | Easy UI, good docs | +| **Vultr** | Cloud Compute | 1 vCPU, 1GB RAM | $6 | Many locations | +| **Linode** | Nanode | 1 vCPU, 1GB RAM | $5 | Now part of Akamai | + +**Recommendation:** +- **Free:** Oracle Cloud ARM (if you can handle the signup process) +- **Paid:** Hetzner CX22 (best specs per dollar) — see [Hetzner guide](/platforms/hetzner) +- **Easy:** DigitalOcean (this guide) — beginner-friendly UI + +--- + +## Prerequisites + +- DigitalOcean account ([signup with $200 free credit](https://m.do.co/c/signup)) +- SSH key pair (or willingness to use password auth) +- ~20 minutes + +## 1) Create a Droplet + +1. Log into [DigitalOcean](https://cloud.digitalocean.com/) +2. Click **Create → Droplets** +3. Choose: + - **Region:** Closest to you (or your users) + - **Image:** Ubuntu 24.04 LTS + - **Size:** Basic → Regular → **$6/mo** (1 vCPU, 1GB RAM, 25GB SSD) + - **Authentication:** SSH key (recommended) or password +4. Click **Create Droplet** +5. Note the IP address + +## 2) Connect via SSH + +```bash +ssh root@YOUR_DROPLET_IP +``` + +## 3) Install Clawdbot + +```bash +# Update system +apt update && apt upgrade -y + +# Install Node.js 22 +curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +apt install -y nodejs + +# Install Clawdbot +curl -fsSL https://clawd.bot/install.sh | bash + +# Verify +clawdbot --version +``` + +## 4) Run Onboarding + +```bash +clawdbot onboard --install-daemon +``` + +The wizard will walk you through: +- Model auth (API keys or OAuth) +- Channel setup (Telegram, WhatsApp, Discord, etc.) +- Gateway token (auto-generated) +- Daemon installation (systemd) + +## 5) Verify the Gateway + +```bash +# Check status +clawdbot status + +# Check service +systemctl status clawdbot + +# View logs +journalctl -u clawdbot -f +``` + +## 6) Access the Dashboard + +The gateway binds to loopback by default. To access the Control UI: + +**Option A: SSH Tunnel (recommended)** +```bash +# From your local machine +ssh -L 18789:localhost:18789 root@YOUR_DROPLET_IP + +# Then open: http://localhost:18789 +``` + +**Option B: Tailscale (easier long-term)** +```bash +# On the droplet +curl -fsSL https://tailscale.com/install.sh | sh +tailscale up + +# Configure gateway to bind to Tailscale +clawdbot config set gateway.bind tailnet +clawdbot gateway restart +``` + +Then access via your Tailscale IP: `http://100.x.x.x:18789` + +## 7) Connect Your Channels + +### Telegram +```bash +clawdbot pairing list telegram +clawdbot pairing approve telegram +``` + +### WhatsApp +```bash +clawdbot channels login whatsapp +# Scan QR code +``` + +See [Channels](/channels) for other providers. + +--- + +## Optimizations for 1GB RAM + +The $6 droplet only has 1GB RAM. To keep things running smoothly: + +### Add swap (recommended) +```bash +fallocate -l 2G /swapfile +chmod 600 /swapfile +mkswap /swapfile +swapon /swapfile +echo '/swapfile none swap sw 0 0' >> /etc/fstab +``` + +### Use a lighter model +If you're hitting OOMs, consider: +- Using API-based models (Claude, GPT) instead of local models +- Setting `agents.defaults.model.primary` to a smaller model + +### Monitor memory +```bash +free -h +htop +``` + +--- + +## Persistence + +All state lives in: +- `~/.clawdbot/` — config, credentials, session data +- `~/clawd/` — workspace (SOUL.md, memory, etc.) + +These survive reboots. Back them up periodically: +```bash +tar -czvf clawdbot-backup.tar.gz ~/.clawdbot ~/clawd +``` + +--- + +## Oracle Cloud Free Alternative + +Oracle Cloud offers **Always Free** ARM instances that are significantly more powerful: + +| What you get | Specs | +|--------------|-------| +| **4 OCPUs** | ARM Ampere A1 | +| **24GB RAM** | More than enough | +| **200GB storage** | Block volume | +| **Forever free** | No credit card charges | + +### Quick setup: +1. Sign up at [oracle.com/cloud/free](https://www.oracle.com/cloud/free/) +2. Create a VM.Standard.A1.Flex instance (ARM) +3. Choose Oracle Linux or Ubuntu +4. Allocate up to 4 OCPU / 24GB RAM within free tier +5. Follow the same Clawdbot install steps above + +**Caveats:** +- Signup can be finicky (retry if it fails) +- ARM architecture — most things work, but some binaries need ARM builds +- Oracle may reclaim idle instances (keep them active) + +For the full Oracle guide, see the [community docs](https://gist.github.com/rssnyder/51e3cfedd730e7dd5f4a816143b25dbd). + +--- + +## Troubleshooting + +### Gateway won't start +```bash +clawdbot gateway status +clawdbot doctor --non-interactive +journalctl -u clawdbot --no-pager -n 50 +``` + +### Port already in use +```bash +lsof -i :18789 +kill +``` + +### Out of memory +```bash +# Check memory +free -h + +# Add more swap +# Or upgrade to $12/mo droplet (2GB RAM) +``` + +--- + +## See Also + +- [Hetzner guide](/platforms/hetzner) — cheaper, more powerful +- [Docker install](/install/docker) — containerized setup +- [Tailscale](/gateway/tailscale) — secure remote access +- [Configuration](/gateway/configuration) — full config reference diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md index d43b83ed7..0fdf176ae 100644 --- a/docs/platforms/fly.md +++ b/docs/platforms/fly.md @@ -182,7 +182,7 @@ cat > /data/clawdbot.json << 'EOF' "bind": "auto" }, "meta": { - "lastTouchedVersion": "2026.1.24" + "lastTouchedVersion": "2026.1.25" } } EOF diff --git a/docs/platforms/gcp.md b/docs/platforms/gcp.md new file mode 100644 index 000000000..cffa03ace --- /dev/null +++ b/docs/platforms/gcp.md @@ -0,0 +1,498 @@ +--- +summary: "Run Clawdbot Gateway 24/7 on a GCP Compute Engine VM (Docker) with durable state" +read_when: + - You want Clawdbot running 24/7 on GCP + - You want a production-grade, always-on Gateway on your own VM + - You want full control over persistence, binaries, and restart behavior +--- + +# Clawdbot on GCP Compute Engine (Docker, Production VPS Guide) + +## Goal + +Run a persistent Clawdbot Gateway on a GCP Compute Engine VM using Docker, with durable state, baked-in binaries, and safe restart behavior. + +If you want "Clawdbot 24/7 for ~$5-12/mo", this is a reliable setup on Google Cloud. +Pricing varies by machine type and region; pick the smallest VM that fits your workload and scale up if you hit OOMs. + +## What are we doing (simple terms)? + +- Create a GCP project and enable billing +- Create a Compute Engine VM +- Install Docker (isolated app runtime) +- Start the Clawdbot Gateway in Docker +- Persist `~/.clawdbot` + `~/clawd` on the host (survives restarts/rebuilds) +- Access the Control UI from your laptop via an SSH tunnel + +The Gateway can be accessed via: +- SSH port forwarding from your laptop +- Direct port exposure if you manage firewalling and tokens yourself + +This guide uses Debian on GCP Compute Engine. +Ubuntu also works; map packages accordingly. +For the generic Docker flow, see [Docker](/install/docker). + +--- + +## Quick path (experienced operators) + +1) Create GCP project + enable Compute Engine API +2) Create Compute Engine VM (e2-small, Debian 12, 20GB) +3) SSH into the VM +4) Install Docker +5) Clone Clawdbot repository +6) Create persistent host directories +7) Configure `.env` and `docker-compose.yml` +8) Bake required binaries, build, and launch + +--- + +## What you need + +- GCP account (free tier eligible for e2-micro) +- gcloud CLI installed (or use Cloud Console) +- SSH access from your laptop +- Basic comfort with SSH + copy/paste +- ~20-30 minutes +- Docker and Docker Compose +- Model auth credentials +- Optional provider credentials + - WhatsApp QR + - Telegram bot token + - Gmail OAuth + +--- + +## 1) Install gcloud CLI (or use Console) + +**Option A: gcloud CLI** (recommended for automation) + +Install from https://cloud.google.com/sdk/docs/install + +Initialize and authenticate: + +```bash +gcloud init +gcloud auth login +``` + +**Option B: Cloud Console** + +All steps can be done via the web UI at https://console.cloud.google.com + +--- + +## 2) Create a GCP project + +**CLI:** + +```bash +gcloud projects create my-clawdbot-project --name="Clawdbot Gateway" +gcloud config set project my-clawdbot-project +``` + +Enable billing at https://console.cloud.google.com/billing (required for Compute Engine). + +Enable the Compute Engine API: + +```bash +gcloud services enable compute.googleapis.com +``` + +**Console:** + +1. Go to IAM & Admin > Create Project +2. Name it and create +3. Enable billing for the project +4. Navigate to APIs & Services > Enable APIs > search "Compute Engine API" > Enable + +--- + +## 3) Create the VM + +**Machine types:** + +| Type | Specs | Cost | Notes | +|------|-------|------|-------| +| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Recommended | +| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | May OOM under load | + +**CLI:** + +```bash +gcloud compute instances create clawdbot-gateway \ + --zone=us-central1-a \ + --machine-type=e2-small \ + --boot-disk-size=20GB \ + --image-family=debian-12 \ + --image-project=debian-cloud +``` + +**Console:** + +1. Go to Compute Engine > VM instances > Create instance +2. Name: `clawdbot-gateway` +3. Region: `us-central1`, Zone: `us-central1-a` +4. Machine type: `e2-small` +5. Boot disk: Debian 12, 20GB +6. Create + +--- + +## 4) SSH into the VM + +**CLI:** + +```bash +gcloud compute ssh clawdbot-gateway --zone=us-central1-a +``` + +**Console:** + +Click the "SSH" button next to your VM in the Compute Engine dashboard. + +Note: SSH key propagation can take 1-2 minutes after VM creation. If connection is refused, wait and retry. + +--- + +## 5) Install Docker (on the VM) + +```bash +sudo apt-get update +sudo apt-get install -y git curl ca-certificates +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker $USER +``` + +Log out and back in for the group change to take effect: + +```bash +exit +``` + +Then SSH back in: + +```bash +gcloud compute ssh clawdbot-gateway --zone=us-central1-a +``` + +Verify: + +```bash +docker --version +docker compose version +``` + +--- + +## 6) Clone the Clawdbot repository + +```bash +git clone https://github.com/clawdbot/clawdbot.git +cd clawdbot +``` + +This guide assumes you will build a custom image to guarantee binary persistence. + +--- + +## 7) Create persistent host directories + +Docker containers are ephemeral. +All long-lived state must live on the host. + +```bash +mkdir -p ~/.clawdbot +mkdir -p ~/clawd +``` + +--- + +## 8) Configure environment variables + +Create `.env` in the repository root. + +```bash +CLAWDBOT_IMAGE=clawdbot:latest +CLAWDBOT_GATEWAY_TOKEN=change-me-now +CLAWDBOT_GATEWAY_BIND=lan +CLAWDBOT_GATEWAY_PORT=18789 + +CLAWDBOT_CONFIG_DIR=/home/$USER/.clawdbot +CLAWDBOT_WORKSPACE_DIR=/home/$USER/clawd + +GOG_KEYRING_PASSWORD=change-me-now +XDG_CONFIG_HOME=/home/node/.clawdbot +``` + +Generate strong secrets: + +```bash +openssl rand -hex 32 +``` + +**Do not commit this file.** + +--- + +## 9) Docker Compose configuration + +Create or update `docker-compose.yml`. + +```yaml +services: + clawdbot-gateway: + image: ${CLAWDBOT_IMAGE} + build: . + restart: unless-stopped + env_file: + - .env + environment: + - HOME=/home/node + - NODE_ENV=production + - TERM=xterm-256color + - CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND} + - CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT} + - CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN} + - GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD} + - XDG_CONFIG_HOME=${XDG_CONFIG_HOME} + - PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + volumes: + - ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot + - ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd + ports: + # Recommended: keep the Gateway loopback-only on the VM; access via SSH tunnel. + # To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly. + - "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789" + + # Optional: only if you run iOS/Android nodes against this VM and need Canvas host. + # If you expose this publicly, read /gateway/security and firewall accordingly. + # - "18793:18793" + command: + [ + "node", + "dist/index.js", + "gateway", + "--bind", + "${CLAWDBOT_GATEWAY_BIND}", + "--port", + "${CLAWDBOT_GATEWAY_PORT}" + ] +``` + +--- + +## 10) Bake required binaries into the image (critical) + +Installing binaries inside a running container is a trap. +Anything installed at runtime will be lost on restart. + +All external binaries required by skills must be installed at image build time. + +The examples below show three common binaries only: +- `gog` for Gmail access +- `goplaces` for Google Places +- `wacli` for WhatsApp + +These are examples, not a complete list. +You may install as many binaries as needed using the same pattern. + +If you add new skills later that depend on additional binaries, you must: +1. Update the Dockerfile +2. Rebuild the image +3. Restart the containers + +**Example Dockerfile** + +```dockerfile +FROM node:22-bookworm + +RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/* + +# Example binary 1: Gmail CLI +RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \ + | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog + +# Example binary 2: Google Places CLI +RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \ + | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces + +# Example binary 3: WhatsApp CLI +RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \ + | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli + +# Add more binaries below using the same pattern + +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY ui/package.json ./ui/package.json +COPY scripts ./scripts + +RUN corepack enable +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm build +RUN pnpm ui:install +RUN pnpm ui:build + +ENV NODE_ENV=production + +CMD ["node","dist/index.js"] +``` + +--- + +## 11) Build and launch + +```bash +docker compose build +docker compose up -d clawdbot-gateway +``` + +Verify binaries: + +```bash +docker compose exec clawdbot-gateway which gog +docker compose exec clawdbot-gateway which goplaces +docker compose exec clawdbot-gateway which wacli +``` + +Expected output: + +``` +/usr/local/bin/gog +/usr/local/bin/goplaces +/usr/local/bin/wacli +``` + +--- + +## 12) Verify Gateway + +```bash +docker compose logs -f clawdbot-gateway +``` + +Success: + +``` +[gateway] listening on ws://0.0.0.0:18789 +``` + +--- + +## 13) Access from your laptop + +Create an SSH tunnel to forward the Gateway port: + +```bash +gcloud compute ssh clawdbot-gateway --zone=us-central1-a -- -L 18789:127.0.0.1:18789 +``` + +Open in your browser: + +`http://127.0.0.1:18789/` + +Paste your gateway token. + +--- + +## What persists where (source of truth) + +Clawdbot runs in Docker, but Docker is not the source of truth. +All long-lived state must survive restarts, rebuilds, and reboots. + +| Component | Location | Persistence mechanism | Notes | +|---|---|---|---| +| Gateway config | `/home/node/.clawdbot/` | Host volume mount | Includes `clawdbot.json`, tokens | +| Model auth profiles | `/home/node/.clawdbot/` | Host volume mount | OAuth tokens, API keys | +| Skill configs | `/home/node/.clawdbot/skills/` | Host volume mount | Skill-level state | +| Agent workspace | `/home/node/clawd/` | Host volume mount | Code and agent artifacts | +| WhatsApp session | `/home/node/.clawdbot/` | Host volume mount | Preserves QR login | +| Gmail keyring | `/home/node/.clawdbot/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` | +| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time | +| Node runtime | Container filesystem | Docker image | Rebuilt every image build | +| OS packages | Container filesystem | Docker image | Do not install at runtime | +| Docker container | Ephemeral | Restartable | Safe to destroy | + +--- + +## Updates + +To update Clawdbot on the VM: + +```bash +cd ~/clawdbot +git pull +docker compose build +docker compose up -d +``` + +--- + +## Troubleshooting + +**SSH connection refused** + +SSH key propagation can take 1-2 minutes after VM creation. Wait and retry. + +**OS Login issues** + +Check your OS Login profile: + +```bash +gcloud compute os-login describe-profile +``` + +Ensure your account has the required IAM permissions (Compute OS Login or Compute OS Admin Login). + +**Out of memory (OOM)** + +If using e2-micro and hitting OOM, upgrade to e2-small or e2-medium: + +```bash +# Stop the VM first +gcloud compute instances stop clawdbot-gateway --zone=us-central1-a + +# Change machine type +gcloud compute instances set-machine-type clawdbot-gateway \ + --zone=us-central1-a \ + --machine-type=e2-small + +# Start the VM +gcloud compute instances start clawdbot-gateway --zone=us-central1-a +``` + +--- + +## Service accounts (security best practice) + +For personal use, your default user account works fine. + +For automation or CI/CD pipelines, create a dedicated service account with minimal permissions: + +1. Create a service account: + ```bash + gcloud iam service-accounts create clawdbot-deploy \ + --display-name="Clawdbot Deployment" + ``` + +2. Grant Compute Instance Admin role (or narrower custom role): + ```bash + gcloud projects add-iam-policy-binding my-clawdbot-project \ + --member="serviceAccount:clawdbot-deploy@my-clawdbot-project.iam.gserviceaccount.com" \ + --role="roles/compute.instanceAdmin.v1" + ``` + +Avoid using the Owner role for automation. Use the principle of least privilege. + +See https://cloud.google.com/iam/docs/understanding-roles for IAM role details. + +--- + +## Next steps + +- Set up messaging channels: [Channels](/channels) +- Pair local devices as nodes: [Nodes](/nodes) +- Configure the Gateway: [Gateway configuration](/gateway/configuration) diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 1b5c85129..d53073026 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -27,6 +27,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v - Railway (one-click): [Railway](/railway) - Fly.io: [Fly.io](/platforms/fly) - Hetzner (Docker): [Hetzner](/platforms/hetzner) +- GCP (Compute Engine): [GCP](/platforms/gcp) - exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev) ## Common links diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index d2d267661..d3bfd02c3 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -30,17 +30,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.24-3 \ +APP_VERSION=2026.1.25 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.zip +ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.25.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.dmg +scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.25.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \ BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.24-3 \ +APP_VERSION=2026.1.25 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24-3.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.25.dSYM.zip ``` ## Appcast entry Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24-3.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.25.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. ## Publish & verify -- Upload `Clawdbot-2026.1.24-3.zip` (and `Clawdbot-2026.1.24-3.dSYM.zip`) to the GitHub release for tag `v2026.1.24-3`. +- Upload `Clawdbot-2026.1.25.zip` (and `Clawdbot-2026.1.25.dSYM.zip`) to the GitHub release for tag `v2026.1.25`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200. diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md new file mode 100644 index 000000000..b34e3fcfe --- /dev/null +++ b/docs/platforms/raspberry-pi.md @@ -0,0 +1,354 @@ +--- +summary: "Clawdbot on Raspberry Pi (budget self-hosted setup)" +read_when: + - Setting up Clawdbot on a Raspberry Pi + - Running Clawdbot on ARM devices + - Building a cheap always-on personal AI +--- + +# Clawdbot on Raspberry Pi + +## Goal + +Run a persistent, always-on Clawdbot Gateway on a Raspberry Pi for **~$35-80** one-time cost (no monthly fees). + +Perfect for: +- 24/7 personal AI assistant +- Home automation hub +- Low-power, always-available Telegram/WhatsApp bot + +## Hardware Requirements + +| Pi Model | RAM | Works? | Notes | +|----------|-----|--------|-------| +| **Pi 5** | 4GB/8GB | ✅ Best | Fastest, recommended | +| **Pi 4** | 4GB | ✅ Good | Sweet spot for most users | +| **Pi 4** | 2GB | ✅ OK | Works, add swap | +| **Pi 4** | 1GB | ⚠️ Tight | Possible with swap, minimal config | +| **Pi 3B+** | 1GB | ⚠️ Slow | Works but sluggish | +| **Pi Zero 2 W** | 512MB | ❌ | Not recommended | + +**Minimum specs:** 1GB RAM, 1 core, 500MB disk +**Recommended:** 2GB+ RAM, 64-bit OS, 16GB+ SD card (or USB SSD) + +## What You'll Need + +- Raspberry Pi 4 or 5 (2GB+ recommended) +- MicroSD card (16GB+) or USB SSD (better performance) +- Power supply (official Pi PSU recommended) +- Network connection (Ethernet or WiFi) +- ~30 minutes + +## 1) Flash the OS + +Use **Raspberry Pi OS Lite (64-bit)** — no desktop needed for a headless server. + +1. Download [Raspberry Pi Imager](https://www.raspberrypi.com/software/) +2. Choose OS: **Raspberry Pi OS Lite (64-bit)** +3. Click the gear icon (⚙️) to pre-configure: + - Set hostname: `gateway-host` + - Enable SSH + - Set username/password + - Configure WiFi (if not using Ethernet) +4. Flash to your SD card / USB drive +5. Insert and boot the Pi + +## 2) Connect via SSH + +```bash +ssh user@gateway-host +# or use the IP address +ssh user@192.168.x.x +``` + +## 3) System Setup + +```bash +# Update system +sudo apt update && sudo apt upgrade -y + +# Install essential packages +sudo apt install -y git curl build-essential + +# Set timezone (important for cron/reminders) +sudo timedatectl set-timezone America/Chicago # Change to your timezone +``` + +## 4) Install Node.js 22 (ARM64) + +```bash +# Install Node.js via NodeSource +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs + +# Verify +node --version # Should show v22.x.x +npm --version +``` + +## 5) Add Swap (Important for 2GB or less) + +Swap prevents out-of-memory crashes: + +```bash +# Create 2GB swap file +sudo fallocate -l 2G /swapfile +sudo chmod 600 /swapfile +sudo mkswap /swapfile +sudo swapon /swapfile + +# Make permanent +echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab + +# Optimize for low RAM (reduce swappiness) +echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf +sudo sysctl -p +``` + +## 6) Install Clawdbot + +### Option A: Standard Install (Recommended) + +```bash +curl -fsSL https://clawd.bot/install.sh | bash +``` + +### Option B: Hackable Install (For tinkering) + +```bash +git clone https://github.com/clawdbot/clawdbot.git +cd clawdbot +npm install +npm run build +npm link +``` + +The hackable install gives you direct access to logs and code — useful for debugging ARM-specific issues. + +## 7) Run Onboarding + +```bash +clawdbot onboard --install-daemon +``` + +Follow the wizard: +1. **Gateway mode:** Local +2. **Auth:** API keys recommended (OAuth can be finicky on headless Pi) +3. **Channels:** Telegram is easiest to start with +4. **Daemon:** Yes (systemd) + +## 8) Verify Installation + +```bash +# Check status +clawdbot status + +# Check service +sudo systemctl status clawdbot + +# View logs +journalctl -u clawdbot -f +``` + +## 9) Access the Dashboard + +Since the Pi is headless, use an SSH tunnel: + +```bash +# From your laptop/desktop +ssh -L 18789:localhost:18789 user@gateway-host + +# Then open in browser +open http://localhost:18789 +``` + +Or use Tailscale for always-on access: + +```bash +# On the Pi +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up + +# Update config +clawdbot config set gateway.bind tailnet +sudo systemctl restart clawdbot +``` + +--- + +## Performance Optimizations + +### Use a USB SSD (Huge Improvement) + +SD cards are slow and wear out. A USB SSD dramatically improves performance: + +```bash +# Check if booting from USB +lsblk +``` + +See [Pi USB boot guide](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#usb-mass-storage-boot) for setup. + +### Reduce Memory Usage + +```bash +# Disable GPU memory allocation (headless) +echo 'gpu_mem=16' | sudo tee -a /boot/config.txt + +# Disable Bluetooth if not needed +sudo systemctl disable bluetooth +``` + +### Monitor Resources + +```bash +# Check memory +free -h + +# Check CPU temperature +vcgencmd measure_temp + +# Live monitoring +htop +``` + +--- + +## ARM-Specific Notes + +### Binary Compatibility + +Most Clawdbot features work on ARM64, but some external binaries may need ARM builds: + +| Tool | ARM64 Status | Notes | +|------|--------------|-------| +| Node.js | ✅ | Works great | +| WhatsApp (Baileys) | ✅ | Pure JS, no issues | +| Telegram | ✅ | Pure JS, no issues | +| gog (Gmail CLI) | ⚠️ | Check for ARM release | +| Chromium (browser) | ✅ | `sudo apt install chromium-browser` | + +If a skill fails, check if its binary has an ARM build. Many Go/Rust tools do; some don't. + +### 32-bit vs 64-bit + +**Always use 64-bit OS.** Node.js and many modern tools require it. Check with: + +```bash +uname -m +# Should show: aarch64 (64-bit) not armv7l (32-bit) +``` + +--- + +## Recommended Model Setup + +Since the Pi is just the Gateway (models run in the cloud), use API-based models: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "anthropic/claude-sonnet-4-20250514", + "fallbacks": ["openai/gpt-4o-mini"] + } + } + } +} +``` + +**Don't try to run local LLMs on a Pi** — even small models are too slow. Let Claude/GPT do the heavy lifting. + +--- + +## Auto-Start on Boot + +The onboarding wizard sets this up, but to verify: + +```bash +# Check service is enabled +sudo systemctl is-enabled clawdbot + +# Enable if not +sudo systemctl enable clawdbot + +# Start on boot +sudo systemctl start clawdbot +``` + +--- + +## Troubleshooting + +### Out of Memory (OOM) + +```bash +# Check memory +free -h + +# Add more swap (see Step 5) +# Or reduce services running on the Pi +``` + +### Slow Performance + +- Use USB SSD instead of SD card +- Disable unused services: `sudo systemctl disable cups bluetooth avahi-daemon` +- Check CPU throttling: `vcgencmd get_throttled` (should return `0x0`) + +### Service Won't Start + +```bash +# Check logs +journalctl -u clawdbot --no-pager -n 100 + +# Common fix: rebuild +cd ~/clawdbot # if using hackable install +npm run build +sudo systemctl restart clawdbot +``` + +### ARM Binary Issues + +If a skill fails with "exec format error": +1. Check if the binary has an ARM64 build +2. Try building from source +3. Or use a Docker container with ARM support + +### WiFi Drops + +For headless Pis on WiFi: + +```bash +# Disable WiFi power management +sudo iwconfig wlan0 power off + +# Make permanent +echo 'wireless-power off' | sudo tee -a /etc/network/interfaces +``` + +--- + +## Cost Comparison + +| Setup | One-Time Cost | Monthly Cost | Notes | +|-------|---------------|--------------|-------| +| **Pi 4 (2GB)** | ~$45 | $0 | + power (~$5/yr) | +| **Pi 4 (4GB)** | ~$55 | $0 | Recommended | +| **Pi 5 (4GB)** | ~$60 | $0 | Best performance | +| **Pi 5 (8GB)** | ~$80 | $0 | Overkill but future-proof | +| DigitalOcean | $0 | $6/mo | $72/year | +| Hetzner | $0 | €3.79/mo | ~$50/year | + +**Break-even:** A Pi pays for itself in ~6-12 months vs cloud VPS. + +--- + +## See Also + +- [Linux guide](/platforms/linux) — general Linux setup +- [DigitalOcean guide](/platforms/digitalocean) — cloud alternative +- [Hetzner guide](/platforms/hetzner) — Docker setup +- [Tailscale](/gateway/tailscale) — remote access +- [Nodes](/nodes) — pair your laptop/phone with the Pi gateway diff --git a/docs/providers/claude-max-api-proxy.md b/docs/providers/claude-max-api-proxy.md new file mode 100644 index 000000000..255be62fc --- /dev/null +++ b/docs/providers/claude-max-api-proxy.md @@ -0,0 +1,145 @@ +--- +summary: "Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint" +read_when: + - You want to use Claude Max subscription with OpenAI-compatible tools + - You want a local API server that wraps Claude Code CLI + - You want to save money by using subscription instead of API keys +--- +# Claude Max API Proxy + +**claude-max-api-proxy** is a community tool that exposes your Claude Max/Pro subscription as an OpenAI-compatible API endpoint. This allows you to use your subscription with any tool that supports the OpenAI API format. + +## Why Use This? + +| Approach | Cost | Best For | +|----------|------|----------| +| Anthropic API | Pay per token (~$15/M input, $75/M output for Opus) | Production apps, high volume | +| Claude Max subscription | $200/month flat | Personal use, development, unlimited usage | + +If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy can save you significant money. + +## How It Works + +``` +Your App → claude-max-api-proxy → Claude Code CLI → Anthropic (via subscription) + (OpenAI format) (converts format) (uses your login) +``` + +The proxy: +1. Accepts OpenAI-format requests at `http://localhost:3456/v1/chat/completions` +2. Converts them to Claude Code CLI commands +3. Returns responses in OpenAI format (streaming supported) + +## Installation + +```bash +# Requires Node.js 20+ and Claude Code CLI +npm install -g claude-max-api-proxy + +# Verify Claude CLI is authenticated +claude --version +``` + +## Usage + +### Start the server + +```bash +claude-max-api +# Server runs at http://localhost:3456 +``` + +### Test it + +```bash +# Health check +curl http://localhost:3456/health + +# List models +curl http://localhost:3456/v1/models + +# Chat completion +curl http://localhost:3456/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-opus-4", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +### With Clawdbot + +You can point Clawdbot at the proxy as a custom OpenAI-compatible endpoint: + +```json5 +{ + env: { + OPENAI_API_KEY: "not-needed", + OPENAI_BASE_URL: "http://localhost:3456/v1" + }, + agents: { + defaults: { + model: { primary: "openai/claude-opus-4" } + } + } +} +``` + +## Available Models + +| Model ID | Maps To | +|----------|---------| +| `claude-opus-4` | Claude Opus 4 | +| `claude-sonnet-4` | Claude Sonnet 4 | +| `claude-haiku-4` | Claude Haiku 4 | + +## Auto-Start on macOS + +Create a LaunchAgent to run the proxy automatically: + +```bash +cat > ~/Library/LaunchAgents/com.claude-max-api.plist << 'EOF' + + + + + Label + com.claude-max-api + RunAtLoad + + KeepAlive + + ProgramArguments + + /usr/local/bin/node + /usr/local/lib/node_modules/claude-max-api-proxy/dist/server/standalone.js + + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:~/.local/bin:/usr/bin:/bin + + + +EOF + +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-max-api.plist +``` + +## Links + +- **npm:** https://www.npmjs.com/package/claude-max-api-proxy +- **GitHub:** https://github.com/atalovesyou/claude-max-api-proxy +- **Issues:** https://github.com/atalovesyou/claude-max-api-proxy/issues + +## Notes + +- This is a **community tool**, not officially supported by Anthropic or Clawdbot +- Requires an active Claude Max/Pro subscription with Claude Code CLI authenticated +- The proxy runs locally and does not send data to any third-party servers +- Streaming responses are fully supported + +## See Also + +- [Anthropic provider](/providers/anthropic) - Native Clawdbot integration with Claude Code CLI OAuth +- [OpenAI provider](/providers/openai) - For OpenAI/Codex subscriptions diff --git a/docs/providers/index.md b/docs/providers/index.md index fa1ad06d2..1a08e43ce 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -52,5 +52,9 @@ See [Venice AI](/providers/venice). - [Deepgram (audio transcription)](/providers/deepgram) +## Community tools + +- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint + For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration, see [Model providers](/concepts/model-providers). diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md index bd31f0a87..36cf51cda 100644 --- a/docs/providers/vercel-ai-gateway.md +++ b/docs/providers/vercel-ai-gateway.md @@ -1,4 +1,5 @@ --- +title: "Vercel AI Gateway" summary: "Vercel AI Gateway setup (auth + model selection)" read_when: - You want to use Vercel AI Gateway with Clawdbot diff --git a/docs/railway.mdx b/docs/railway.mdx index 808416f50..b8f994a7d 100644 --- a/docs/railway.mdx +++ b/docs/railway.mdx @@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard. ## One-click deploy -Deploy on Railway +Deploy on Railway After deploy, find your public URL in **Railway → your service → Settings → Domains**. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 6492bd469..244757a48 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -17,7 +17,7 @@ When the operator says “release”, immediately do this preflight (no extra qu - Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed. 1) **Version & metadata** -- [ ] Bump `package.json` version (e.g., `2026.1.24`). +- [ ] Bump `package.json` version (e.g., `2026.1.25`). - [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs. - [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts). - [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`. diff --git a/docs/render.mdx b/docs/render.mdx new file mode 100644 index 000000000..3fcdae07a --- /dev/null +++ b/docs/render.mdx @@ -0,0 +1,158 @@ +--- +title: Deploy on Render +--- + +Deploy Clawdbot on Render using Infrastructure as Code. The included `render.yaml` Blueprint defines your entire stack declaratively, service, disk, environment variables, so you can deploy with a single click and version your infrastructure alongside your code. + +## Prerequisites + +- A [Render account](https://render.com) (free tier available) +- An API key from your preferred [model provider](/providers) + +## Deploy with a Render Blueprint + +Deploy to Render + +Clicking this link will: + +1. Create a new Render service from the `render.yaml` Blueprint at the root of this repo. +2. Prompt you to set `SETUP_PASSWORD` +3. Build the Docker image and deploy + +Once deployed, your service URL follows the pattern `https://.onrender.com`. + +## Understanding the Blueprint + +Render Blueprints are YAML files that define your infrastructure. The `render.yaml` in this +repository configures everything needed to run Clawdbot: + +```yaml +services: + - type: web + name: clawdbot + runtime: docker + plan: starter + healthCheckPath: /health + envVars: + - key: PORT + value: "8080" + - key: SETUP_PASSWORD + sync: false # prompts during deploy + - key: CLAWDBOT_STATE_DIR + value: /data/.clawdbot + - key: CLAWDBOT_WORKSPACE_DIR + value: /data/workspace + - key: CLAWDBOT_GATEWAY_TOKEN + generateValue: true # auto-generates a secure token + disk: + name: clawdbot-data + mountPath: /data + sizeGB: 1 +``` + +Key Blueprint features used: + +| Feature | Purpose | +|---------|---------| +| `runtime: docker` | Builds from the repo's Dockerfile | +| `healthCheckPath` | Render monitors `/health` and restarts unhealthy instances | +| `sync: false` | Prompts for value during deploy (secrets) | +| `generateValue: true` | Auto-generates a cryptographically secure value | +| `disk` | Persistent storage that survives redeploys | + +## Choosing a plan + +| Plan | Spin-down | Disk | Best for | +|------|-----------|------|----------| +| Free | After 15 min idle | Not available | Testing, demos | +| Starter | Never | 1GB+ | Personal use, small teams | +| Standard+ | Never | 1GB+ | Production, multiple channels | + +The Blueprint defaults to `starter`. To use free tier, change `plan: free` in your fork's +`render.yaml` (but note: no persistent disk means config resets on each deploy). + +## After deployment + +### Complete the setup wizard + +1. Navigate to `https://.onrender.com/setup` +2. Enter your `SETUP_PASSWORD` +3. Select a model provider and paste your API key +4. Optionally configure messaging channels (Telegram, Discord, Slack) +5. Click **Run setup** + +### Access the Control UI + +The web dashboard is available at `https://.onrender.com/clawdbot`. + +## Render Dashboard features + +### Logs + +View real-time logs in **Dashboard → your service → Logs**. Filter by: +- Build logs (Docker image creation) +- Deploy logs (service startup) +- Runtime logs (application output) + +### Shell access + +For debugging, open a shell session via **Dashboard → your service → Shell**. The persistent disk is mounted at `/data`. + +### Environment variables + +Modify variables in **Dashboard → your service → Environment**. Changes trigger an automatic redeploy. + +### Auto-deploy + +If you use the original Clawdbot repository, Render will not auto-deploy your Clawdbot. To update it, run a manual Blueprint sync from the dashboard. + +## Custom domain + +1. Go to **Dashboard → your service → Settings → Custom Domains** +2. Add your domain +3. Configure DNS as instructed (CNAME to `*.onrender.com`) +4. Render provisions a TLS certificate automatically + +## Scaling + +Render supports horizontal and vertical scaling: + +- **Vertical**: Change the plan to get more CPU/RAM +- **Horizontal**: Increase instance count (Standard plan and above) + +For Clawdbot, vertical scaling is usually sufficient. Horizontal scaling requires sticky sessions or external state management. + +## Backups and migration + +Export your configuration and workspace at any time: + +``` +https://.onrender.com/setup/export +``` + +This downloads a portable backup you can restore on any Clawdbot host. + +## Troubleshooting + +### Service won't start + +Check the deploy logs in the Render Dashboard. Common issues: + +- Missing `SETUP_PASSWORD` — the Blueprint prompts for this, but verify it's set +- Port mismatch — ensure `PORT=8080` matches the Dockerfile's exposed port + +### Slow cold starts (free tier) + +Free tier services spin down after 15 minutes of inactivity. The first request after spin-down takes a few seconds while the container starts. Upgrade to Starter plan for always-on. + +### Data loss after redeploy + +This happens on free tier (no persistent disk). Upgrade to a paid plan, or +regularly export your config via `/setup/export`. + +### Health check failures + +Render expects a 200 response from `/health` within 30 seconds. If builds succeed but deploys fail, the service may be taking too long to start. Check: + +- Build logs for errors +- Whether the container runs locally with `docker build && docker run` diff --git a/docs/vps.md b/docs/vps.md index a6d267513..23e88255b 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -14,6 +14,7 @@ deployments work at a high level. - **Railway** (one‑click + browser setup): [Railway](/railway) - **Fly.io**: [Fly.io](/platforms/fly) - **Hetzner (Docker)**: [Hetzner](/platforms/hetzner) +- **GCP (Compute Engine)**: [GCP](/platforms/gcp) - **exe.dev** (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev) - **AWS (EC2/Lightsail/free tier)**: works well too. Video guide: https://x.com/techfrenAJ/status/2014934471095812547 diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 925b05bc1..7d82036a0 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/bluebubbles", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot BlueBubbles channel plugin", "clawdbot": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 792a94225..2a9a63c71 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/copilot-proxy", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Copilot Proxy provider plugin", "clawdbot": { diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 2afc99e2e..65a6bf0cd 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/diagnostics-otel", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot diagnostics OpenTelemetry exporter", "clawdbot": { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index dae5fe1f1..90a99d4d3 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/discord", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Discord channel plugin", "clawdbot": { diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts index d6902bffe..f349ada6a 100644 --- a/extensions/google-antigravity-auth/index.ts +++ b/extensions/google-antigravity-auth/index.ts @@ -281,6 +281,7 @@ async function loginAntigravity(params: { openUrl: (url: string) => Promise; prompt: (message: string) => Promise; note: (message: string, title?: string) => Promise; + log: (message: string) => void; progress: { update: (msg: string) => void; stop: (msg?: string) => void }; }): Promise<{ access: string; @@ -314,6 +315,11 @@ async function loginAntigravity(params: { ].join("\n"), "Google Antigravity OAuth", ); + // Output raw URL below the box for easy copying (fixes #1772) + params.log(""); + params.log("Copy this URL:"); + params.log(authUrl); + params.log(""); } if (!needsManual) { @@ -382,6 +388,7 @@ const antigravityPlugin = { openUrl: ctx.openUrl, prompt: async (message) => String(await ctx.prompter.text({ message })), note: ctx.prompter.note, + log: (message) => ctx.runtime.log(message), progress: spin, }); diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json index 96bffde7c..f1d8f86bd 100644 --- a/extensions/google-antigravity-auth/package.json +++ b/extensions/google-antigravity-auth/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/google-antigravity-auth", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Google Antigravity OAuth provider plugin", "clawdbot": { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index dc8a894d7..7e3fef15b 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/google-gemini-cli-auth", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Gemini CLI OAuth provider plugin", "clawdbot": { diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 056bdedb6..af1ccf8e1 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/googlechat", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Google Chat channel plugin", "clawdbot": { @@ -34,6 +34,6 @@ "clawdbot": "workspace:*" }, "peerDependencies": { - "clawdbot": ">=2026.1.24" + "clawdbot": ">=2026.1.25" } } diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 79aa7890d..944ad06bf 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/imessage", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot iMessage channel plugin", "clawdbot": { diff --git a/extensions/line/package.json b/extensions/line/package.json index b518f5ca5..346d66415 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/line", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot LINE channel plugin", "clawdbot": { diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index a03344d1a..d6bfbb31d 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/llm-task", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot JSON-only LLM task plugin", "clawdbot": { diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 3926b553b..b73dbac69 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/lobster", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "clawdbot": { diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 24529ee97..7fa12bc74 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/matrix", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Matrix channel plugin", "clawdbot": { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 77d799c34..60c02d50f 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/mattermost", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Mattermost channel plugin", "clawdbot": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index c70c2a63f..c70da1395 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/memory-core", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot core memory search plugin", "clawdbot": { @@ -9,6 +9,6 @@ ] }, "peerDependencies": { - "clawdbot": ">=2026.1.24" + "clawdbot": ">=2026.1.25" } } diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 80018044f..e003f5890 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/memory-lancedb", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture", "dependencies": { diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index b336b80e6..b94f8e76a 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/msteams", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Microsoft Teams channel plugin", "clawdbot": { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index bf5e443e5..2da3f3b2a 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/nextcloud-talk", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Nextcloud Talk channel plugin", "clawdbot": { diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 3a3e5ac56..b2fb4b799 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/nostr", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs", "clawdbot": { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 873f3458a..052201205 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/open-prose", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "clawdbot": { diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 034c65dea..65948eb7b 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/signal", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Signal channel plugin", "clawdbot": { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 73f2f6ecd..5bd452d2e 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/slack", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Slack channel plugin", "clawdbot": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 81b378df2..64d3d7dea 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/telegram", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Telegram channel plugin", "clawdbot": { diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index dca4f914d..06750126d 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/tlon", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Tlon/Urbit channel plugin", "clawdbot": { diff --git a/extensions/tlon/src/urbit/send.ts b/extensions/tlon/src/urbit/send.ts index 35f7f2d74..621bbd69a 100644 --- a/extensions/tlon/src/urbit/send.ts +++ b/extensions/tlon/src/urbit/send.ts @@ -63,16 +63,28 @@ export async function sendGroupMessage({ const story = [{ inline: [text] }]; const sentAt = Date.now(); + // Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies + let formattedReplyId = replyToId; + if (replyToId && /^\d+$/.test(replyToId)) { + try { + formattedReplyId = formatUd(BigInt(replyToId)); + } catch { + // Fall back to raw ID if formatting fails + } + } + const action = { channel: { nest: `chat/${hostShip}/${channelName}`, - action: replyToId + action: formattedReplyId ? { - reply: { - id: replyToId, - delta: { - add: { - memo: { + // Thread reply - needs post wrapper around reply action + // ReplyActionAdd takes Memo: {content, author, sent} - no kind/blob/meta + post: { + reply: { + id: formattedReplyId, + action: { + add: { content: story, author: fromShip, sent: sentAt, @@ -82,6 +94,7 @@ export async function sendGroupMessage({ }, } : { + // Regular post post: { add: { content: story, diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 6123a7315..a8721d47d 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2026.1.24 +## 2026.1.25 ### Changes - Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core). diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 840776c19..31b171f76 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/voice-call", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot voice-call plugin", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 8e18af842..b7b57eb51 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/whatsapp", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot WhatsApp channel plugin", "clawdbot": { diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index a3a87a878..8f077a6b3 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/zalo", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Zalo channel plugin", "clawdbot": { diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 513295b46..0ab93d1ce 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/zalouser", - "version": "2026.1.24", + "version": "2026.1.25", "type": "module", "description": "Clawdbot Zalo Personal Account plugin via zca-cli", "dependencies": { diff --git a/package.json b/package.json index 5d77e25d0..0c63d5d69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawdbot", - "version": "2026.1.24-3", + "version": "2026.1.25", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 781a461a9..14bef9f5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,7 +357,7 @@ importers: extensions/memory-core: dependencies: clawdbot: - specifier: '>=2026.1.24' + specifier: '>=2026.1.25' version: link:../.. extensions/memory-lancedb: diff --git a/render.yaml b/render.yaml new file mode 100644 index 000000000..01923a8f6 --- /dev/null +++ b/render.yaml @@ -0,0 +1,21 @@ +services: + - type: web + name: clawdbot + runtime: docker + plan: starter + healthCheckPath: /health + envVars: + - key: PORT + value: "8080" + - key: SETUP_PASSWORD + sync: false + - key: CLAWDBOT_STATE_DIR + value: /data/.clawdbot + - key: CLAWDBOT_WORKSPACE_DIR + value: /data/workspace + - key: CLAWDBOT_GATEWAY_TOKEN + generateValue: true + disk: + name: clawdbot-data + mountPath: /data + sizeGB: 1 diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json index 8899afc93..d652938a6 100644 --- a/scripts/clawtributors-map.json +++ b/scripts/clawtributors-map.json @@ -12,7 +12,10 @@ "manmal", "thesash", "rhjoh", - "ysqander" + "ysqander", + "atalovesyou", + "0xJonHoldsCrypto", + "hougangdev" ], "seedCommit": "d6863f87", "placeholderAvatar": "assets/avatar-placeholder.svg", diff --git a/scripts/sync-labels.ts b/scripts/sync-labels.ts new file mode 100644 index 000000000..297644c1e --- /dev/null +++ b/scripts/sync-labels.ts @@ -0,0 +1,107 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +type RepoLabel = { + name: string; + color?: string; +}; + +const COLOR_BY_PREFIX = new Map([ + ["channel", "1d76db"], + ["app", "6f42c1"], + ["extensions", "0e8a16"], + ["docs", "0075ca"], + ["cli", "f9d0c4"], + ["gateway", "d4c5f9"], +]); + +const configPath = resolve(".github/labeler.yml"); +const labelNames = extractLabelNames(readFileSync(configPath, "utf8")); + +if (!labelNames.length) { + throw new Error("labeler.yml must declare at least one label."); +} + +const repo = resolveRepo(); +const existing = fetchExistingLabels(repo); + +const missing = labelNames.filter((label) => !existing.has(label)); +if (!missing.length) { + console.log("All labeler labels already exist."); + process.exit(0); +} + +for (const label of missing) { + const color = pickColor(label); + execFileSync( + "gh", + [ + "api", + "-X", + "POST", + `repos/${repo}/labels`, + "-f", + `name=${label}`, + "-f", + `color=${color}`, + ], + { stdio: "inherit" }, + ); + console.log(`Created label: ${label}`); +} + +function extractLabelNames(contents: string): string[] { + const labels: string[] = []; + for (const line of contents.split("\n")) { + if (!line.trim() || line.trimStart().startsWith("#")) { + continue; + } + if (/^\s/.test(line)) { + continue; + } + const match = line.match(/^(["'])(.+)\1\s*:/) ?? line.match(/^([^:]+):/); + if (match) { + const name = (match[2] ?? match[1] ?? "").trim(); + if (name) { + labels.push(name); + } + } + } + return labels; +} + +function pickColor(label: string): string { + const prefix = label.includes(":") ? label.split(":", 1)[0].trim() : label.trim(); + return COLOR_BY_PREFIX.get(prefix) ?? "ededed"; +} + +function resolveRepo(): string { + const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], { + encoding: "utf8", + }).trim(); + + if (!remote) { + throw new Error("Unable to determine repository from git remote."); + } + + if (remote.startsWith("git@github.com:")) { + return remote.replace("git@github.com:", "").replace(/\.git$/, ""); + } + + if (remote.startsWith("https://github.com/")) { + return remote.replace("https://github.com/", "").replace(/\.git$/, ""); + } + + throw new Error(`Unsupported GitHub remote: ${remote}`); +} + +function fetchExistingLabels(repo: string): Map { + const raw = execFileSync( + "gh", + ["api", `repos/${repo}/labels?per_page=100`, "--paginate"], + { encoding: "utf8" }, + ); + const labels = JSON.parse(raw) as RepoLabel[]; + return new Map(labels.map((label) => [label.name, label])); +} diff --git a/skills/discord/SKILL.md b/skills/discord/SKILL.md index 0b64f14e1..5525a3bf5 100644 --- a/skills/discord/SKILL.md +++ b/skills/discord/SKILL.md @@ -1,6 +1,7 @@ --- name: discord description: Use when you need to control Discord from Clawdbot via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels. +metadata: {"clawdbot":{"emoji":"🎮","requires":{"config":["channels.discord"]}}} --- # Discord Actions diff --git a/skills/github/SKILL.md b/skills/github/SKILL.md index 03b2a0033..e7c89f7ba 100644 --- a/skills/github/SKILL.md +++ b/skills/github/SKILL.md @@ -1,6 +1,7 @@ --- name: github description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries." +metadata: {"clawdbot":{"emoji":"🐙","requires":{"bins":["gh"]},"install":[{"id":"brew","kind":"brew","formula":"gh","bins":["gh"],"label":"Install GitHub CLI (brew)"},{"id":"apt","kind":"apt","package":"gh","bins":["gh"],"label":"Install GitHub CLI (apt)"}]}} --- # GitHub Skill diff --git a/skills/notion/SKILL.md b/skills/notion/SKILL.md index 869871b3c..04921e250 100644 --- a/skills/notion/SKILL.md +++ b/skills/notion/SKILL.md @@ -2,7 +2,7 @@ name: notion description: Notion API for creating and managing pages, databases, and blocks. homepage: https://developers.notion.com -metadata: {"clawdbot":{"emoji":"📝"}} +metadata: {"clawdbot":{"emoji":"📝","requires":{"env":["NOTION_API_KEY"]},"primaryEnv":"NOTION_API_KEY"}} --- # notion diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index df04f858f..b72bab1f3 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -1,6 +1,7 @@ --- name: slack description: Use when you need to control Slack from Clawdbot via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs. +metadata: {"clawdbot":{"emoji":"💬","requires":{"config":["channels.slack"]}}} --- # Slack Actions diff --git a/src/agents/claude-cli-runner.test.ts b/src/agents/claude-cli-runner.test.ts index 6414aecb5..7825d00da 100644 --- a/src/agents/claude-cli-runner.test.ts +++ b/src/agents/claude-cli-runner.test.ts @@ -61,7 +61,7 @@ describe("runClaudeCliAgent", () => { expect(argv).toContain("hi"); }); - it("uses provided --session-id when a claude session id is provided", async () => { + it("uses --resume when a claude session id is provided", async () => { runCommandWithTimeoutMock.mockResolvedValueOnce({ stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }), stderr: "", @@ -83,7 +83,7 @@ describe("runClaudeCliAgent", () => { expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[]; - expect(argv).toContain("--session-id"); + expect(argv).toContain("--resume"); expect(argv).toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b"); expect(argv).toContain("hi"); }); diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index a2fcaa8a5..f21c04f52 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -28,6 +28,14 @@ const CLAUDE_MODEL_ALIASES: Record = { const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = { command: "claude", args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"], + resumeArgs: [ + "-p", + "--output-format", + "json", + "--dangerously-skip-permissions", + "--resume", + "{sessionId}", + ], output: "json", input: "arg", modelArg: "--model", diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index a1d218dd7..739b3ada3 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -133,8 +133,50 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool { return { label: "Cron", name: "cron", - description: - "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility. Use `contextMessages` (0-10) to add previous messages as context to the job text.", + description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. + +ACTIONS: +- status: Check cron scheduler status +- list: List jobs (use includeDisabled:true to include disabled) +- add: Create job (requires job object, see schema below) +- update: Modify job (requires jobId + patch object) +- remove: Delete job (requires jobId) +- run: Trigger job immediately (requires jobId) +- runs: Get job run history (requires jobId) +- wake: Send wake event (requires text, optional mode) + +JOB SCHEMA (for add action): +{ + "name": "string (optional)", + "schedule": { ... }, // Required: when to run + "payload": { ... }, // Required: what to execute + "sessionTarget": "main" | "isolated", // Required + "enabled": true | false // Optional, default true +} + +SCHEDULE TYPES (schedule.kind): +- "at": One-shot at absolute time + { "kind": "at", "atMs": } +- "every": Recurring interval + { "kind": "every", "everyMs": , "anchorMs": } +- "cron": Cron expression + { "kind": "cron", "expr": "", "tz": "" } + +PAYLOAD TYPES (payload.kind): +- "systemEvent": Injects text as system event into session + { "kind": "systemEvent", "text": "" } +- "agentTurn": Runs agent with message (isolated sessions only) + { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": , "deliver": , "channel": "", "to": "", "bestEffortDeliver": } + +CRITICAL CONSTRAINTS: +- sessionTarget="main" REQUIRES payload.kind="systemEvent" +- sessionTarget="isolated" REQUIRES payload.kind="agentTurn" + +WAKE MODES (for wake action): +- "next-heartbeat" (default): Wake on next heartbeat +- "now": Wake immediately + +Use jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.`, parameters: CronToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index 5e758d426..c9940de0f 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -14,6 +14,7 @@ export type AnnounceTarget = { channel: string; to: string; accountId?: string; + threadId?: string; // Forum topic/thread ID }; export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null { @@ -22,7 +23,22 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget if (parts.length < 3) return null; const [channelRaw, kind, ...rest] = parts; if (kind !== "group" && kind !== "channel") return null; - const id = rest.join(":").trim(); + + // Extract topic/thread ID from rest (supports both :topic: and :thread:) + // Telegram uses :topic:, other platforms use :thread: + let threadId: string | undefined; + const restJoined = rest.join(":"); + const topicMatch = restJoined.match(/:topic:(\d+)$/); + const threadMatch = restJoined.match(/:thread:(\d+)$/); + const match = topicMatch || threadMatch; + + if (match) { + threadId = match[1]; // Keep as string to match AgentCommandOpts.threadId + } + + // Remove :topic:N or :thread:N suffix from ID for target + const id = match ? restJoined.replace(/:(topic|thread):\d+$/, "") : restJoined.trim(); + if (!id) return null; if (!channelRaw) return null; const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw); @@ -37,7 +53,11 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget const normalized = normalizedChannel ? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget) : undefined; - return { channel, to: normalized ?? kindTarget }; + return { + channel, + to: normalized ?? kindTarget, + threadId, + }; } export function buildAgentToAgentMessageContext(params: { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index a428aa6da..939fa92f0 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -179,6 +179,17 @@ export async function runAgentTurnWithFallback(params: { images: params.opts?.images, }) .then((result) => { + // CLI backends don't emit streaming assistant events, so we need to + // emit one with the final text so server-chat can populate its buffer + // and send the response to TUI/WebSocket clients. + const cliText = result.payloads?.[0]?.text?.trim(); + if (cliText) { + emitAgentEvent({ + runId, + stream: "assistant", + data: { text: cliText }, + }); + } emitAgentEvent({ runId, stream: "lifecycle", @@ -358,12 +369,13 @@ export async function runAgentTurnWithFallback(params: { // Use pipeline if available (block streaming enabled), otherwise send directly if (params.blockStreamingEnabled && params.blockReplyPipeline) { params.blockReplyPipeline.enqueue(blockPayload); - } else { - // Send directly when flushing before tool execution (no streaming). + } else if (params.blockStreamingEnabled) { + // Send directly when flushing before tool execution (no pipeline but streaming enabled). // Track sent key to avoid duplicate in final payloads. directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload)); await params.opts?.onBlockReply?.(blockPayload); } + // When streaming is disabled entirely, blocks are accumulated in final text instead. } : undefined, onBlockReplyFlush: diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 0c7fa9f48..e1dbcf7a1 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -337,12 +337,56 @@ async function pageTargetId(page: Page): Promise { } } -async function findPageByTargetId(browser: Browser, targetId: string): Promise { +async function findPageByTargetId( + browser: Browser, + targetId: string, + cdpUrl?: string, +): Promise { const pages = await getAllPages(browser); + // First, try the standard CDP session approach for (const page of pages) { const tid = await pageTargetId(page).catch(() => null); if (tid && tid === targetId) return page; } + // If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget), + // fall back to URL-based matching using the /json/list endpoint + if (cdpUrl) { + try { + const baseUrl = cdpUrl + .replace(/\/+$/, "") + .replace(/^ws:/, "http:") + .replace(/\/cdp$/, ""); + const response = await fetch(`${baseUrl}/json/list`); + if (response.ok) { + const targets = (await response.json()) as Array<{ + id: string; + url: string; + title?: string; + }>; + const target = targets.find((t) => t.id === targetId); + if (target) { + // Try to find a page with matching URL + const urlMatch = pages.filter((p) => p.url() === target.url); + if (urlMatch.length === 1) { + return urlMatch[0]; + } + // If multiple URL matches, use index-based matching as fallback + // This works when Playwright and the relay enumerate tabs in the same order + if (urlMatch.length > 1) { + const sameUrlTargets = targets.filter((t) => t.url === target.url); + if (sameUrlTargets.length === urlMatch.length) { + const idx = sameUrlTargets.findIndex((t) => t.id === targetId); + if (idx >= 0 && idx < urlMatch.length) { + return urlMatch[idx]; + } + } + } + } + } + } catch { + // Ignore fetch errors and fall through to return null + } + } return null; } @@ -355,7 +399,7 @@ export async function getPageForTargetId(opts: { if (!pages.length) throw new Error("No pages available in the connected browser."); const first = pages[0]; if (!opts.targetId) return first; - const found = await findPageByTargetId(browser, opts.targetId); + const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); if (!found) { // Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget), // which prevents us from resolving a page's targetId via newCDPSession(). If Playwright @@ -496,7 +540,7 @@ export async function closePageByTargetIdViaPlaywright(opts: { targetId: string; }): Promise { const { browser } = await connectBrowser(opts.cdpUrl); - const page = await findPageByTargetId(browser, opts.targetId); + const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); if (!page) { throw new Error("tab not found"); } @@ -512,7 +556,7 @@ export async function focusPageByTargetIdViaPlaywright(opts: { targetId: string; }): Promise { const { browser } = await connectBrowser(opts.cdpUrl); - const page = await findPageByTargetId(browser, opts.targetId); + const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); if (!page) { throw new Error("tab not found"); } diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 18a11c797..fe4e41307 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -13,11 +13,9 @@ const providerId = "telegram"; function readTelegramSendParams(params: Record) { const to = readStringParam(params, "to", { required: true }); const mediaUrl = readStringParam(params, "media", { trim: false }); - const content = - readStringParam(params, "message", { - required: !mediaUrl, - allowEmpty: true, - }) ?? ""; + const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true }); + const caption = readStringParam(params, "caption", { allowEmpty: true }); + const content = message || caption || ""; const replyTo = readStringParam(params, "replyTo"); const threadId = readStringParam(params, "threadId"); const buttons = params.buttons; diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index c79c550f3..dff7b5c17 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|nanogpt-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|nanogpt-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", ) .option( "--token-provider ", @@ -75,6 +75,7 @@ export function registerOnboardCommand(program: Command) { .option("--minimax-api-key ", "MiniMax API key") .option("--synthetic-api-key ", "Synthetic API key") .option("--nanogpt-api-key ", "NanoGPT API key") + .option("--venice-api-key ", "Venice API key") .option("--opencode-zen-api-key ", "OpenCode Zen API key") .option("--gateway-port ", "Gateway port") .option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom") @@ -125,6 +126,7 @@ export function registerOnboardCommand(program: Command) { minimaxApiKey: opts.minimaxApiKey as string | undefined, syntheticApiKey: opts.syntheticApiKey as string | undefined, nanogptApiKey: opts.nanogptApiKey as string | undefined, + veniceApiKey: opts.veniceApiKey as string | undefined, opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined, gatewayPort: typeof gatewayPort === "number" && Number.isFinite(gatewayPort) diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index b3d82247f..483917faa 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -10,6 +10,61 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { const warnings: string[] = []; const auditHint = `- Run: ${formatCliCommand("clawdbot security audit --deep")}`; + // =========================================== + // GATEWAY NETWORK EXPOSURE CHECK + // =========================================== + // Check for dangerous gateway binding configurations + // that expose the gateway to network without proper auth + + const gatewayBind = cfg.gateway?.bind ?? "loopback"; + const customBindHost = cfg.gateway?.customBindHost?.trim(); + const authMode = cfg.gateway?.auth?.mode ?? "off"; + const authToken = cfg.gateway?.auth?.token; + const authPassword = cfg.gateway?.auth?.password; + + const isLoopbackBindHost = (host: string) => { + const normalized = host.trim().toLowerCase(); + return ( + normalized === "localhost" || + normalized === "::1" || + normalized === "[::1]" || + normalized.startsWith("127.") + ); + }; + + // Bindings that expose gateway beyond localhost + const exposedBindings = ["all", "lan", "0.0.0.0"]; + const isExposed = + exposedBindings.includes(gatewayBind) || + (gatewayBind === "custom" && (!customBindHost || !isLoopbackBindHost(customBindHost))); + + if (isExposed) { + if (authMode === "off") { + warnings.push( + `- CRITICAL: Gateway bound to "${gatewayBind}" with NO authentication.`, + ` Anyone on your network (or internet if port-forwarded) can fully control your agent.`, + ` Fix: ${formatCliCommand("clawdbot config set gateway.bind loopback")}`, + ` Or enable auth: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`, + ); + } else if (authMode === "token" && !authToken) { + warnings.push( + `- CRITICAL: Gateway bound to "${gatewayBind}" with empty auth token.`, + ` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`, + ); + } else if (authMode === "password" && !authPassword) { + warnings.push( + `- CRITICAL: Gateway bound to "${gatewayBind}" with empty password.`, + ` Fix: ${formatCliCommand("clawdbot configure")} to set a password`, + ); + } else { + // Auth is configured, but still warn about network exposure + warnings.push( + `- WARNING: Gateway bound to "${gatewayBind}" (network-accessible).`, + ` Ensure your auth credentials are strong and not exposed.`, + ); + } + } + const warnDmPolicy = async (params: { label: string; provider: ChannelId; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 9015c8628..ec6ae045f 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -21,6 +21,7 @@ import { applyOpencodeZenConfig, applyOpenrouterConfig, applySyntheticConfig, + applyVeniceConfig, applyVercelAiGatewayConfig, applyZaiConfig, setAnthropicApiKey, @@ -32,6 +33,7 @@ import { setOpencodeZenApiKey, setOpenrouterApiKey, setSyntheticApiKey, + setVeniceApiKey, setVercelAiGatewayApiKey, setZaiApiKey, } from "../../onboard-auth.js"; @@ -292,6 +294,25 @@ export async function applyNonInteractiveAuthChoice(params: { }); return applyNanoGptConfig(nextConfig); } + + if (authChoice === "venice-api-key") { + const resolved = await resolveNonInteractiveApiKey({ + provider: "venice", + cfg: baseConfig, + flagValue: opts.veniceApiKey, + flagName: "--venice-api-key", + envVar: "VENICE_API_KEY", + runtime, + }); + if (!resolved) return null; + if (resolved.source !== "profile") await setVeniceApiKey(resolved.key); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "venice:default", + provider: "venice", + mode: "api_key", + }); + return applyVeniceConfig(nextConfig); + } if ( authChoice === "minimax-cloud" || diff --git a/src/config/io.ts b/src/config/io.ts index da3a7fb23..9078ef2a2 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -211,6 +211,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parseJson: (raw) => deps.json5.parse(raw), }); + // Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars + if (resolved && typeof resolved === "object" && "env" in resolved) { + applyConfigEnv(resolved as ClawdbotConfig, deps.env); + } + // Substitute ${VAR} env var references const substituted = resolveConfigEnvVars(resolved, deps.env); @@ -365,6 +370,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { }; } + // Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars + if (resolved && typeof resolved === "object" && "env" in resolved) { + applyConfigEnv(resolved as ClawdbotConfig, deps.env); + } + // Substitute ${VAR} env var references let substituted: unknown; try { diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts index 7b684771a..dc04a29d5 100644 --- a/src/gateway/protocol/schema/logs-chat.ts +++ b/src/gateway/protocol/schema/logs-chat.ts @@ -35,7 +35,7 @@ export const ChatHistoryParamsSchema = Type.Object( export const ChatSendParamsSchema = Type.Object( { sessionKey: NonEmptyString, - message: NonEmptyString, + message: Type.String(), thinking: Type.Optional(Type.String()), deliver: Type.Optional(Type.Boolean()), attachments: Type.Optional(Type.Array(Type.Unknown())), diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts new file mode 100644 index 000000000..14657464a --- /dev/null +++ b/src/gateway/server-chat.agent-events.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createAgentEventHandler, createChatRunState } from "./server-chat.js"; + +describe("agent event handler", () => { + it("emits chat delta for assistant text-only events", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); + const broadcast = vi.fn(); + const nodeSendToSession = vi.fn(); + const agentRunSeq = new Map(); + const chatRunState = createChatRunState(); + chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" }); + + const handler = createAgentEventHandler({ + broadcast, + nodeSendToSession, + agentRunSeq, + chatRunState, + resolveSessionKeyForRun: () => undefined, + clearAgentRunContext: vi.fn(), + }); + + handler({ + runId: "run-1", + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { text: "Hello world" }, + }); + + const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat"); + expect(chatCalls).toHaveLength(1); + const payload = chatCalls[0]?.[1] as { + state?: string; + message?: { content?: Array<{ text?: string }> }; + }; + expect(payload.state).toBe("delta"); + expect(payload.message?.content?.[0]?.text).toBe("Hello world"); + const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat"); + expect(sessionChatCalls).toHaveLength(1); + nowSpy.mockRestore(); + }); +}); diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts new file mode 100644 index 000000000..149ab4a67 --- /dev/null +++ b/src/gateway/server-methods/agent.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { GatewayRequestContext } from "./types.js"; +import { agentHandlers } from "./agent.js"; + +const mocks = vi.hoisted(() => ({ + loadSessionEntry: vi.fn(), + updateSessionStore: vi.fn(), + agentCommand: vi.fn(), + registerAgentRunContext: vi.fn(), +})); + +vi.mock("../session-utils.js", () => ({ + loadSessionEntry: mocks.loadSessionEntry, +})); + +vi.mock("../../config/sessions.js", async () => { + const actual = await vi.importActual( + "../../config/sessions.js", + ); + return { + ...actual, + updateSessionStore: mocks.updateSessionStore, + resolveAgentIdFromSessionKey: () => "main", + resolveExplicitAgentSessionKey: () => undefined, + resolveAgentMainSessionKey: () => "agent:main:main", + }; +}); + +vi.mock("../../commands/agent.js", () => ({ + agentCommand: mocks.agentCommand, +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + listAgentIds: () => ["main"], +})); + +vi.mock("../../infra/agent-events.js", () => ({ + registerAgentRunContext: mocks.registerAgentRunContext, + onAgentEvent: vi.fn(), +})); + +vi.mock("../../sessions/send-policy.js", () => ({ + resolveSendPolicy: () => "allow", +})); + +vi.mock("../../utils/delivery-context.js", async () => { + const actual = await vi.importActual( + "../../utils/delivery-context.js", + ); + return { + ...actual, + normalizeSessionDeliveryFields: () => ({}), + }; +}); + +const makeContext = (): GatewayRequestContext => + ({ + dedupe: new Map(), + addChatRun: vi.fn(), + logGateway: { info: vi.fn(), error: vi.fn() }, + }) as unknown as GatewayRequestContext; + +describe("gateway agent handler", () => { + it("preserves cliSessionIds from existing session entry", async () => { + const existingCliSessionIds = { "claude-cli": "abc-123-def" }; + const existingClaudeCliSessionId = "abc-123-def"; + + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "existing-session-id", + updatedAt: Date.now(), + cliSessionIds: existingCliSessionIds, + claudeCliSessionId: existingClaudeCliSessionId, + }, + canonicalKey: "agent:main:main", + }); + + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = {}; + await updater(store); + capturedEntry = store["agent:main:main"] as Record; + }); + + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + const respond = vi.fn(); + await agentHandlers.agent({ + params: { + message: "test", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "test-idem", + }, + respond, + context: makeContext(), + req: { type: "req", id: "1", method: "agent" }, + client: null, + isWebchatConnect: () => false, + }); + + expect(mocks.updateSessionStore).toHaveBeenCalled(); + expect(capturedEntry).toBeDefined(); + expect(capturedEntry?.cliSessionIds).toEqual(existingCliSessionIds); + expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId); + }); + + it("handles missing cliSessionIds gracefully", async () => { + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "existing-session-id", + updatedAt: Date.now(), + // No cliSessionIds or claudeCliSessionId + }, + canonicalKey: "agent:main:main", + }); + + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = {}; + await updater(store); + capturedEntry = store["agent:main:main"] as Record; + }); + + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + const respond = vi.fn(); + await agentHandlers.agent({ + params: { + message: "test", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "test-idem-2", + }, + respond, + context: makeContext(), + req: { type: "req", id: "2", method: "agent" }, + client: null, + isWebchatConnect: () => false, + }); + + expect(mocks.updateSessionStore).toHaveBeenCalled(); + expect(capturedEntry).toBeDefined(); + // Should be undefined, not cause an error + expect(capturedEntry?.cliSessionIds).toBeUndefined(); + expect(capturedEntry?.claudeCliSessionId).toBeUndefined(); + }); +}); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 8c5782e00..d159d1f78 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -251,6 +251,8 @@ export const agentHandlers: GatewayRequestHandlers = { groupId: resolvedGroupId ?? entry?.groupId, groupChannel: resolvedGroupChannel ?? entry?.groupChannel, space: resolvedGroupSpace ?? entry?.space, + cliSessionIds: entry?.cliSessionIds, + claudeCliSessionId: entry?.claudeCliSessionId, }; sessionEntry = nextEntry; const sendPolicy = resolveSendPolicy({ diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 50f441779..9010a6f21 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -338,6 +338,15 @@ export const chatHandlers: GatewayRequestHandlers = { : undefined, })) .filter((a) => a.content) ?? []; + const rawMessage = p.message.trim(); + if (!rawMessage && normalizedAttachments.length === 0) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "message or attachment required"), + ); + return; + } let parsedMessage = p.message; let parsedImages: ChatImageContent[] = []; if (normalizedAttachments.length > 0) { diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index fa33b7c21..28719290e 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -28,11 +28,16 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { return; } - const threadMarker = ":thread:"; - const threadIndex = sessionKey.lastIndexOf(threadMarker); - const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex); + // Extract topic/thread ID from sessionKey (supports both :topic: and :thread:) + // Telegram uses :topic:, other platforms use :thread: + const topicIndex = sessionKey.lastIndexOf(":topic:"); + const threadIndex = sessionKey.lastIndexOf(":thread:"); + const markerIndex = Math.max(topicIndex, threadIndex); + const marker = topicIndex > threadIndex ? ":topic:" : ":thread:"; + + const baseSessionKey = markerIndex === -1 ? sessionKey : sessionKey.slice(0, markerIndex); const threadIdRaw = - threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length); + markerIndex === -1 ? undefined : sessionKey.slice(markerIndex + marker.length); const sessionThreadId = threadIdRaw?.trim() || undefined; const { cfg, entry } = loadSessionEntry(sessionKey); @@ -42,7 +47,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { // Handles race condition where store wasn't flushed before restart const sentinelContext = payload.deliveryContext; let sessionDeliveryContext = deliveryContextFromSession(entry); - if (!sessionDeliveryContext && threadIndex !== -1 && baseSessionKey) { + if (!sessionDeliveryContext && markerIndex !== -1 && baseSessionKey) { const { entry: baseEntry } = loadSessionEntry(baseSessionKey); sessionDeliveryContext = deliveryContextFromSession(baseEntry); } @@ -74,6 +79,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { const threadId = payload.threadId ?? + parsedTarget?.threadId ?? // From resolveAnnounceTargetFromKey (extracts :topic:N) sessionThreadId ?? (origin?.threadId != null ? String(origin.threadId) : undefined); diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts index 54f772580..6827b24c4 100644 --- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts @@ -208,6 +208,39 @@ describe("gateway server chat", () => { | undefined; expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]); + const callsBeforeImageOnly = spy.mock.calls.length; + const reqIdOnly = "chat-img-only"; + ws.send( + JSON.stringify({ + type: "req", + id: reqIdOnly, + method: "chat.send", + params: { + sessionKey: "main", + message: "", + idempotencyKey: "idem-img-only", + attachments: [ + { + type: "image", + mimeType: "image/png", + fileName: "dot.png", + content: `data:image/png;base64,${pngB64}`, + }, + ], + }, + }), + ); + + const imgOnlyRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqIdOnly, 8000); + expect(imgOnlyRes.ok).toBe(true); + expect(imgOnlyRes.payload?.runId).toBeDefined(); + + await waitFor(() => spy.mock.calls.length > callsBeforeImageOnly, 8000); + const imgOnlyOpts = spy.mock.calls.at(-1)?.[1] as + | { images?: Array<{ type: string; data: string; mimeType: string }> } + | undefined; + expect(imgOnlyOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]); + const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); tempDirs.push(historyDir); testState.sessionStorePath = path.join(historyDir, "sessions.json"); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index c4046a08e..1cb4cc5c3 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -381,6 +381,31 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: ClawdbotConfig; }; } +// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data. +function mergeSessionEntryIntoCombined(params: { + combined: Record; + entry: SessionEntry; + agentId: string; + canonicalKey: string; +}) { + const { combined, entry, agentId, canonicalKey } = params; + const existing = combined[canonicalKey]; + + if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) { + combined[canonicalKey] = { + ...entry, + ...existing, + spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy), + }; + } else { + combined[canonicalKey] = { + ...existing, + ...entry, + spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy), + }; + } +} + export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): { storePath: string; store: Record; @@ -393,10 +418,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): { const combined: Record = {}; for (const [key, entry] of Object.entries(store)) { const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key); - combined[canonicalKey] = { - ...entry, - spawnedBy: canonicalizeSpawnedByForAgent(defaultAgentId, entry.spawnedBy), - }; + mergeSessionEntryIntoCombined({ + combined, + entry, + agentId: defaultAgentId, + canonicalKey, + }); } return { storePath, store: combined }; } @@ -408,13 +435,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): { const store = loadSessionStore(storePath); for (const [key, entry] of Object.entries(store)) { const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key); - // Merge with existing entry if present (avoid overwriting with less complete data) - const existing = combined[canonicalKey]; - combined[canonicalKey] = { - ...existing, - ...entry, - spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy), - }; + mergeSessionEntryIntoCombined({ + combined, + entry, + agentId, + canonicalKey, + }); } } diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 2e020ff8d..518da3c28 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -129,9 +129,10 @@ export async function checkGitUpdateStatus(params: { ).catch(() => null); const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null; - const dirtyRes = await runCommandWithTimeout(["git", "-C", root, "status", "--porcelain"], { - timeoutMs, - }).catch(() => null); + const dirtyRes = await runCommandWithTimeout( + ["git", "-C", root, "status", "--porcelain", "--", ":!dist/control-ui/"], + { timeoutMs }, + ).catch(() => null); const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null; const fetchOk = params.fetch diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index e33159326..6bf450d83 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -44,7 +44,7 @@ describe("runGatewayUpdate", () => { [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" }, - [`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" }, + [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: " M README.md" }, }); const result = await runGatewayUpdate({ @@ -69,7 +69,7 @@ describe("runGatewayUpdate", () => { [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" }, - [`git -C ${tempDir} status --porcelain`]: { stdout: "" }, + [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" }, [`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: { stdout: "origin/main", }, @@ -103,7 +103,7 @@ describe("runGatewayUpdate", () => { const { runner, calls } = createRunner({ [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, - [`git -C ${tempDir} status --porcelain`]: { stdout: "" }, + [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" }, [`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" }, [`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${stableTag}\n${betaTag}\n`, @@ -112,6 +112,7 @@ describe("runGatewayUpdate", () => { "pnpm install": { stdout: "" }, "pnpm build": { stdout: "" }, "pnpm ui:build": { stdout: "" }, + [`git -C ${tempDir} checkout -- dist/control-ui/`]: { stdout: "" }, "pnpm clawdbot doctor --non-interactive": { stdout: "" }, }); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 0a5196fd7..c73c3a7e7 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -346,10 +346,14 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const channel: UpdateChannel = opts.channel ?? "dev"; const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null; const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH; - gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 10 : 9) : 8; + gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9; const statusCheck = await runStep( - step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot), + step( + "clean check", + ["git", "-C", gitRoot, "status", "--porcelain", "--", ":!dist/control-ui/"], + gitRoot, + ), ); steps.push(statusCheck); const hasUncommittedChanges = @@ -654,6 +658,17 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< ); steps.push(uiBuildStep); + // Restore dist/control-ui/ to committed state to prevent dirty repo after update + // (ui:build regenerates assets with new hashes, which would block future updates) + const restoreUiStep = await runStep( + step( + "restore control-ui", + ["git", "-C", gitRoot, "checkout", "--", "dist/control-ui/"], + gitRoot, + ), + ); + steps.push(restoreUiStep); + const doctorStep = await runStep( step( "clawdbot doctor", diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 028e657cb..7f9f209ed 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -11,6 +11,12 @@ export const DEFAULT_AGENT_ID = "main"; export const DEFAULT_MAIN_KEY = "main"; export const DEFAULT_ACCOUNT_ID = "default"; +// Pre-compiled regex +const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; +const INVALID_CHARS_RE = /[^a-z0-9_-]+/g; +const LEADING_DASH_RE = /^-+/; +const TRAILING_DASH_RE = /-+$/; + function normalizeToken(value: string | undefined | null): string { return (value ?? "").trim().toLowerCase(); } @@ -52,14 +58,14 @@ export function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) return DEFAULT_AGENT_ID; // Keep it path-safe + shell-friendly. - if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); + if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase(); // Best-effort fallback: collapse invalid characters to "-" return ( trimmed .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/^-+/, "") - .replace(/-+$/, "") + .replace(INVALID_CHARS_RE, "-") + .replace(LEADING_DASH_RE, "") + .replace(TRAILING_DASH_RE, "") .slice(0, 64) || DEFAULT_AGENT_ID ); } @@ -67,13 +73,13 @@ export function normalizeAgentId(value: string | undefined | null): string { export function sanitizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) return DEFAULT_AGENT_ID; - if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); + if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase(); return ( trimmed .toLowerCase() - .replace(/[^a-z0-9_-]+/gi, "-") - .replace(/^-+/, "") - .replace(/-+$/, "") + .replace(INVALID_CHARS_RE, "-") + .replace(LEADING_DASH_RE, "") + .replace(TRAILING_DASH_RE, "") .slice(0, 64) || DEFAULT_AGENT_ID ); } @@ -81,13 +87,13 @@ export function sanitizeAgentId(value: string | undefined | null): string { export function normalizeAccountId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) return DEFAULT_ACCOUNT_ID; - if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); + if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase(); return ( trimmed .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/^-+/, "") - .replace(/-+$/, "") + .replace(INVALID_CHARS_RE, "-") + .replace(LEADING_DASH_RE, "") + .replace(TRAILING_DASH_RE, "") .slice(0, 64) || DEFAULT_ACCOUNT_ID ); } diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index d31885cfa..38b69f049 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -141,7 +141,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }); markDispatchIdle(); - if (!queuedFinal) { + const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0; + + if (!anyReplyDelivered) { if (prepared.isRoomish) { clearHistoryEntriesIfEnabled({ historyMap: ctx.channelHistories, diff --git a/src/tui/components/filterable-select-list.ts b/src/tui/components/filterable-select-list.ts index 67361bcf1..a7b197bf5 100644 --- a/src/tui/components/filterable-select-list.ts +++ b/src/tui/components/filterable-select-list.ts @@ -69,7 +69,7 @@ export class FilterableSelectList implements Component { lines.push(filterLabel + inputText); // Separator - lines.push(chalk.dim("─".repeat(width))); + lines.push(chalk.dim("─".repeat(Math.max(0, width)))); // Select list const listLines = this.selectList.render(width); diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index f8e07e790..54fc34918 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -214,7 +214,8 @@ export class SearchableSelectList implements Component { const maxValueWidth = Math.min(30, width - prefixWidth - 4); const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); const valueText = this.highlightMatch(truncatedValue, query); - const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText))); + const spacingWidth = Math.max(1, 32 - visibleWidth(valueText)); + const spacing = " ".repeat(spacingWidth); const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length; const remainingWidth = width - descriptionStart - 2; if (remainingWidth > 10) { diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index e137cb8c8..e11fedb71 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -103,7 +103,7 @@ bottom: 0; flex-shrink: 0; display: flex; - align-items: stretch; + flex-direction: column; gap: 12px; margin-top: auto; /* Push to bottom of flex container */ padding: 12px 4px 4px; @@ -111,6 +111,121 @@ z-index: 10; } +/* Image attachments preview */ +.chat-attachments { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px; + background: var(--panel); + border-radius: 8px; + border: 1px solid var(--border); + width: fit-content; + max-width: 100%; + align-self: flex-start; /* Don't stretch in flex column parent */ +} + +.chat-attachment { + position: relative; + width: 80px; + height: 80px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border); + background: var(--bg); +} + +.chat-attachment__img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.chat-attachment__remove { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 150ms ease-out; +} + +.chat-attachment:hover .chat-attachment__remove { + opacity: 1; +} + +.chat-attachment__remove:hover { + background: rgba(220, 38, 38, 0.9); +} + +.chat-attachment__remove svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; +} + +/* Light theme attachment overrides */ +:root[data-theme="light"] .chat-attachments { + background: #f8fafc; + border-color: rgba(16, 24, 40, 0.1); +} + +:root[data-theme="light"] .chat-attachment { + border-color: rgba(16, 24, 40, 0.15); + background: #fff; +} + +:root[data-theme="light"] .chat-attachment__remove { + background: rgba(0, 0, 0, 0.6); +} + +/* Message images (sent images displayed in chat) */ +.chat-message-images { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.chat-message-image { + max-width: 300px; + max-height: 200px; + border-radius: 8px; + object-fit: contain; + cursor: pointer; + transition: transform 150ms ease-out; +} + +.chat-message-image:hover { + transform: scale(1.02); +} + +/* User message images align right */ +.chat-group.user .chat-message-images { + justify-content: flex-end; +} + +/* Compose input row - horizontal layout */ +.chat-compose__row { + display: flex; + align-items: stretch; + gap: 12px; + flex: 1; +} + :root[data-theme="light"] .chat-compose { background: linear-gradient(to bottom, transparent, var(--bg-content) 20%); } diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index a78e0ef0a..27dfe62d1 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1303,9 +1303,8 @@ /* Chat compose */ .chat-compose { margin-top: 12px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; + display: flex; + flex-direction: column; gap: 10px; } diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 81aae3c88..c5f883716 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -8,11 +8,13 @@ import { normalizeBasePath } from "./navigation"; import type { GatewayHelloOk } from "./gateway"; import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; import type { ClawdbotApp } from "./app"; +import type { ChatAttachment, ChatQueueItem } from "./ui-types"; type ChatHost = { connected: boolean; chatMessage: string; - chatQueue: Array<{ id: string; text: string; createdAt: number }>; + chatAttachments: ChatAttachment[]; + chatQueue: ChatQueueItem[]; chatRunId: string | null; chatSending: boolean; sessionKey: string; @@ -45,15 +47,17 @@ export async function handleAbortChat(host: ChatHost) { await abortChatRun(host as unknown as ClawdbotApp); } -function enqueueChatMessage(host: ChatHost, text: string) { +function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) { const trimmed = text.trim(); - if (!trimmed) return; + const hasAttachments = Boolean(attachments && attachments.length > 0); + if (!trimmed && !hasAttachments) return; host.chatQueue = [ ...host.chatQueue, { id: generateUUID(), text: trimmed, createdAt: Date.now(), + attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined, }, ]; } @@ -61,19 +65,31 @@ function enqueueChatMessage(host: ChatHost, text: string) { async function sendChatMessageNow( host: ChatHost, message: string, - opts?: { previousDraft?: string; restoreDraft?: boolean }, + opts?: { + previousDraft?: string; + restoreDraft?: boolean; + attachments?: ChatAttachment[]; + previousAttachments?: ChatAttachment[]; + restoreAttachments?: boolean; + }, ) { resetToolStream(host as unknown as Parameters[0]); - const ok = await sendChatMessage(host as unknown as ClawdbotApp, message); + const ok = await sendChatMessage(host as unknown as ClawdbotApp, message, opts?.attachments); if (!ok && opts?.previousDraft != null) { host.chatMessage = opts.previousDraft; } + if (!ok && opts?.previousAttachments) { + host.chatAttachments = opts.previousAttachments; + } if (ok) { setLastActiveSessionKey(host as unknown as Parameters[0], host.sessionKey); } if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) { host.chatMessage = opts.previousDraft; } + if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) { + host.chatAttachments = opts.previousAttachments; + } scheduleChatScroll(host as unknown as Parameters[0]); if (ok && !host.chatRunId) { void flushChatQueue(host); @@ -86,7 +102,7 @@ async function flushChatQueue(host: ChatHost) { const [next, ...rest] = host.chatQueue; if (!next) return; host.chatQueue = rest; - const ok = await sendChatMessageNow(host, next.text); + const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments }); if (!ok) { host.chatQueue = [next, ...host.chatQueue]; } @@ -104,7 +120,12 @@ export async function handleSendChat( if (!host.connected) return; const previousDraft = host.chatMessage; const message = (messageOverride ?? host.chatMessage).trim(); - if (!message) return; + const attachments = host.chatAttachments ?? []; + const attachmentsToSend = messageOverride == null ? attachments : []; + const hasAttachments = attachmentsToSend.length > 0; + + // Allow sending with just attachments (no message text required) + if (!message && !hasAttachments) return; if (isChatStopCommand(message)) { await handleAbortChat(host); @@ -113,16 +134,21 @@ export async function handleSendChat( if (messageOverride == null) { host.chatMessage = ""; + // Clear attachments when sending + host.chatAttachments = []; } if (isChatBusy(host)) { - enqueueChatMessage(host, message); + enqueueChatMessage(host, message, attachmentsToSend); return; } await sendChatMessageNow(host, message, { previousDraft: messageOverride == null ? previousDraft : undefined, restoreDraft: Boolean(messageOverride && opts?.restoreDraft), + attachments: hasAttachments ? attachmentsToSend : undefined, + previousAttachments: messageOverride == null ? attachments : undefined, + restoreAttachments: Boolean(messageOverride && opts?.restoreDraft), }); } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index db29bd7ec..fe67c86f1 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -431,6 +431,7 @@ export function renderApp(state: AppViewState) { onSessionKeyChange: (next) => { state.sessionKey = next; state.chatMessage = ""; + state.chatAttachments = []; state.chatStream = null; state.chatStreamStartedAt = null; state.chatRunId = null; @@ -477,6 +478,8 @@ export function renderApp(state: AppViewState) { }, onChatScroll: (event) => state.handleChatScroll(event), onDraftChange: (next) => (state.chatMessage = next), + attachments: state.chatAttachments, + onAttachmentsChange: (next) => (state.chatAttachments = next), onSend: () => state.handleSendChat(), canAbort: Boolean(state.chatRunId), onAbort: () => void state.handleAbortChat(), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index f589c760c..069465e32 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -19,7 +19,7 @@ import type { SkillStatusReport, StatusSummary, } from "./types"; -import type { ChatQueueItem, CronFormState } from "./ui-types"; +import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; import type { SkillMessage } from "./controllers/skills"; import type { @@ -49,6 +49,7 @@ export type AppViewState = { chatLoading: boolean; chatSending: boolean; chatMessage: string; + chatAttachments: ChatAttachment[]; chatMessages: unknown[]; chatToolMessages: unknown[]; chatStream: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 0e21d283a..649e76342 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -24,7 +24,7 @@ import type { StatusSummary, NostrProfile, } from "./types"; -import { type ChatQueueItem, type CronFormState } from "./ui-types"; +import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; import type { @@ -129,6 +129,7 @@ export class ClawdbotApp extends LitElement { @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; @state() chatQueue: ChatQueueItem[] = []; + @state() chatAttachments: ChatAttachment[] = []; // Sidebar state for tool output viewing @state() sidebarOpen = false; @state() sidebarContent: string | null = null; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index ea1c7ffda..4a9ccec14 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -13,6 +13,48 @@ import { } from "./message-extract"; import { extractToolCards, renderToolCardSidebar } from "./tool-cards"; +type ImageBlock = { + url: string; + alt?: string; +}; + +function extractImages(message: unknown): ImageBlock[] { + const m = message as Record; + const content = m.content; + const images: ImageBlock[] = []; + + if (Array.isArray(content)) { + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const b = block as Record; + + if (b.type === "image") { + // Handle source object format (from sendChatMessage) + const source = b.source as Record | undefined; + if (source?.type === "base64" && typeof source.data === "string") { + const data = source.data as string; + const mediaType = (source.media_type as string) || "image/png"; + // If data is already a data URL, use it directly + const url = data.startsWith("data:") + ? data + : `data:${mediaType};base64,${data}`; + images.push({ url }); + } else if (typeof b.url === "string") { + images.push({ url: b.url }); + } + } else if (b.type === "image_url") { + // OpenAI format + const imageUrl = b.image_url as Record | undefined; + if (typeof imageUrl?.url === "string") { + images.push({ url: imageUrl.url }); + } + } + } + } + + return images; +} + export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) { return html`
@@ -163,6 +205,25 @@ function isAvatarUrl(value: string): boolean { ); } +function renderMessageImages(images: ImageBlock[]) { + if (images.length === 0) return nothing; + + return html` +
+ ${images.map( + (img) => html` + ${img.alt window.open(img.url, "_blank")} + /> + `, + )} +
+ `; +} + function renderGroupedMessage( message: unknown, opts: { isStreaming: boolean; showReasoning: boolean }, @@ -179,6 +240,8 @@ function renderGroupedMessage( const toolCards = extractToolCards(message); const hasToolCards = toolCards.length > 0; + const images = extractImages(message); + const hasImages = images.length > 0; const extractedText = extractTextCached(message); const extractedThinking = @@ -207,11 +270,12 @@ function renderGroupedMessage( )}`; } - if (!markdown && !hasToolCards) return nothing; + if (!markdown && !hasToolCards && !hasImages) return nothing; return html`
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} + ${renderMessageImages(images)} ${reasoningMarkdown ? html`
${unsafeHTML( toSanitizedMarkdownHtml(reasoningMarkdown), diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts new file mode 100644 index 000000000..c75ceefc4 --- /dev/null +++ b/ui/src/ui/controllers/chat.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + handleChatEvent, + type ChatEventPayload, + type ChatState, +} from "./chat"; + +function createState(overrides: Partial = {}): ChatState { + return { + client: null, + connected: true, + sessionKey: "main", + chatLoading: false, + chatMessages: [], + chatThinkingLevel: null, + chatSending: false, + chatMessage: "", + chatRunId: null, + chatStream: null, + chatStreamStartedAt: null, + lastError: null, + ...overrides, + }; +} + +describe("handleChatEvent", () => { + it("returns null when payload is missing", () => { + const state = createState(); + expect(handleChatEvent(state, undefined)).toBe(null); + }); + + it("returns null when sessionKey does not match", () => { + const state = createState({ sessionKey: "main" }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "other", + state: "final", + }; + expect(handleChatEvent(state, payload)).toBe(null); + }); + + it("returns null for delta from another run", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-user", + chatStream: "Hello", + }); + const payload: ChatEventPayload = { + runId: "run-announce", + sessionKey: "main", + state: "delta", + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + }; + expect(handleChatEvent(state, payload)).toBe(null); + expect(state.chatRunId).toBe("run-user"); + expect(state.chatStream).toBe("Hello"); + }); + + it("returns 'final' for final from another run (e.g. sub-agent announce) without clearing state", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-user", + chatStream: "Working...", + chatStreamStartedAt: 123, + }); + const payload: ChatEventPayload = { + runId: "run-announce", + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "Sub-agent findings" }], + }, + }; + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatRunId).toBe("run-user"); + expect(state.chatStream).toBe("Working..."); + expect(state.chatStreamStartedAt).toBe(123); + }); + + it("processes final from own run and clears state", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: "Reply", + chatStreamStartedAt: 100, + }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "main", + state: "final", + }; + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatRunId).toBe(null); + expect(state.chatStream).toBe(null); + expect(state.chatStreamStartedAt).toBe(null); + }); +}); diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 53027c6ea..518c35fe1 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,6 +1,7 @@ -import type { GatewayBrowserClient } from "../gateway"; import { extractText } from "../chat/message-extract"; +import type { GatewayBrowserClient } from "../gateway"; import { generateUUID } from "../uuid"; +import type { ChatAttachment } from "../ui-types"; export type ChatState = { client: GatewayBrowserClient | null; @@ -11,6 +12,7 @@ export type ChatState = { chatThinkingLevel: string | null; chatSending: boolean; chatMessage: string; + chatAttachments: ChatAttachment[]; chatRunId: string | null; chatStream: string | null; chatStreamStartedAt: number | null; @@ -43,17 +45,44 @@ export async function loadChatHistory(state: ChatState) { } } -export async function sendChatMessage(state: ChatState, message: string): Promise { +function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null { + const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl); + if (!match) return null; + return { mimeType: match[1], content: match[2] }; +} + +export async function sendChatMessage( + state: ChatState, + message: string, + attachments?: ChatAttachment[], +): Promise { if (!state.client || !state.connected) return false; const msg = message.trim(); - if (!msg) return false; + const hasAttachments = attachments && attachments.length > 0; + if (!msg && !hasAttachments) return false; const now = Date.now(); + + // Build user message content blocks + const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = []; + if (msg) { + contentBlocks.push({ type: "text", text: msg }); + } + // Add image previews to the message for display + if (hasAttachments) { + for (const att of attachments) { + contentBlocks.push({ + type: "image", + source: { type: "base64", media_type: att.mimeType, data: att.dataUrl }, + }); + } + } + state.chatMessages = [ ...state.chatMessages, { role: "user", - content: [{ type: "text", text: msg }], + content: contentBlocks, timestamp: now, }, ]; @@ -64,12 +93,29 @@ export async function sendChatMessage(state: ChatState, message: string): Promis state.chatRunId = runId; state.chatStream = ""; state.chatStreamStartedAt = now; + + // Convert attachments to API format + const apiAttachments = hasAttachments + ? attachments + .map((att) => { + const parsed = dataUrlToBase64(att.dataUrl); + if (!parsed) return null; + return { + type: "image", + mimeType: parsed.mimeType, + content: parsed.content, + }; + }) + .filter((a): a is NonNullable => a !== null) + : undefined; + try { await state.client.request("chat.send", { sessionKey: state.sessionKey, message: msg, deliver: false, idempotencyKey: runId, + attachments: apiAttachments, }); return true; } catch (err) { @@ -115,8 +161,17 @@ export function handleChatEvent( ) { if (!payload) return null; if (payload.sessionKey !== state.sessionKey) return null; - if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) + + // Final from another run (e.g. sub-agent announce): refresh history to show new message. + // See https://github.com/clawdbot/clawdbot/issues/1909 + if ( + payload.runId && + state.chatRunId && + payload.runId !== state.chatRunId + ) { + if (payload.state === "final") return "final"; return null; + } if (payload.state === "delta") { const next = extractText(payload.message); diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 428c4c381..196d6d114 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -1,7 +1,14 @@ +export type ChatAttachment = { + id: string; + dataUrl: string; + mimeType: string; +}; + export type ChatQueueItem = { id: string; text: string; createdAt: number; + attachments?: ChatAttachment[]; }; export const CRON_CHANNEL_LAST = "last"; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index dd61ca0ec..a9b4da572 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,7 +1,7 @@ import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; import type { SessionsListResult } from "../types"; -import type { ChatQueueItem } from "../ui-types"; +import type { ChatAttachment, ChatQueueItem } from "../ui-types"; import type { ChatItem, MessageGroup } from "../types/chat-types"; import { icons } from "../icons"; import { @@ -52,6 +52,9 @@ export type ChatProps = { splitRatio?: number; assistantName: string; assistantAvatar: string | null; + // Image attachments + attachments?: ChatAttachment[]; + onAttachmentsChange?: (attachments: ChatAttachment[]) => void; // Event handlers onRefresh: () => void; onToggleFocusMode: () => void; @@ -95,6 +98,82 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un return nothing; } +function generateAttachmentId(): string { + return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function handlePaste( + e: ClipboardEvent, + props: ChatProps, +) { + const items = e.clipboardData?.items; + if (!items || !props.onAttachmentsChange) return; + + const imageItems: DataTransferItem[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.startsWith("image/")) { + imageItems.push(item); + } + } + + if (imageItems.length === 0) return; + + e.preventDefault(); + + for (const item of imageItems) { + const file = item.getAsFile(); + if (!file) continue; + + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + const newAttachment: ChatAttachment = { + id: generateAttachmentId(), + dataUrl, + mimeType: file.type, + }; + const current = props.attachments ?? []; + props.onAttachmentsChange?.([...current, newAttachment]); + }; + reader.readAsDataURL(file); + } +} + +function renderAttachmentPreview(props: ChatProps) { + const attachments = props.attachments ?? []; + if (attachments.length === 0) return nothing; + + return html` +
+ ${attachments.map( + (att) => html` +
+ Attachment preview + +
+ `, + )} +
+ `; +} + export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || props.stream !== null; @@ -109,8 +188,11 @@ export function renderChat(props: ChatProps) { avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, }; + const hasAttachments = (props.attachments?.length ?? 0) > 0; const composePlaceholder = props.connected - ? "Message (↩ to send, Shift+↩ for line breaks)" + ? hasAttachments + ? "Add a message or paste more images..." + : "Message (↩ to send, Shift+↩ for line breaks, paste images)" : "Connect to the gateway to start chatting…"; const splitRatio = props.splitRatio ?? 0.6; @@ -217,7 +299,12 @@ export function renderChat(props: ChatProps) { ${props.queue.map( (item) => html`
-
${item.text}
+
+ ${item.text || + (item.attachments?.length + ? `Image (${item.attachments.length})` + : "")} +
- + ${renderAttachmentPreview(props)} +
+ +
+ + +