Compare commits

..
Author SHA1 Message Date
Peter Steinberger 8aa2c0229d docs: update changelog for rate-limit fallback 2026-01-05 22:33:55 +01:00
Peter Steinberger 485481895e style: format PR 223 changes 2026-01-05 22:33:51 +01:00
CI f01784369d fix(model): treat quota errors as rate limits 2026-01-05 19:16:38 +01:00
CI 0a853df6c9 fix(model): retry with supported thinking level 2026-01-05 18:54:23 +01:00
CI 25ada49e36 fix: retry model fallback on rate limits 2026-01-05 18:04:36 +01:00
Peter Steinberger 234059811c feat(ui): add chat reading indicator 2026-01-05 16:16:34 +00:00
Peter Steinberger 7f3f73af1c fix: show model auth in status 2026-01-05 15:50:18 +01:00
Peter Steinberger bf6aad1965 fix(ci): format directive-handling 2026-01-05 14:34:55 +00:00
Django Navarro 977467066d fix(coding-agent): close PR template code block correctly
The outer fence (4 backticks) was closing prematurely after the bash
example, leaving the rest of the template (Feature intent through
Submitted by Razor) rendered as prose instead of inside the code block.

Fixed by moving the closing fence to the end of the full template.
2026-01-05 14:33:21 +00:00
Peter Steinberger 0c37f27a4a fix: show /model auth source 2026-01-05 14:14:26 +00:00
Peter Steinberger cffbe79077 fix: add /model list alias 2026-01-05 14:11:33 +00:00
Peter Steinberger bb959684fe fix(tui): support pi-tui 0.36 key exports 2026-01-05 13:59:50 +00:00
Peter Steinberger 8e8d07cbf4 fix(ci): satisfy formatter checks 2026-01-05 13:55:53 +00:00
Peter Steinberger 5f4936dce5 fix(wizard): type OAuth provider login 2026-01-05 13:55:46 +00:00
Peter Steinberger a9bcf88bfa refactor(tui): use key helper predicates 2026-01-05 13:55:43 +00:00
Peter Steinberger f24fe4e9cd fix(whatsapp): reconnect on crypto unhandled rejection 2026-01-05 13:55:37 +00:00
Peter Steinberger 7619534bc0 feat(groups): resolve requireMention for discord/slack 2026-01-05 13:55:32 +00:00
Peter Steinberger ce68d82dfa fix: widen /model key masking 2026-01-05 13:50:45 +00:00
Peter Steinberger 5163886694 fix: show auth in /model list 2026-01-05 13:49:25 +00:00
Peter Steinberger d9103b387a fix: sync gateway mode via gateway config 2026-01-05 06:39:37 +00:00
Peter Steinberger 724354b9f0 fix: make tool list dynamic in system prompt 2026-01-05 06:36:24 +00:00
Peter Steinberger 79561d07a0 fix: allow openai-codex in onboarding types 2026-01-05 07:33:33 +01:00
Peter Steinberger 30038f7d37 fix: custom connections sidebar 2026-01-05 07:25:13 +01:00
Peter Steinberger 5431a9c692 fix: clean status + help + mid alias 2026-01-05 07:24:51 +01:00
Peter Steinberger 5aebc07369 chore: remove stale a2ui bundle hash 2026-01-05 06:17:06 +00:00
Peter Steinberger 7c51efe8f8 fix: prefer gateway config in local mode 2026-01-05 06:16:48 +00:00
Peter Steinberger 1119f2003e fix: preserve JSON5 config parsing 2026-01-05 06:16:48 +00:00
Peter Steinberger 9be1a14a08 fix: resolve agent dir in onboarding 2026-01-05 07:12:13 +01:00
Peter Steinberger 17ef7b3b0e fix: status runtime + help 2026-01-05 07:07:17 +01:00
Peter Steinberger 0d0da2e297 fix: remove sidebar toggle toolbar item safely 2026-01-05 06:49:57 +01:00
Peter Steinberger 82c16a8bed fix: remove settings sidebar toggle 2026-01-05 06:48:49 +01:00
Peter Steinberger 2c0f3a2887 docs: update auth docs 2026-01-05 06:46:20 +01:00
Peter Steinberger bc74e7cd9b docs: default mac build arch to host 2026-01-05 06:45:23 +01:00
Peter Steinberger 1545ac0003 chore: update a2ui bundle hash 2026-01-05 06:39:08 +01:00
Peter Steinberger 160fd1d8b6 docs: clarify a2ui bundle hash handling 2026-01-05 06:39:03 +01:00
Peter Steinberger 4305472787 docs: document sandbox media staging 2026-01-05 06:37:12 +01:00
Peter Steinberger 545f52d7d5 fix: hide settings toolbar row 2026-01-05 06:36:34 +01:00
Peter Steinberger a40fd5219c docs: clarify unrecognized file handling 2026-01-05 06:36:30 +01:00
Peter Steinberger 48322f7174 docs: highlight oauth and any-os support 2026-01-05 06:35:43 +01:00
Peter Steinberger f3cb41511d feat: add openai codex oauth 2026-01-05 06:31:45 +01:00
Peter Steinberger bce62f8c0f chore: update pi dependencies 2026-01-05 06:19:35 +01:00
Peter Steinberger 995f5959af fix: stage sandbox media for inbound attachments 2026-01-05 06:18:11 +01:00
Peter Steinberger a7d33c06f9 refactor: align agent lifecycle 2026-01-05 05:55:02 +01:00
Peter Steinberger ce5fd84432 docs: note settings sidebar layout 2026-01-05 05:54:37 +01:00
Peter Steinberger a89204362e fix: use sidebar settings layout 2026-01-05 05:54:21 +01:00
Peter Steinberger bcdaba1d48 chore: format custom editor 2026-01-05 05:32:30 +01:00
Peter Steinberger 95d9160e27 fix: avoid settings toolbar overflow 2026-01-05 05:32:14 +01:00
Peter Steinberger 8a31a868c0 fix: honor tailnet bind for macOS gateway endpoint 2026-01-05 05:30:40 +01:00
Peter Steinberger 870473be85 chore: update deps 2026-01-05 05:27:58 +01:00
Peter Steinberger b593ccb122 chore: update appcast for 2026.1.5-3 2026-01-05 05:14:07 +01:00
Peter Steinberger 35a32b31bd docs: note notarize env vars 2026-01-05 04:26:05 +01:00
Peter Steinberger 5dbbad0452 chore: default mac packaging to notarize 2026-01-05 04:22:58 +01:00
Peter Steinberger 19affcac90 chore: update appcast for 2026.1.5-3 2026-01-05 04:00:08 +01:00
Peter Steinberger 92f95abdcf docs: link to hosted docs 2026-01-05 03:59:58 +01:00
Peter Steinberger 477fa49a30 fix: include missing dist dirs in npm pack 2026-01-05 03:56:57 +01:00
Peter Steinberger 36b96c2b28 chore: update appcast for 2026.1.5-2 2026-01-05 03:53:09 +01:00
Peter Steinberger 2eb78b8da7 fix: resolve qrcode ESM import for Node 25 2026-01-05 03:47:57 +01:00
Peter Steinberger 3110e37db4 chore: update appcast for 2026.1.5-1 2026-01-05 03:32:53 +01:00
Peter Steinberger 93bb0257f0 fix: include sessions in npm pack and update qrcode import 2026-01-05 03:28:25 +01:00
Peter Steinberger 197b8f7c3b chore: update appcast for 2026.1.5 2026-01-05 03:13:14 +01:00
Peter Steinberger deba1b6739 style: format daemon program args test 2026-01-05 02:54:08 +01:00
Peter Steinberger aab98a6d18 test: fix daemon program args fs mocks 2026-01-05 02:51:56 +01:00
Peter Steinberger 849a008f34 test: avoid max port in browser server tests 2026-01-05 02:50:48 +01:00
Peter Steinberger 8791e46cf3 fix: resolve npx gateway daemon install 2026-01-05 02:48:25 +01:00
Peter Steinberger b779029517 fix: hide duplicate doc titles 2026-01-05 02:45:14 +01:00
Peter Steinberger 9c039e8356 docs: consolidate 2026.1.5 changelog 2026-01-05 02:39:42 +01:00
Peter Steinberger 4aeba76741 docs: add ClawdHub mention 2026-01-05 02:34:02 +01:00
Peter Steinberger d92a9e351e style: fix linting order and formatting 2026-01-05 02:33:59 +01:00
Peter Steinberger a1acd7dae8 chore: add qrcode-terminal vendor module stubs 2026-01-05 02:33:55 +01:00
Peter Steinberger 67420e9a81 fix: allow group activation for allowFrom senders 2026-01-05 02:33:51 +01:00
Peter Steinberger e4335ea094 fix: bundle qr renderer in relay 2026-01-05 02:19:49 +01:00
Peter Steinberger 0c632f4855 fix: prefer tailnet IP for local gateway calls 2026-01-05 02:19:26 +01:00
Peter Steinberger a322075764 fix: use id for cron tool params 2026-01-05 02:15:11 +01:00
Peter Steinberger 359cb66e68 fix: allow wildcard control commands 2026-01-05 02:06:18 +01:00
Peter Steinberger 00370139a5 docs: clarify derived port mapping 2026-01-05 02:03:29 +01:00
Peter Steinberger 17422608b2 fix: gate /activation to owners in groups 2026-01-05 02:03:29 +01:00
Peter Steinberger f871563f37 chore: sync generated protocol swift 2026-01-05 00:54:54 +00:00
Peter Steinberger 85549ac3b6 fix: gate group activation by owner 2026-01-05 00:48:16 +00:00
Peter Steinberger 1bad96aa2b style: tidy auto-reply imports and formatting 2026-01-05 01:46:16 +01:00
Peter Steinberger b0dcdc4982 fix: avoid mixing ?? and || in discord monitor 2026-01-05 01:46:16 +01:00
Shadow 13d39b8fb1 Fix discord/slack monitor compile errors 2026-01-04 18:44:19 -06:00
Peter Steinberger 50d26d827e fix: avoid duplicate senderName in slack monitor 2026-01-05 00:43:31 +00:00
Peter Steinberger 3fc9acc105 Merge remote-tracking branch 'mcinteerj/fix/whatsapp-offline-read-receipts' 2026-01-05 01:38:24 +01:00
Peter Steinberger d58828ebd7 test: relax timeouts for slow runs 2026-01-05 01:36:30 +01:00
Peter Steinberger f90eea5195 docs: add changelog entry for WhatsApp offline read receipts 2026-01-05 01:36:30 +01:00
Jake 3f40f4ab54 style: fix lint issues 2026-01-05 01:36:30 +01:00
Jake 65a55b97e0 WhatsApp: mark offline/history messages as read 2026-01-05 01:36:29 +01:00
Peter Steinberger 852f947b44 fix: unify control command handling 2026-01-05 01:31:36 +01:00
Peter Steinberger 54ad1ead80 docs: document --dev/--profile 2026-01-05 01:27:13 +01:00
Peter Steinberger c6de1b1f7d feat: add --dev/--profile CLI profiles 2026-01-05 01:27:13 +01:00
Peter Steinberger f601dac30d style: tidy tool schema normalization 2026-01-05 01:27:13 +01:00
Peter Steinberger 2bbf2698cb fix(ui): render markdown in tool result cards 2026-01-05 01:27:13 +01:00
Peter Steinberger f6097bc6e3 fix(ui): avoid overlapping guild action buttons 2026-01-05 01:27:13 +01:00
Peter Steinberger 39e482414a chore: apply upstream autostash 2026-01-05 00:26:52 +00:00
Peter Steinberger d6933b074a fix: make control ui chat scroll page 2026-01-05 00:18:18 +00:00
Peter Steinberger bcdfe461d4 fix(ci): resolve lint and docs build failures 2026-01-05 00:17:14 +00:00
Peter Steinberger 16ce76307e docs(faq): align model ids with shorthands 2026-01-05 01:11:29 +01:00
Peter Steinberger c37b4c18e0 docs: document env loading + shell fallback 2026-01-05 01:11:29 +01:00
Peter Steinberger 2899a986a8 feat(config): add default model shorthands 2026-01-05 01:11:29 +01:00
Peter Steinberger 7a63b4995b feat: opt-in login shell env fallback 2026-01-05 01:11:29 +01:00
Peter Steinberger 7a36e6fcd9 fix(discord): avoid duplicate block replies 2026-01-05 01:11:29 +01:00
Peter Steinberger 77b19643e2 fix: load global .env fallback 2026-01-05 01:11:29 +01:00
Josh Palmer aa45f512f4 fix sessions dir from state env
what: use CLAWDBOT_STATE_DIR/CLAWDIS_STATE_DIR for session transcripts

why: isolate multi-instance gateways

tests: not run
2026-01-05 00:51:11 +01:00
Peter Steinberger 59dfe0337d docs(changelog): note OpenAI duplicate reply fix 2026-01-05 00:39:34 +01:00
Peter Steinberger 4963432777 fix(discord): avoid duplicate replies on repeated message_end 2026-01-05 00:35:42 +01:00
Josh PalmerandClaude Opus 4.5 a737bfaab4 docs: make nix-clawdbot link more prominent
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:24:33 +01:00
Josh PalmerandClaude Opus 4.5 67c89e00c5 docs: add Nix installation guide and navigation
- Expand docs/nix.md from runtime-only to full installation guide
- Reference nix-clawdbot as the recommended Nix setup path
- Add "Installation" section to docs.json navigation (wizard, nix, docker, setup)
- Add Nix link to README quick links

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:22:15 +01:00
Peter Steinberger 8f572ab361 docs: add WhatsApp Business tip for same-phone setup 2026-01-04 23:17:26 +00:00
Peter Steinberger 435edaf997 fix: OpenAI tool schema compatibility 2026-01-05 00:15:55 +01:00
Peter Steinberger c3c9dee65e docs(tools): document agent tool allow/deny 2026-01-05 00:05:35 +01:00
Jake 946b32c842 fix(whatsapp): suppress typing during heartbeats
- Prevent typing indicator during heartbeat runs
- Add regression tests

Co-authored-by: Jake <mcinteerj@gmail.com>
2026-01-04 23:03:36 +00:00
Peter Steinberger 4dd515b65f fix(tools): honor agent tool denylist without sandbox 2026-01-05 00:02:14 +01:00
Andranik SahakyanandPeter Steinberger d9a9f6db7d fix(mac): add Sendable conformance to generated Swift protocol structs (#195)
* fix(mac): add Sendable conformance to generated Swift protocol structs

* fix(mac): make generated protocol types Sendable

* chore(mac): drop redundant Sendable extensions

* docs(changelog): thank @andranik-sahakyan for Sendable fix

* chore(swiftformat): exclude generated protocol models

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-04 22:39:21 +00:00
Jake a6f7ab499f style: fix lint issues 2026-01-05 11:25:14 +13:00
Jake 3c7c819e9b WhatsApp: mark offline/history messages as read 2026-01-05 10:23:22 +13:00
Jake 58dd2e9514 fix: also suppress typing indicators in agent-runner during heartbeats 2026-01-05 10:23:22 +13:00
Jake 424b7fe493 fix: prevent typing indicator during heartbeat background tasks 2026-01-05 10:23:22 +13:00
Peter Steinberger 1657c5e3d2 fix: route system events per session 2026-01-04 22:11:04 +01:00
Peter Steinberger 2ceceb8c25 style(ts): normalize type-only imports 2026-01-04 21:56:16 +01:00
Peter Steinberger 39be40cd23 chore(release): bump to 2026.1.5 2026-01-04 21:54:04 +01:00
Peter Steinberger 0faa200924 fix(onboarding): auto-build Control UI assets 2026-01-04 21:53:23 +01:00
Peter Steinberger ff605194ef fix(ui): render markdown in chat 2026-01-04 21:51:26 +01:00
Peter Steinberger 78998dba9e feat: add image model config + tool 2026-01-04 19:35:49 +01:00
Peter Steinberger 0716a624a8 chore(lint): apply biome fixes 2026-01-04 19:08:22 +01:00
Peter Steinberger e005dcb8e7 fix(oauth): derive oauth.json from state dir 2026-01-04 19:08:13 +01:00
Peter Steinberger 3300fba57c docs(discord): add bot creation guide 2026-01-04 19:01:04 +01:00
Nachx639andClaude Opus 4.5 fa3a768a3a fix(macos): remove authorizedWhenInUse references (iOS-only API) (#165)
CLAuthorizationStatus.authorizedWhenInUse only exists on iOS. On macOS,
location services only support .authorizedAlways. This was causing
compilation warnings and potentially incorrect behavior.

Fixes:
- GeneralSettings.swift: Remove authorizedWhenInUse check
- PermissionManager.swift: Update ensureLocation and status methods
- MacNodeRuntime.swift: Update location permission check

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:58:01 +00:00
Peter Steinberger da4f3211b8 chore: refresh version references 2026-01-04 18:49:36 +01:00
Peter Steinberger d85f91d247 feat: guide control ui access without gui 2026-01-04 18:49:36 +01:00
Peter Steinberger 5dcf43d6ad test: cover macos location permission status 2026-01-04 18:49:36 +01:00
Shadow 50cecd8210 Discord: remove duplicate message ids 2026-01-04 11:36:18 -06:00
Onur Solmaz 7dc8ea815e docs: add macOS launchd instructions for stopping gateway
Unify the "Processes keep restarting" FAQ section to cover both macOS
(launchd) and Linux (systemd). Previously only covered Linux.

Also update the "Clean uninstall" section with macOS commands.
2026-01-04 17:29:39 +00:00
Peter Steinberger 2110cac5d6 fix(cli): add config alias and reduce probe noise 2026-01-04 17:23:34 +00:00
Peter Steinberger 9eee832282 chore: update protocol swift models 2026-01-04 18:16:36 +01:00
Peter Steinberger 5d17b84e8a test(gateway): allow webchat chat.send without node 2026-01-04 17:12:49 +00:00
Peter Steinberger 3fed0ac2e8 fix(ui): show chat send errors 2026-01-04 17:12:49 +00:00
Peter Steinberger 2694e59ba6 fix(gateway): allow Control UI chat without node 2026-01-04 17:12:49 +00:00
Peter Steinberger 266fd748d0 fix(ui): allow Control UI chat without node 2026-01-04 17:12:49 +00:00
Peter Steinberger 564cc9359d style: swiftformat gateway models 2026-01-04 18:12:33 +01:00
Peter Steinberger ff46f8ce58 chore: format models CLI 2026-01-04 18:11:41 +01:00
Peter Steinberger 8e5153ba10 docs(changelog): add android notification tap fix 2026-01-04 18:05:26 +01:00
Peter Steinberger e2c6a96cd3 test(android): cover notification tap intent 2026-01-04 18:05:26 +01:00
Manuel Jiménez TorresandClaude Opus 4.5 7200dabfb2 feat(android): open app when tapping foreground service notification
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:05:26 +01:00
212 changed files with 7614 additions and 2317 deletions

No files matched your search

+1 -1
View File
@@ -48,4 +48,4 @@
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
+3 -1
View File
@@ -45,13 +45,15 @@
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) instead of manual conflict resolution.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are in `~/.profile`.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
+67 -1
View File
@@ -5,8 +5,74 @@
## Unreleased
### Fixes
- Model: retry fallback on rate-limit/quota errors and unsupported thinking levels. (#223) — thanks @augard
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
- TUI: migrate key handling to the updated pi-tui Key matcher API.
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json.
- Model: `/model` list shows auth source (masked key or OAuth email) per provider.
- Model: `/model list` is an alias for `/model`.
- Model: `/model` output now includes auth source location (env/auth.json/models.json).
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
- Control UI: show a reading indicator bubble while the assistant is responding.
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
- Status: show model auth source (api-key/oauth).
### Maintenance
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
## 2026.1.5-3
### Fixes
- NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid `ERR_MODULE_NOT_FOUND` in Node 25 npx installs.
## 2026.1.5-2
### Fixes
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/*.js` modules.
## 2026.1.5-1
### Fixes
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/index.js`.
## 2026.1.5
### Highlights
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
- Agent tools: new `image` tool routed to the image model (when configured).
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
- Docs: document built-in model shorthands + precedence (user config wins).
### Fixes
- Control UI: render Markdown in tool result cards.
- Control UI: prevent overlapping action buttons in Discord guild rules on narrow layouts.
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
- Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
- Cron tool uses `id` for update/remove/run/runs (aligns with gateway params). (#180) — thanks @adamgall
- Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame).
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan
- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files.
- macOS: parse JSON5 config safely to avoid wiping user settings when comments are present.
- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj
- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj
- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT).
- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176).
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env).
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
- Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
- Control UI: render Markdown in chat messages (sanitized).
## 2026.1.4
+28 -3
View File
@@ -20,11 +20,11 @@ It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMe
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
Website: https://clawd.me · Docs: https://docs.clawdbot.com/ · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Nix: [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
Subscriptions: **Anthropic (Claude Pro/Max)** and **OpenAI (ChatGPT/Codex)** are supported via OAuth. See `docs/onboarding.md`.
## Highlights
@@ -68,7 +68,7 @@ Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OA
- Nix mode for declarative config; Docker-based installs.
- Health, doctor migrations, structured logging, release tooling.
## Changes since 2.0.0-beta5 (2026-01-03)
## Changes since 2026.1.4 (2026-01-04)
### Highlights
- Project rename completed: CLIs, paths, bundle IDs, env vars, and docs unified on Clawdbot.
@@ -111,6 +111,12 @@ Your surfaces
└─ iOS node (Canvas + voice)
```
## Skills registry (ClawdHub)
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
https://clawdhub.com
## Quick start (from source)
Runtime: **Node ≥22** + **pnpm**.
@@ -214,6 +220,25 @@ Minimal `~/.clawdbot/clawdbot.json`:
}
```
Env vars: loaded from `.env` in the current working directory, plus a global fallback at `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`) without overriding existing values.
Optional: import missing keys from your login shell env (sources your shell profile) via config or env var:
```json5
{
env: {
shellEnv: {
enabled: true,
timeoutMs: 15000
}
}
}
```
- Env var: `CLAWDBOT_LOAD_SHELL_ENV=1`
- Timeout override: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`
- Behavior: only imports known/expected keys, never overrides existing `process.env`.
### WhatsApp
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
+41 -198
View File
@@ -1,212 +1,55 @@
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<title>Clawdis</title>
<item>
<title>2.0.0-beta5</title>
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>2765</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
<sparkle:version>3095</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2.0.0-beta5</h2>
<h3>Fixed</h3>
<ul>
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
<li>Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).</li>
<li>Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).</li>
<li>Tests: cover read tool image metadata + text output.</li>
<li>Tests: add queue mode coverage (collect/followup + directive parsing).</li>
</ul>
<h3>Breaking</h3>
<ul>
<li>Skills config schema moved under <code>skills.*</code>:</li>
</ul>
- <code>skillsLoad.extraDirs</code> → <code>skills.load.extraDirs</code>
- <code>skillsInstall.*</code> → <code>skills.install.*</code>
- per-skill config map moved to <code>skills.entries</code> (e.g. <code>skills.peekaboo.enabled</code> → <code>skills.entries.peekaboo.enabled</code>)
- new optional bundled allowlist: <code>skills.allowBundled</code> (only affects bundled skills)
<ul>
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdbot doctor</code> to migrate).</li>
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdbot doctor</code> to migrate).</li>
</ul>
<h3>Features</h3>
<ul>
<li>Discord: expand <code>discord</code> tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.</li>
<li>Discord/Telegram: add reply tags (<code>[[reply_to_current]]</code>, <code>[[reply_to:<id>]]</code>) with per-provider <code>replyToMode</code> (off|first|all) for native threaded replies.</li>
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDBOT_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
<li>Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.</li>
<li>Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.</li>
<li>Signal: add <code>signal-cli</code> JSON-RPC support for send/receive via the Signal provider.</li>
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
<li>Discord: allow agent-triggered reactions via <code>clawdbot_discord</code> when enabled, and surface message ids in context.</li>
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
<li>Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).</li>
<li>Tests: add a Z.AI live test gate for smoke validation when keys are present.</li>
<li>macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.</li>
<li>CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.</li>
<li>CLI: add ASCII banner header to wizard entry points.</li>
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
<li>CLI: enhance <code>clawdbot tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
<li>Tests: add a Docker-based onboarding E2E harness.</li>
<li>Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.</li>
<li>Browser tools: add remote CDP URL support, Linux launcher options (<code>executablePath</code>, <code>noSandbox</code>), and surface <code>cdpUrl</code> in status.</li>
<li>Skills: add tmux-first coding-agent skill + <code>requires.anyBins</code> gate for multi-CLI setup (thanks @sreekaransrinath).</li>
</ul>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3>
<ul>
<li>Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).</li>
<li>macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.</li>
<li>Chat UI: keep the chat scrolled to the latest message after switching sessions.</li>
<li>Chat UI: show rich session display names in Web Chat + SwiftUI + Android.</li>
<li>Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.</li>
<li>Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).</li>
<li>Providers: make outbound text chunk limits configurable via <code>*.textChunkLimit</code> (defaults remain 4000/Discord 2000).</li>
<li>CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.</li>
<li>Control UI: accept a <code>?token=</code> URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.</li>
<li>Agent prompt: remove hardcoded user name in system prompt example.</li>
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
<li>Gateway CLI: read <code>CLAWDBOT_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
<li>Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.</li>
<li>Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.</li>
<li>Discord: include recent guild context when replying to mentions and add <code>discord.historyLimit</code> to tune how many messages are captured.</li>
<li>Discord: include author tag + id in group context <code>[from:]</code> lines for ping-ready replies (thanks @thewilloftheshadow).</li>
<li>Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).</li>
<li>Discord: preserve newlines when stripping reply tags from agent output.</li>
<li>Gateway: fix TypeScript build by aligning hook mapping <code>channel</code> types and removing a dead Group DM branch in Discord monitor.</li>
<li>Skills: switch imsg installer to brew tap formula.</li>
<li>Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.</li>
<li>Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.</li>
<li>Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.</li>
<li>CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.</li>
<li>CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).</li>
<li>CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.</li>
<li>CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.</li>
<li>CLI onboarding: always prompt for WhatsApp <code>whatsapp.allowFrom</code> and print (optionally open) the Control UI URL when done.</li>
<li>CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).</li>
<li>macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.</li>
<li>macOS: keep config writes on the main actor to satisfy Swift concurrency rules.</li>
<li>macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient <code>cancelled</code> device refresh errors, and auto-recover the control channel on disconnect.</li>
<li>macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.</li>
<li>macOS menu: tighten session row padding and time out session preview loading with cached fallback.</li>
<li>macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.</li>
<li>macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b</li>
<li>macOS codesign: include camera entitlement so permission prompts work in the menu bar app.</li>
<li>Agent tools: bash tool supports real TTY via <code>stdinMode: "pty"</code> with node-pty, warning + fallback on load/start failure.</li>
<li>Agent tools: map <code>camera.snap</code> JPEG payloads to <code>image/jpeg</code> to avoid MIME mismatch errors.</li>
<li>Tests: cover <code>camera.snap</code> MIME mapping to prevent image/png vs image/jpeg mismatches.</li>
<li>macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.</li>
<li>Camera snap: add <code>delayMs</code> parameter (default 2000ms on macOS) to improve exposure reliability.</li>
<li>Camera: add <code>camera.list</code> and optional <code>deviceId</code> selection for snaps/clips.</li>
<li>Tests: cover camera device selection params in CLI + agent tools.</li>
<li>macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b</li>
<li>macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b</li>
<li>macOS remote: route settings through gateway config and avoid local config reads in remote mode.</li>
<li>Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).</li>
<li>Telegram: honor per-group mention gating defaults/overrides via <code>telegram.groups</code> and <code>"*"</code> defaults (thanks @joshp123).</li>
<li>Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl</li>
<li>Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.</li>
<li>Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.</li>
<li>Cron: prevent <code>every</code> schedules without an anchor from firing in a tight loop (thanks @jamesgroat).</li>
<li>Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock</li>
<li>Docs/agent tools: clarify that browser <code>wait</code> should be avoided by default and used only in exceptional cases.</li>
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
<li>Browser CLI: add <code>clawdbot browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
<li>CLI: tighten onboarding prompt typing to keep bun builds green.</li>
<li>macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.</li>
<li>macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.</li>
<li>macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).</li>
<li>macOS Debug: add an icon for the App Logging submenu.</li>
<li>macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.</li>
<li>macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.</li>
<li>macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).</li>
<li>macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.</li>
<li>macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.</li>
<li>macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.</li>
<li>WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.</li>
<li>Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).</li>
<li>Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.</li>
<li>Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.</li>
<li>iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.</li>
<li>ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.</li>
<li>Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).</li>
<li>Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).</li>
<li>Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.</li>
<li>Chat UI: user bubbles use <code>ui.seamColor</code> (fallback to a calmer default blue).</li>
<li>Android Chat UI: use <code>onPrimary</code> for user bubble text to preserve contrast (thanks @Syhids).</li>
<li>Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.</li>
<li>Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.</li>
<li>Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.</li>
<li>Control UI: soften chat bubble text opacity for calmer readability.</li>
<li>macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.</li>
<li>macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).</li>
<li>Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).</li>
<li>iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).</li>
<li>iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.</li>
<li>iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.</li>
<li>iOS/Android Talk Mode: explicitly <code>chat.subscribe</code> when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.</li>
<li>Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.</li>
<li>Gateway: <code>voice.transcript</code> now also maps agent bus output to <code>chat</code> events, ensuring chat UIs refresh for voice-triggered runs.</li>
<li>Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.</li>
<li>iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.</li>
<li>Gateway config: inject <code>talk.apiKey</code> from <code>ELEVENLABS_API_KEY</code>/shell profile so nodes can fetch it on demand.</li>
<li>Canvas A2UI: tag requests with <code>platform=android|ios|macos</code> and boost Android canvas background contrast.</li>
<li>iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).</li>
<li>macOS menu: device list now uses <code>node.list</code> (devices only; no agent/tool presence entries).</li>
<li>macOS menu: device list now shows connected nodes only.</li>
<li>macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.</li>
<li>macOS menu: split device platform/version across first and second rows for better fit.</li>
<li>macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.</li>
<li>Canvas A2UI: polish the debug status HUD styling.</li>
<li>iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.</li>
<li>iOS Talk Mode: avoid audio tap queue assertions when starting recognition.</li>
<li>macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).</li>
<li>macOS remote: harden SSH tunnel recovery/logging, honor <code>gateway.remote.url</code> port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.</li>
<li>iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.</li>
<li>macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).</li>
<li>iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.</li>
<li>iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.</li>
<li>iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.</li>
<li>iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.</li>
<li>iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.</li>
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdbot sessions dir.</li>
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2.0.0-beta5/Clawdbot-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
</item>
<item>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3091</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
</item>
<item>
<title>2026.1.5-2</title>
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3089</sparkle:version>
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
</item>
</channel>
</rss>
+2 -2
View File
@@ -68,7 +68,7 @@ dependencies {
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.2")
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.webkit:webkit:1.15.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
@@ -101,7 +101,7 @@ dependencies {
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
}
tasks.withType<Test>().configureEach {
@@ -515,7 +515,6 @@ class NodeRuntime(context: Context) {
}
private fun hasBackgroundLocationPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@@ -1,10 +1,12 @@
package com.clawdbot.android.node
import android.annotation.SuppressLint
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.CancellationSignal
import androidx.core.content.ContextCompat
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
@@ -64,6 +66,15 @@ class LocationCaptureManager(private val context: Context) {
providers: List<String>,
maxAgeMs: Long?,
): Location? {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!fineOk && !coarseOk) {
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
}
val now = System.currentTimeMillis()
val candidates =
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
@@ -72,12 +83,20 @@ class LocationCaptureManager(private val context: Context) {
return freshest
}
@SuppressLint("MissingPermission")
private suspend fun requestCurrent(
manager: LocationManager,
providers: List<String>,
timeoutMs: Long,
): Location {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!fineOk && !coarseOk) {
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
}
val resolved =
providers.firstOrNull { manager.isProviderEnabled(it) }
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
@@ -133,7 +133,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
if (requestedMode != null) {
viewModel.setLocationMode(requestedMode)
if (requestedMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (requestedMode == LocationMode.Always) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@@ -191,7 +191,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
if (fineOk || coarseOk) {
viewModel.setLocationMode(targetMode)
if (targetMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (targetMode == LocationMode.Always) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@@ -28,9 +28,9 @@ import androidx.compose.ui.unit.dp
fun StatusPill(
bridge: BridgeState,
voiceEnabled: Boolean,
activity: StatusActivity? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier,
activity: StatusActivity? = null,
) {
Surface(
onClick = onClick,
+35 -30
View File
@@ -401,11 +401,9 @@ final class AppState {
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
var root = ClawdbotConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let desiredMode: String? = switch self.connectionMode {
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let desiredMode: String? = switch connectionMode {
case .local:
"local"
case .remote:
@@ -413,37 +411,44 @@ final class AppState {
case .unconfigured:
nil
}
let remoteHost = connectionMode == .remote
? CommandResolver.parseSSHTarget(remoteTarget)?.host
: nil
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
Task { @MainActor in
var root = await ConfigStore.load()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if self.connectionMode == .remote,
let host = CommandResolver.parseSSHTarget(self.remoteTarget)?.host
{
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
gateway["remote"] = remote
changed = true
if connectionMode == .remote, let host = remoteHost {
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
gateway["remote"] = remote
changed = true
}
}
}
guard changed else { return }
root["gateway"] = gateway
ClawdbotConfigFile.saveDict(root)
guard changed else { return }
root["gateway"] = gateway
try? await ConfigStore.save(root)
}
}
func triggerVoiceEars(ttl: TimeInterval? = 5) {
@@ -20,7 +20,7 @@ enum ClawdbotConfigFile {
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
guard let root = self.parseConfigData(data) else {
self.logger.warning("config JSON root invalid")
return [:]
}
@@ -122,4 +122,19 @@ enum ClawdbotConfigFile {
}
return nil
}
private static func parseConfigData(_ data: Data) -> [String: Any]? {
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return root
}
let decoder = JSONDecoder()
if #available(macOS 12.0, *) {
decoder.allowsJSON5 = true
}
if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) {
self.logger.notice("config parsed with JSON5 decoder")
return decoded.mapValues { $0.foundationValue }
}
return nil
}
}
+11 -4
View File
@@ -34,11 +34,14 @@ enum ConfigStore {
if let override = overrides.loadRemote {
return await override()
}
return await self.loadFromGateway()
return await self.loadFromGateway() ?? [:]
}
if let override = overrides.loadLocal {
return override()
}
if let gateway = await self.loadFromGateway() {
return gateway
}
return ClawdbotConfigFile.loadDict()
}
@@ -55,13 +58,17 @@ enum ConfigStore {
if let override = overrides.saveLocal {
override(root)
} else {
ClawdbotConfigFile.saveDict(root)
do {
try await self.saveToGateway(root)
} catch {
ClawdbotConfigFile.saveDict(root)
}
}
}
}
@MainActor
private static func loadFromGateway() async -> [String: Any] {
private static func loadFromGateway() async -> [String: Any]? {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
@@ -69,7 +76,7 @@ enum ConfigStore {
timeoutMs: 8000)
return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch {
return [:]
return nil
}
}
@@ -2,11 +2,11 @@ import SwiftUI
extension ConnectionsSettings {
var body: some View {
NavigationSplitView {
HStack(spacing: 0) {
self.sidebar
} detail: {
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
self.store.start()
self.ensureSelection()
@@ -18,27 +18,30 @@ extension ConnectionsSettings {
}
private var sidebar: some View {
List(selection: self.$selectedProvider) {
if !self.enabledProviders.isEmpty {
Section("Configured") {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if !self.enabledProviders.isEmpty {
self.sidebarSectionHeader("Configured")
ForEach(self.enabledProviders) { provider in
self.sidebarRow(provider)
.tag(provider)
}
}
}
if !self.availableProviders.isEmpty {
Section("Available") {
if !self.availableProviders.isEmpty {
self.sidebarSectionHeader("Available")
ForEach(self.availableProviders) { provider in
self.sidebarRow(provider)
.tag(provider)
}
}
}
.padding(.vertical, 10)
.padding(.horizontal, 10)
}
.listStyle(.sidebar)
.frame(minWidth: 210, idealWidth: 230, maxWidth: 260)
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
@@ -49,7 +52,7 @@ extension ConnectionsSettings {
self.emptyDetail
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var emptyDetail: some View {
@@ -79,18 +82,41 @@ extension ConnectionsSettings {
}
private func sidebarRow(_ provider: ConnectionProvider) -> some View {
HStack(spacing: 8) {
Circle()
.fill(self.providerTint(provider))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(provider.title)
Text(self.providerSummary(provider))
.font(.caption)
.foregroundStyle(.secondary)
let isSelected = self.selectedProvider == provider
return Button {
self.selectedProvider = provider
} label: {
HStack(spacing: 8) {
Circle()
.fill(self.providerTint(provider))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(provider.title)
Text(self.providerSummary(provider))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear) // ensure full-width hit test area
.contentShape(Rectangle())
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
private func sidebarSectionHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
.padding(.horizontal, 4)
.padding(.top, 2)
}
private func detailHeader(for provider: ConnectionProvider) -> some View {
@@ -13,12 +13,14 @@ enum GatewayEndpointState: Sendable, Equatable {
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
actor GatewayEndpointStore {
static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String?
let password: @Sendable () -> String?
let localPort: @Sendable () -> Int
let localHost: @Sendable () async -> String
let remotePortIfRunning: @Sendable () async -> UInt16?
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
@@ -33,6 +35,16 @@ actor GatewayEndpointStore {
env: ProcessInfo.processInfo.environment)
},
localPort: { GatewayEnvironment.gatewayPort() },
localHost: {
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
tailscaleIP: tailscaleIP)
},
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
}
@@ -89,13 +101,17 @@ actor GatewayEndpointStore {
}
let port = deps.localPort()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil)
let token = deps.token()
let password = deps.password()
switch initialMode {
case .local:
self.state = .ready(
mode: .local,
url: URL(string: "ws://127.0.0.1:\(port)")!,
url: URL(string: "ws://\(host):\(port)")!,
token: token,
password: password)
case .remote:
@@ -129,9 +145,10 @@ actor GatewayEndpointStore {
switch mode {
case .local:
let port = self.deps.localPort()
let host = await self.deps.localHost()
self.setState(.ready(
mode: .local,
url: URL(string: "ws://127.0.0.1:\(port)")!,
url: URL(string: "ws://\(host):\(port)")!,
token: token,
password: password))
case .remote:
@@ -218,6 +235,39 @@ actor GatewayEndpointStore {
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
}
}
private static func resolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
{
if let envBind = env["CLAWDBOT_GATEWAY_BIND"] {
let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
if let gateway = root["gateway"] as? [String: Any],
let bind = gateway["bind"] as? String
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
return nil
}
private static func resolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
{
switch bindMode {
case "tailnet", "auto":
tailscaleIP ?? "127.0.0.1"
default:
"127.0.0.1"
}
}
}
#if DEBUG
@@ -229,5 +279,19 @@ extension GatewayEndpointStore {
{
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
}
static func _testResolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
{
self.resolveGatewayBindMode(root: root, env: env)
}
static func _testResolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
{
self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
}
}
#endif
@@ -1,6 +0,0 @@
import ClawdbotProtocol
// The generated gateway protocol models are value types, but they don't currently declare Sendable.
// We use them across actors via GatewayConnection's event stream, so mark them as unchecked.
extension HelloOk: @unchecked Sendable {}
extension EventFrame: @unchecked Sendable {}
@@ -151,6 +151,7 @@ struct GeneralSettings: View {
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
let status = CLLocationManager().authorizationStatus
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
if status == .authorizedAlways {
return true
}
@@ -140,6 +140,7 @@ enum PermissionManager {
private static func ensureLocation(interactive: Bool) async -> Bool {
let status = CLLocationManager().authorizationStatus
switch status {
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
case .authorizedAlways:
return true
case .notDetermined:
@@ -201,6 +202,7 @@ enum PermissionManager {
case .location:
let status = CLLocationManager().authorizationStatus
// Note: macOS only supports authorizedAlways
results[cap] = status == .authorizedAlways
}
}
@@ -194,6 +194,22 @@ enum SettingsTab: CaseIterable {
case .about: "About"
}
}
var systemImage: String {
switch self {
case .general: "gearshape"
case .connections: "link"
case .skills: "sparkles"
case .sessions: "clock.arrow.circlepath"
case .cron: "calendar"
case .config: "slider.horizontal.3"
case .instances: "network"
case .voiceWake: "waveform.circle"
case .permissions: "lock.shield"
case .debug: "ant"
case .about: "info.circle"
}
}
}
@MainActor
@@ -3,14 +3,14 @@ import Foundation
public let GATEWAY_PROTOCOL_VERSION = 2
public enum ErrorCode: String, Codable {
public enum ErrorCode: String, Codable, Sendable {
case notLinked = "NOT_LINKED"
case agentTimeout = "AGENT_TIMEOUT"
case invalidRequest = "INVALID_REQUEST"
case unavailable = "UNAVAILABLE"
}
public struct ConnectParams: Codable {
public struct ConnectParams: Codable, Sendable {
public let minprotocol: Int
public let maxprotocol: Int
public let client: [String: AnyCodable]
@@ -47,7 +47,7 @@ public struct ConnectParams: Codable {
}
}
public struct HelloOk: Codable {
public struct HelloOk: Codable, Sendable {
public let type: String
public let _protocol: Int
public let server: [String: AnyCodable]
@@ -84,7 +84,7 @@ public struct HelloOk: Codable {
}
}
public struct RequestFrame: Codable {
public struct RequestFrame: Codable, Sendable {
public let type: String
public let id: String
public let method: String
@@ -109,7 +109,7 @@ public struct RequestFrame: Codable {
}
}
public struct ResponseFrame: Codable {
public struct ResponseFrame: Codable, Sendable {
public let type: String
public let id: String
public let ok: Bool
@@ -138,7 +138,7 @@ public struct ResponseFrame: Codable {
}
}
public struct EventFrame: Codable {
public struct EventFrame: Codable, Sendable {
public let type: String
public let event: String
public let payload: AnyCodable?
@@ -167,7 +167,7 @@ public struct EventFrame: Codable {
}
}
public struct PresenceEntry: Codable {
public struct PresenceEntry: Codable, Sendable {
public let host: String?
public let ip: String?
public let version: String?
@@ -228,7 +228,7 @@ public struct PresenceEntry: Codable {
}
}
public struct StateVersion: Codable {
public struct StateVersion: Codable, Sendable {
public let presence: Int
public let health: Int
@@ -245,7 +245,7 @@ public struct StateVersion: Codable {
}
}
public struct Snapshot: Codable {
public struct Snapshot: Codable, Sendable {
public let presence: [PresenceEntry]
public let health: AnyCodable
public let stateversion: StateVersion
@@ -278,7 +278,7 @@ public struct Snapshot: Codable {
}
}
public struct ErrorShape: Codable {
public struct ErrorShape: Codable, Sendable {
public let code: String
public let message: String
public let details: AnyCodable?
@@ -307,7 +307,7 @@ public struct ErrorShape: Codable {
}
}
public struct AgentEvent: Codable {
public struct AgentEvent: Codable, Sendable {
public let runid: String
public let seq: Int
public let stream: String
@@ -336,7 +336,7 @@ public struct AgentEvent: Codable {
}
}
public struct SendParams: Codable {
public struct SendParams: Codable, Sendable {
public let to: String
public let message: String
public let mediaurl: String?
@@ -369,7 +369,7 @@ public struct SendParams: Codable {
}
}
public struct AgentParams: Codable {
public struct AgentParams: Codable, Sendable {
public let message: String
public let to: String?
public let sessionid: String?
@@ -422,28 +422,24 @@ public struct AgentParams: Codable {
}
}
public struct AgentWaitParams: Codable {
public struct AgentWaitParams: Codable, Sendable {
public let runid: String
public let afterms: Int?
public let timeoutms: Int?
public init(
runid: String,
afterms: Int?,
timeoutms: Int?
) {
self.runid = runid
self.afterms = afterms
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case runid = "runId"
case afterms = "afterMs"
case timeoutms = "timeoutMs"
}
}
public struct WakeParams: Codable {
public struct WakeParams: Codable, Sendable {
public let mode: AnyCodable
public let text: String
@@ -460,7 +456,7 @@ public struct WakeParams: Codable {
}
}
public struct NodePairRequestParams: Codable {
public struct NodePairRequestParams: Codable, Sendable {
public let nodeid: String
public let displayname: String?
public let platform: String?
@@ -509,10 +505,10 @@ public struct NodePairRequestParams: Codable {
}
}
public struct NodePairListParams: Codable {
public struct NodePairListParams: Codable, Sendable {
}
public struct NodePairApproveParams: Codable {
public struct NodePairApproveParams: Codable, Sendable {
public let requestid: String
public init(
@@ -525,7 +521,7 @@ public struct NodePairApproveParams: Codable {
}
}
public struct NodePairRejectParams: Codable {
public struct NodePairRejectParams: Codable, Sendable {
public let requestid: String
public init(
@@ -538,7 +534,7 @@ public struct NodePairRejectParams: Codable {
}
}
public struct NodePairVerifyParams: Codable {
public struct NodePairVerifyParams: Codable, Sendable {
public let nodeid: String
public let token: String
@@ -555,7 +551,7 @@ public struct NodePairVerifyParams: Codable {
}
}
public struct NodeRenameParams: Codable {
public struct NodeRenameParams: Codable, Sendable {
public let nodeid: String
public let displayname: String
@@ -572,10 +568,10 @@ public struct NodeRenameParams: Codable {
}
}
public struct NodeListParams: Codable {
public struct NodeListParams: Codable, Sendable {
}
public struct NodeDescribeParams: Codable {
public struct NodeDescribeParams: Codable, Sendable {
public let nodeid: String
public init(
@@ -588,7 +584,7 @@ public struct NodeDescribeParams: Codable {
}
}
public struct NodeInvokeParams: Codable {
public struct NodeInvokeParams: Codable, Sendable {
public let nodeid: String
public let command: String
public let params: AnyCodable?
@@ -617,7 +613,7 @@ public struct NodeInvokeParams: Codable {
}
}
public struct SessionsListParams: Codable {
public struct SessionsListParams: Codable, Sendable {
public let limit: Int?
public let activeminutes: Int?
public let includeglobal: Bool?
@@ -642,7 +638,7 @@ public struct SessionsListParams: Codable {
}
}
public struct SessionsPatchParams: Codable {
public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable?
@@ -679,7 +675,7 @@ public struct SessionsPatchParams: Codable {
}
}
public struct SessionsResetParams: Codable {
public struct SessionsResetParams: Codable, Sendable {
public let key: String
public init(
@@ -692,7 +688,7 @@ public struct SessionsResetParams: Codable {
}
}
public struct SessionsDeleteParams: Codable {
public struct SessionsDeleteParams: Codable, Sendable {
public let key: String
public let deletetranscript: Bool?
@@ -709,7 +705,7 @@ public struct SessionsDeleteParams: Codable {
}
}
public struct SessionsCompactParams: Codable {
public struct SessionsCompactParams: Codable, Sendable {
public let key: String
public let maxlines: Int?
@@ -726,10 +722,10 @@ public struct SessionsCompactParams: Codable {
}
}
public struct ConfigGetParams: Codable {
public struct ConfigGetParams: Codable, Sendable {
}
public struct ConfigSetParams: Codable {
public struct ConfigSetParams: Codable, Sendable {
public let raw: String
public init(
@@ -742,10 +738,10 @@ public struct ConfigSetParams: Codable {
}
}
public struct ConfigSchemaParams: Codable {
public struct ConfigSchemaParams: Codable, Sendable {
}
public struct ConfigSchemaResponse: Codable {
public struct ConfigSchemaResponse: Codable, Sendable {
public let schema: AnyCodable
public let uihints: [String: AnyCodable]
public let version: String
@@ -770,7 +766,7 @@ public struct ConfigSchemaResponse: Codable {
}
}
public struct WizardStartParams: Codable {
public struct WizardStartParams: Codable, Sendable {
public let mode: AnyCodable?
public let workspace: String?
@@ -787,7 +783,7 @@ public struct WizardStartParams: Codable {
}
}
public struct WizardNextParams: Codable {
public struct WizardNextParams: Codable, Sendable {
public let sessionid: String
public let answer: [String: AnyCodable]?
@@ -804,7 +800,7 @@ public struct WizardNextParams: Codable {
}
}
public struct WizardCancelParams: Codable {
public struct WizardCancelParams: Codable, Sendable {
public let sessionid: String
public init(
@@ -817,7 +813,7 @@ public struct WizardCancelParams: Codable {
}
}
public struct WizardStatusParams: Codable {
public struct WizardStatusParams: Codable, Sendable {
public let sessionid: String
public init(
@@ -830,7 +826,7 @@ public struct WizardStatusParams: Codable {
}
}
public struct WizardStep: Codable {
public struct WizardStep: Codable, Sendable {
public let id: String
public let type: AnyCodable
public let title: String?
@@ -946,7 +942,7 @@ public struct WizardStatusResult: Codable, Sendable {
}
}
public struct TalkModeParams: Codable {
public struct TalkModeParams: Codable, Sendable {
public let enabled: Bool
public let phase: String?
@@ -963,7 +959,7 @@ public struct TalkModeParams: Codable {
}
}
public struct ProvidersStatusParams: Codable {
public struct ProvidersStatusParams: Codable, Sendable {
public let probe: Bool?
public let timeoutms: Int?
@@ -980,7 +976,7 @@ public struct ProvidersStatusParams: Codable {
}
}
public struct WebLoginStartParams: Codable {
public struct WebLoginStartParams: Codable, Sendable {
public let force: Bool?
public let timeoutms: Int?
public let verbose: Bool?
@@ -1001,7 +997,7 @@ public struct WebLoginStartParams: Codable {
}
}
public struct WebLoginWaitParams: Codable {
public struct WebLoginWaitParams: Codable, Sendable {
public let timeoutms: Int?
public init(
@@ -1014,7 +1010,7 @@ public struct WebLoginWaitParams: Codable {
}
}
public struct ModelChoice: Codable {
public struct ModelChoice: Codable, Sendable {
public let id: String
public let name: String
public let provider: String
@@ -1043,10 +1039,10 @@ public struct ModelChoice: Codable {
}
}
public struct ModelsListParams: Codable {
public struct ModelsListParams: Codable, Sendable {
}
public struct ModelsListResult: Codable {
public struct ModelsListResult: Codable, Sendable {
public let models: [ModelChoice]
public init(
@@ -1059,10 +1055,10 @@ public struct ModelsListResult: Codable {
}
}
public struct SkillsStatusParams: Codable {
public struct SkillsStatusParams: Codable, Sendable {
}
public struct SkillsInstallParams: Codable {
public struct SkillsInstallParams: Codable, Sendable {
public let name: String
public let installid: String
public let timeoutms: Int?
@@ -1083,7 +1079,7 @@ public struct SkillsInstallParams: Codable {
}
}
public struct SkillsUpdateParams: Codable {
public struct SkillsUpdateParams: Codable, Sendable {
public let skillkey: String
public let enabled: Bool?
public let apikey: String?
@@ -1108,7 +1104,7 @@ public struct SkillsUpdateParams: Codable {
}
}
public struct CronJob: Codable {
public struct CronJob: Codable, Sendable {
public let id: String
public let name: String
public let description: String?
@@ -1165,7 +1161,7 @@ public struct CronJob: Codable {
}
}
public struct CronListParams: Codable {
public struct CronListParams: Codable, Sendable {
public let includedisabled: Bool?
public init(
@@ -1178,10 +1174,10 @@ public struct CronListParams: Codable {
}
}
public struct CronStatusParams: Codable {
public struct CronStatusParams: Codable, Sendable {
}
public struct CronAddParams: Codable {
public struct CronAddParams: Codable, Sendable {
public let name: String
public let description: String?
public let enabled: Bool?
@@ -1222,7 +1218,7 @@ public struct CronAddParams: Codable {
}
}
public struct CronUpdateParams: Codable {
public struct CronUpdateParams: Codable, Sendable {
public let id: String
public let patch: [String: AnyCodable]
@@ -1239,7 +1235,7 @@ public struct CronUpdateParams: Codable {
}
}
public struct CronRemoveParams: Codable {
public struct CronRemoveParams: Codable, Sendable {
public let id: String
public init(
@@ -1252,7 +1248,7 @@ public struct CronRemoveParams: Codable {
}
}
public struct CronRunParams: Codable {
public struct CronRunParams: Codable, Sendable {
public let id: String
public let mode: AnyCodable?
@@ -1269,7 +1265,7 @@ public struct CronRunParams: Codable {
}
}
public struct CronRunsParams: Codable {
public struct CronRunsParams: Codable, Sendable {
public let id: String
public let limit: Int?
@@ -1286,7 +1282,7 @@ public struct CronRunsParams: Codable {
}
}
public struct CronRunLogEntry: Codable {
public struct CronRunLogEntry: Codable, Sendable {
public let ts: Int
public let jobid: String
public let action: String
@@ -1331,7 +1327,7 @@ public struct CronRunLogEntry: Codable {
}
}
public struct ChatHistoryParams: Codable {
public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String
public let limit: Int?
@@ -1348,7 +1344,7 @@ public struct ChatHistoryParams: Codable {
}
}
public struct ChatSendParams: Codable {
public struct ChatSendParams: Codable, Sendable {
public let sessionkey: String
public let message: String
public let thinking: String?
@@ -1385,7 +1381,7 @@ public struct ChatSendParams: Codable {
}
}
public struct ChatAbortParams: Codable {
public struct ChatAbortParams: Codable, Sendable {
public let sessionkey: String
public let runid: String
@@ -1402,7 +1398,7 @@ public struct ChatAbortParams: Codable {
}
}
public struct ChatEvent: Codable {
public struct ChatEvent: Codable, Sendable {
public let runid: String
public let sessionkey: String
public let seq: Int
@@ -1443,7 +1439,7 @@ public struct ChatEvent: Codable {
}
}
public struct TickEvent: Codable {
public struct TickEvent: Codable, Sendable {
public let ts: Int
public init(
@@ -1456,7 +1452,7 @@ public struct TickEvent: Codable {
}
}
public struct ShutdownEvent: Codable {
public struct ShutdownEvent: Codable, Sendable {
public let reason: String
public let restartexpectedms: Int?
@@ -1473,7 +1469,7 @@ public struct ShutdownEvent: Codable {
}
}
public enum GatewayFrame: Codable {
public enum GatewayFrame: Codable, Sendable {
case req(RequestFrame)
case res(ResponseFrame)
case event(EventFrame)
@@ -1,156 +1,36 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite struct GatewayEndpointStoreTests {
private final class ModeBox: @unchecked Sendable {
private let lock = NSLock()
private var value: AppState.ConnectionMode
init(_ initial: AppState.ConnectionMode) {
self.value = initial
}
func get() -> AppState.ConnectionMode {
self.lock.lock()
defer { self.lock.unlock() }
return self.value
}
func set(_ next: AppState.ConnectionMode) {
self.lock.lock()
defer { self.lock.unlock() }
self.value = next
}
@Suite(.serialized)
struct GatewayEndpointStoreTests {
@Test func resolvesLocalHostFromBindModes() {
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "loopback",
tailscaleIP: "100.64.0.10") == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "lan",
tailscaleIP: "100.64.0.10") == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.0.10") == "100.64.0.10")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil) == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.0.10") == "100.64.0.10")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil) == "127.0.0.1")
}
@Test func localRefreshResolvesToLocalhostPort() async throws {
let mode = ModeBox(.local)
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { "t" },
password: { nil },
localPort: { 1234 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))
await store.refresh()
let cfg = try await store.requireConfig()
#expect(cfg.url.absoluteString == "ws://127.0.0.1:1234")
#expect(cfg.token == "t")
}
@Test func remoteWithoutTunnelRecoversByEnsuringTunnel() async throws {
let mode = ModeBox(.remote)
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { nil },
password: { nil },
localPort: { 18789 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))
let cfg = try await store.requireConfig()
#expect(cfg.url.absoluteString == "ws://127.0.0.1:18789")
#expect(cfg.token == nil)
}
@Test func ensureRemoteTunnelPublishesReadyState() async throws {
let mode = ModeBox(.remote)
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { "tok" },
password: { "pw" },
localPort: { 1 },
remotePortIfRunning: { 5555 },
ensureRemoteTunnel: { 5555 }))
let stream = await store.subscribe(bufferingNewest: 10)
var iterator = stream.makeAsyncIterator()
_ = await iterator.next() // initial
_ = try await store.ensureRemoteControlTunnel()
let next = await iterator.next()
guard case let .ready(mode, url, token, password) = next else {
Issue.record("expected .ready after ensure, got \(String(describing: next))")
return
}
#expect(mode == .remote)
#expect(url.absoluteString == "ws://127.0.0.1:5555")
#expect(token == "tok")
#expect(password == "pw")
}
@Test func resolvesGatewayPasswordByMode() {
let root: [String: Any] = [
"gateway": [
"auth": ["password": " local "],
"remote": ["password": " remote "],
],
]
let env: [String: String] = [:]
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
@Test func resolvesBindModeFromEnvOrConfig() {
let root: [String: Any] = ["gateway": ["bind": "tailnet"]]
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
root: root,
env: env) == "local")
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: true,
env: [:]) == "tailnet")
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
root: root,
env: env) == "remote")
}
@Test func gatewayPasswordEnvOverridesConfig() {
let root: [String: Any] = [
"gateway": [
"auth": ["password": "local"],
"remote": ["password": "remote"],
],
]
let env = ["CLAWDBOT_GATEWAY_PASSWORD": " env "]
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: root,
env: env) == "env")
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: true,
root: root,
env: env) == "env")
}
@Test func gatewayPasswordIgnoresWhitespaceValues() {
let root: [String: Any] = [
"gateway": [
"auth": ["password": " "],
"remote": ["password": "\n\t"],
],
]
let env = ["CLAWDBOT_GATEWAY_PASSWORD": " "]
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: root,
env: env) == nil)
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: true,
root: root,
env: env) == nil)
}
@Test func unconfiguredModeRejectsConfig() async {
let mode = ModeBox(.unconfigured)
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { nil },
password: { nil },
localPort: { 18789 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))
await #expect(throws: Error.self) {
_ = try await store.requireConfig()
}
env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan")
}
}
@@ -1,5 +1,6 @@
import Testing
import ClawdbotIPC
import CoreLocation
@testable import Clawdbot
@Suite(.serialized)
@@ -22,4 +23,16 @@ struct PermissionManagerTests {
let ensured = await PermissionManager.ensure(caps, interactive: false)
#expect(ensured.keys.count == caps.count)
}
@Test func locationStatusMatchesAuthorizationAlways() async {
let status = CLLocationManager().authorizationStatus
let results = await PermissionManager.status([.location])
#expect(results[.location] == (status == .authorizedAlways))
}
@Test func ensureLocationNonInteractiveMatchesAuthorizationAlways() async {
let status = CLLocationManager().authorizationStatus
let ensured = await PermissionManager.ensure([.location], interactive: false)
#expect(ensured[.location] == (status == .authorizedAlways))
}
}
+61
View File
@@ -0,0 +1,61 @@
---
summary: "Agent loop lifecycle, streams, and wait semantics"
read_when:
- You need an exact walkthrough of the agent loop or lifecycle events
---
# Agent Loop (Clawdis)
Short, exact flow of one agent run. Source of truth: current code in `src/`.
## Entry points
- Gateway RPC: `agent` and `agent.wait` in `src/gateway/server-methods/agent.ts`.
- CLI: `agentCommand` in `src/commands/agent.ts`.
## High-level flow
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
2) `agentCommand` runs the agent:
- resolves model + thinking/verbose defaults
- loads skills snapshot
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
- emits **lifecycle end/error** if the embedded loop does not emit one
3) `runEmbeddedPiAgent`:
- builds `AgentSession` and subscribes to pi events
- streams assistant deltas + tool events
- enforces timeout -> aborts run if exceeded
- returns payloads + usage metadata
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdis `agent` stream:
- tool events => `stream: "tool"`
- assistant deltas => `stream: "assistant"`
- lifecycle events => `stream: "lifecycle"` (`phase: "start" | "end" | "error"`)
5) `agent.wait` uses `waitForAgentJob`:
- waits for **lifecycle end/error** for `runId`
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
## Event streams (today)
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
- `assistant`: streamed deltas from pi-agent-core
- `tool`: streamed tool events from pi-agent-core
## Chat surface handling
- `createAgentEventHandler` in `src/gateway/server-chat.ts`:
- buffers assistant deltas
- emits chat `delta` messages
- emits chat `final` when **lifecycle end/error** arrives
## Timeouts
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
- Agent runtime: `agent.timeoutSeconds` default 600s; enforced in `runEmbeddedPiAgent` abort timer.
## Where things can end early
- Agent timeout (abort)
- AbortSignal (cancel)
- Gateway disconnect or RPC timeout
- `agent.wait` timeout (wait-only, does not stop agent)
## Files
- `src/gateway/server-methods/agent.ts`
- `src/gateway/server-methods/agent-job.ts`
- `src/commands/agent.ts`
- `src/agents/pi-embedded-runner.ts`
- `src/agents/pi-embedded-subscribe.ts`
- `src/gateway/server-chat.ts`
-2
View File
@@ -3,7 +3,6 @@ summary: "Agent runtime (embedded p-mono), workspace contract, and session boots
read_when:
- Changing agent runtime, workspace bootstrap, or session behavior
---
<!-- {% raw %} -->
# Agent Runtime 🤖
CLAWDBOT runs a single embedded agent runtime derived from **p-mono** (internal name: **p**).
@@ -101,4 +100,3 @@ At minimum, set:
---
*Next: [Group Chats](./group-messages.md)* 🦞
<!-- {% endraw %} -->
+4 -1
View File
@@ -3,6 +3,10 @@
line-height: 1.7;
}
.mdx-content > h1:first-of-type {
display: none;
}
.markdown h1,
.markdown h2,
.markdown h3,
@@ -127,4 +131,3 @@
font-family: var(--font-pixel);
letter-spacing: 0.06em;
}
-2
View File
@@ -3,7 +3,6 @@ summary: "How inbound audio/voice notes are downloaded, transcribed, and injecte
read_when:
- Changing audio transcription or media handling
---
<!-- {% raw %} -->
# Audio / Voice Notes — 2025-12-05
## What works
@@ -47,4 +46,3 @@ Requires `OPENAI_API_KEY` in env and `openai` CLI installed:
## Gotchas
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
- Keep timeouts reasonable (`timeoutSeconds`, default 45s) to avoid blocking the reply queue.
<!-- {% endraw %} -->
-2
View File
@@ -4,7 +4,6 @@ read_when:
- Onboarding a new assistant instance
- Reviewing safety/permission implications
---
<!-- {% raw %} -->
# Building a personal assistant with CLAWDBOT (Clawd-style)
CLAWDBOT is a WhatsApp + Telegram + Discord gateway for **Pi** agents. This guide is the “personal assistant” setup: one dedicated WhatsApp number that behaves like your always-on agent.
@@ -196,4 +195,3 @@ Logs live under `/tmp/clawdbot/` (default: `clawdbot-YYYY-MM-DD.log`).
- Cron + wakeups: [Cron + wakeups](./cron.md)
- macOS menu bar companion: [Clawdbot macOS app](./clawdbot-mac.md)
- Security: [Security](./security.md)
<!-- {% endraw %} -->
+84 -4
View File
@@ -3,7 +3,6 @@ summary: "All configuration options for ~/.clawdbot/clawdbot.json with examples"
read_when:
- Adding or modifying config fields
---
<!-- {% raw %} -->
# Configuration 🔧
CLAWDBOT reads an optional **JSON5** config from `~/.clawdbot/clawdbot.json` (comments + trailing commas allowed).
@@ -60,6 +59,51 @@ To prevent the bot from responding to WhatsApp @-mentions in groups (only respon
## Common options
### Env vars + `.env`
CLAWDBOT reads env vars from the parent process (shell, launchd/systemd, CI, etc.).
Additionally, it loads:
- `.env` from the current working directory (if present)
- a global fallback `.env` from `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`)
Neither `.env` file overrides existing env vars.
### `env.shellEnv` (optional)
Opt-in convenience: if enabled and none of the expected keys are set yet, CLAWDBOT runs your login shell and imports only the missing expected keys (never overrides).
This effectively sources your shell profile.
```json5
{
env: {
shellEnv: {
enabled: true,
timeoutMs: 15000
}
}
}
```
Env var equivalent:
- `CLAWDBOT_LOAD_SHELL_ENV=1`
- `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`
### Auth storage (OAuth + API keys)
Clawdbot keeps subscription OAuth tokens + API keys in the **agent auth store**:
- `~/.clawdbot/agent/auth.json`
The agent directory can be overridden with:
- `CLAWDBOT_AGENT_DIR` (preferred)
- `PI_CODING_AGENT_DIR` (legacy)
Legacy OAuth storage is still supported for migration:
- Default: `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)
- Override: `CLAWDBOT_OAUTH_DIR`
On first use, Clawdbot auto‑migrates legacy `oauth.json` entries into `auth.json`.
### `identity`
Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
@@ -82,7 +126,7 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
{
wizard: {
lastRunAt: "2026-01-01T00:00:00.000Z",
lastRunVersion: "2.0.0-beta5",
lastRunVersion: "2026.1.4",
lastRunCommit: "abc1234",
lastRunCommand: "configure",
lastRunMode: "local"
@@ -431,6 +475,19 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
(omit to show the full catalog).
`modelAliases` adds short names for `/model` (alias -> provider/model).
`modelFallbacks` lists ordered fallback models to try when the default fails.
`imageModel` selects an image-capable model for the `image` tool.
`imageModelFallbacks` lists ordered fallback image models for the `image` tool.
Clawdbot also ships a few built-in `modelAliases` shorthands (when an `agent` section exists):
- `opus` -> `anthropic/claude-opus-4-5`
- `sonnet` -> `anthropic/claude-sonnet-4-5`
- `gpt` -> `openai/gpt-5.2`
- `gpt-mini` -> `openai/gpt-5-mini`
- `gemini` -> `google/gemini-3-pro-preview`
- `gemini-flash` -> `google/gemini-3-flash-preview`
If you configure the same alias name (case-insensitive) yourself, your value wins (defaults never override).
```json5
{
@@ -448,6 +505,10 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
"openrouter/deepseek/deepseek-r1:free",
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
],
imageModel: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
imageModelFallbacks: [
"openrouter/google/gemini-2.0-flash-vision:free"
],
thinkingDefault: "low",
verboseDefault: "off",
elevatedDefault: "on",
@@ -502,6 +563,20 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
`agent.tools` configures a global tool allow/deny policy (deny wins).
This is applied even when the Docker sandbox is **off**.
Example (disable browser/canvas everywhere):
```json5
{
agent: {
tools: {
deny: ["browser", "canvas"]
}
}
}
```
`agent.elevated` controls elevated (host) bash access:
- `enabled`: allow elevated mode (default true)
- `allowFrom`: per-surface allowlists (empty = disabled)
@@ -956,10 +1031,16 @@ Requires full Gateway restart:
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
- `CLAWDBOT_CONFIG_PATH` (per-instance config)
- `CLAWDBOT_STATE_DIR` (sessions/creds/logs)
- `CLAWDBOT_STATE_DIR` (sessions/creds)
- `agent.workspace` (memories)
- `gateway.port` (unique per instance)
Convenience flags (CLI):
- `clawdbot --dev …` → uses `~/.clawdbot-dev` + shifts ports from base `19001`
- `clawdbot --profile <name> …` → uses `~/.clawdbot-<name>` (port via config/env/flags)
See `docs/gateway.md` for the derived port mapping (gateway/bridge/browser/canvas).
Example:
```bash
CLAWDBOT_CONFIG_PATH=~/.clawdbot/a.json \
@@ -1168,4 +1249,3 @@ Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wa
---
*Next: [Agent Runtime](./agent.md)* 🦞
<!-- {% endraw %} -->
+104
View File
@@ -35,6 +35,110 @@ Note: Discord does not provide a simple username → id lookup without extra gui
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
## How to create your own bot
This is the “Discord Developer Portal” setup for running Clawdbot in a server (guild) channel like `#help`.
### 1) Create the Discord app + bot user
1. Discord Developer Portal → **Applications** → **New Application**
2. In your app:
- **Bot** → **Add Bot**
- Copy the **Bot Token** (this is what you put in `DISCORD_BOT_TOKEN`)
### 2) Enable the gateway intents Clawdbot needs
Discord blocks “privileged intents” unless you explicitly enable them.
In **Bot** → **Privileged Gateway Intents**, enable:
- **Message Content Intent** (required to read message text in most guilds; without it you’ll see “Used disallowed intents” or the bot will connect but not react to messages)
- **Server Members Intent** (recommended; required for some member/user lookups and allowlist matching in guilds)
You usually do **not** need **Presence Intent**.
### 3) Generate an invite URL (OAuth2 URL Generator)
In your app: **OAuth2** → **URL Generator**
**Scopes**
- ✅ `bot`
- ✅ `applications.commands` (only if you want slash commands; otherwise leave unchecked)
**Bot Permissions** (minimal baseline)
- ✅ View Channels
- ✅ Send Messages
- ✅ Read Message History
- ✅ Embed Links
- ✅ Attach Files
- ✅ Add Reactions (optional but recommended)
- ✅ Use External Emojis / Stickers (optional; only if you want them)
Avoid **Administrator** unless you’re debugging and fully trust the bot.
Copy the generated URL, open it, pick your server, and install the bot.
### 4) Get the ids (guild/user/channel)
Discord uses numeric ids everywhere; Clawdbot config prefers ids.
1. Discord (desktop/web) → **User Settings** → **Advanced** → enable **Developer Mode**
2. Right-click:
- Server name → **Copy Server ID** (guild id)
- Channel (e.g. `#help`) → **Copy Channel ID**
- Your user → **Copy User ID**
### 5) Configure Clawdbot
#### Token
Set the bot token via env var (recommended on servers):
- `DISCORD_BOT_TOKEN=...`
Or via config:
```json5
{
discord: {
enabled: true,
token: "YOUR_BOT_TOKEN"
}
}
```
#### Allowlist + channel routing
Example “single server, only allow me, only allow #help”:
```json5
{
discord: {
enabled: true,
dm: { enabled: false },
guilds: {
"YOUR_GUILD_ID": {
users: ["YOUR_USER_ID"],
requireMention: true,
channels: {
help: { allow: true, requireMention: true }
}
}
}
}
}
```
Notes:
- `requireMention: true` means the bot only replies when mentioned (recommended for shared channels).
- If `channels` is present, any channel not listed is denied by default.
### 6) Verify it works
1. Start the gateway.
2. In your server channel, send: `@Krill hello` (or whatever your bot name is).
3. If nothing happens: check **Troubleshooting** below.
### Troubleshooting
- **“Used disallowed intents”**: enable **Message Content Intent** (and likely **Server Members Intent**) in the Developer Portal, then restart the gateway.
- **Bot connects but never replies in a guild channel**:
- Missing **Message Content Intent**, or
- The bot lacks channel permissions (View/Send/Read History), or
- Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user.
- **DMs don’t work**: `discord.dm.enabled` may be `false` or `discord.dm.allowFrom` doesn’t include you.
## Capabilities & limits
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
- Typing indicators sent best-effort; message chunking honors Discord’s 2k character limit.
+1
View File
@@ -73,6 +73,7 @@ container. The gateway stays on your host, but the tool execution is isolated:
- one container per session (hard wall)
- per-session workspace folder mounted at `/workspace`
- allow/deny tool policy (deny wins)
- inbound media is copied into the sandbox workspace (`media/inbound/*`) so tools can read it
### Default behavior
+9 -1
View File
@@ -27,11 +27,19 @@
"pages": [
"index",
"onboarding",
"wizard",
"clawd",
"faq"
]
},
{
"group": "Installation",
"pages": [
"wizard",
"nix",
"docker",
"setup"
]
},
{
"group": "Core Concepts",
"pages": [
+78 -26
View File
@@ -14,7 +14,9 @@ Everything lives under `~/.clawdbot/`:
| Path | Purpose |
|------|---------|
| `~/.clawdbot/clawdbot.json` | Main config (JSON5) |
| `~/.clawdbot/agent/auth.json` | OAuth + API key store (Anthropic/OpenAI, etc.) |
| `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens |
| `~/.clawdbot/credentials/oauth.json` | Legacy OAuth store (auto‑migrated) |
| `~/.clawdbot/sessions/` | Conversation history & state |
| `~/.clawdbot/sessions/sessions.json` | Session metadata |
@@ -105,18 +107,36 @@ The macOS app onboarding is still being polished and can have quirks (e.g., What
### OAuth vs API key — what's the difference?
- **OAuth** — Uses your Claude Pro/Max subscription ($20-100/mo flat). No per-token charges. ✅ Recommended!
- **API key** — Pay-per-token via console.anthropic.com. Can get expensive fast.
- **OAuth** — Uses your **subscription** (Anthropic Claude Pro/Max or OpenAI ChatGPT/Codex). No per‑token charges. ✅ Recommended!
- **API key** — Pay‑per‑token via the provider’s API billing. Can get expensive fast.
They're **separate billing**! An API key does NOT use your subscription.
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back. Or just run:
**For OAuth:** During onboarding, pick **Anthropic OAuth** or **OpenAI Codex OAuth**, log in, paste the code/URL when prompted. Or just run:
```bash
pnpm clawdbot login
```
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/` to your server. The auth is just a JSON file.
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/agent/auth.json` to your server. The auth is just a JSON file.
### How are env vars loaded?
CLAWDBOT reads env vars from the parent process (shell, launchd/systemd, CI, etc.). It also loads `.env` files:
- `.env` in the current working directory
- global fallback: `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`)
Neither `.env` file overrides existing env vars.
Optional convenience: import missing expected keys from your login shell env (sources your shell profile):
```json5
{
env: { shellEnv: { enabled: true, timeoutMs: 15000 } }
}
```
Or set `CLAWDBOT_LOAD_SHELL_ENV=1` (timeout: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`).
### Does enterprise OAuth work?
@@ -128,7 +148,7 @@ pnpm clawdbot login
OAuth needs the callback to reach the machine running the CLI. Options:
1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/credentials/` to the container.
1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/agent/auth.json` to the container.
2. **SSH tunnel** — `ssh -L 18789:localhost:18789 user@server`
3. **Tailscale** — Put both machines on your tailnet.
@@ -446,31 +466,54 @@ cd ~/path/to/clawdbot
codex --full-auto "debug why clawdbot gateway won't start"
```
### Processes keep restarting after I kill them (Linux)
### Processes keep restarting after I kill them
Something is supervising them. Check:
The gateway runs under a supervisor that auto-restarts it. You need to stop the supervisor, not just kill the process.
**macOS (launchd)**
```bash
# systemd?
systemctl list-units | grep -i clawdbot
sudo systemctl stop clawdbot
# Check if running
launchctl list | grep clawdbot
# pm2?
pm2 list
pm2 delete all
# Stop and disable
launchctl disable gui/$UID/com.clawdbot.gateway
launchctl bootout gui/$UID/com.clawdbot.gateway
# Re-enable later
launchctl enable gui/$UID/com.clawdbot.gateway
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.gateway.plist
```
Stop the supervisor first, then the processes.
**Linux (systemd)**
```bash
# Check if running
systemctl list-units | grep -i clawdbot
# Stop and disable
sudo systemctl disable --now clawdbot
```
**pm2 (if used)**
```bash
pm2 list
pm2 delete clawdbot
```
### Clean uninstall (start fresh)
```bash
# Stop processes
pkill -f "clawdbot"
# macOS: stop launchd service
launchctl disable gui/$UID/com.clawdbot.gateway
launchctl bootout gui/$UID/com.clawdbot.gateway 2>/dev/null
# If using systemd
sudo systemctl stop clawdbot
sudo systemctl disable clawdbot
# Linux: stop systemd service
sudo systemctl disable --now clawdbot
# Kill any remaining processes
pkill -f "clawdbot"
# Remove data
rm -rf ~/.clawdbot
@@ -507,23 +550,32 @@ Use `/model` to switch without restarting:
/model sonnet
/model haiku
/model opus
/model gpt
/model gpt-mini
/model gemini
/model gemini-flash
```
List available models with `/model`, `/model list`, or `/model status`.
Clawdbot ships a few default model shorthands (you can override them in config):
`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`.
**Setup:** Configure allowed models and aliases in `clawdbot.json`:
```json
{
"agent": {
"model": "anthropic/claude-opus-4-5-20251022",
"model": "anthropic/claude-opus-4-5",
"allowedModels": [
"anthropic/claude-opus-4-5-20251022",
"anthropic/claude-sonnet-4-5-20251022",
"anthropic/claude-haiku-4-5-20251001"
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-5",
"anthropic/claude-haiku-4-5"
],
"modelAliases": {
"opus": "anthropic/claude-opus-4-5-20251022",
"sonnet": "anthropic/claude-sonnet-4-5-20251022",
"haiku": "anthropic/claude-haiku-4-5-20251001"
"opus": "anthropic/claude-opus-4-5",
"sonnet": "anthropic/claude-sonnet-4-5",
"haiku": "anthropic/claude-haiku-4-5"
}
}
}
+28
View File
@@ -49,6 +49,34 @@ pnpm gateway:watch
Supported if you isolate state + config and use unique ports.
### Dev profile (`--dev`)
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup.
```bash
clawdbot --dev setup
clawdbot --dev gateway --allow-unconfigured
# then target the dev instance:
clawdbot --dev status
clawdbot --dev health
```
Defaults (can be overridden via env/flags/config):
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
- `bridge.port=19002` (derived: `gateway.port+1`)
- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
- `canvasHost.port=19005` (derived: `gateway.port+4`)
- `agent.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
Derived ports (rules of thumb):
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
- `bridge.port = base + 1` (or `CLAWDBOT_BRIDGE_PORT` / config override)
- `browser.controlUrl port = base + 2` (or `CLAWDBOT_BROWSER_CONTROL_URL` / config override)
- `canvasHost.port = base + 4` (or `CLAWDBOT_CANVAS_HOST_PORT` / config override)
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
Checklist per instance:
- unique `gateway.port`
- unique `CLAWDBOT_CONFIG_PATH`
+1 -2
View File
@@ -3,7 +3,6 @@ summary: "Image and media handling rules for send, gateway, and agent replies"
read_when:
- Modifying media pipeline or attachments
---
<!-- {% raw %} -->
# Image & Media Support — 2025-12-05
CLAWDBOT is now **web-only** (Baileys). This document captures the current media handling rules for send, gateway, and agent replies.
@@ -38,6 +37,7 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media
- When inbound web messages include media, CLAWDBOT downloads to a temp file and exposes templating variables:
- `{{MediaUrl}}` pseudo-URL for the inbound media.
- `{{MediaPath}}` local temp path written before running the command.
- When a per-session Docker sandbox is enabled, inbound media is copied into the sandbox workspace and `MediaPath`/`MediaUrl` are rewritten to a relative path like `media/inbound/<filename>`.
- Audio transcription (if configured) runs before templating and can replace `Body` with the transcript.
## Limits & Errors
@@ -49,4 +49,3 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media
- Cover send + reply flows for image/audio/document cases.
- Validate recompression for images (size bound) and voice-note flag for audio.
- Ensure multi-media replies fan out as sequential sends.
<!-- {% endraw %} -->
+4 -4
View File
@@ -3,23 +3,23 @@ summary: "Top-level overview of Clawdbot, features, and purpose"
read_when:
- Introducing Clawdbot to newcomers
---
<!-- {% raw %} -->
# CLAWDBOT 🦞
> *"EXFOLIATE! EXFOLIATE!"* — A space lobster, probably
<p align="center">
<img src="whatsapp-clawd.jpg" alt="CLAWDBOT" width="420">
<img src="whatsapp-clawd.jpg" alt="CLAWDBOT" width="420" />
</p>
<p align="center">
<strong>WhatsApp + Telegram + Discord + iMessage gateway for AI agents (Pi).</strong><br>
<strong>Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).</strong><br />
Send a message, get an agent response — from your pocket.
</p>
<p align="center">
<a href="https://github.com/clawdbot/clawdbot">GitHub</a> ·
<a href="https://github.com/clawdbot/clawdbot/releases">Releases</a> ·
<a href="https://docs.clawdbot.com/">Docs</a> ·
<a href="./clawd.md">Clawd setup</a>
</p>
@@ -63,6 +63,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
- 🎮 **Discord Bot** — DMs + guild channels via discord.js
- 💬 **iMessage** — Local imsg CLI integration (macOS)
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
- 🔐 **Subscription auth** — Anthropic (Claude Pro/Max) + OpenAI (ChatGPT/Codex) via OAuth
- 💬 **Sessions** — Direct chats collapse into shared `main` (default); groups are isolated
- 👥 **Group Chat Support** — Mention-based by default; owner can toggle `/activation always|mention`
- 📎 **Media Support** — Send and receive images, audio, documents
@@ -161,7 +162,6 @@ Example:
---
*"We're all just playing with our own prompts."* — an AI, probably high on tokens
<!-- {% endraw %} -->
## Credits
+1 -1
View File
@@ -23,4 +23,4 @@ Shapes & sizes
Behavioral notes
- No external CLI/broker toggle for ears/working; keep it internal to the app’s own signals to avoid accidental flapping.
- Keep TTLs short (<10s) so the icon returns to baseline quickly if a job hangs.
- Keep TTLs short (&lt;10s) so the icon returns to baseline quickly if a job hangs.
+1 -1
View File
@@ -23,7 +23,7 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
## Build & package
Notes:
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
- Universal by default (`arm64 x86_64`). Override with `BUILD_ARCHS="arm64"` if you need arm-only.
- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`).
```bash
# From repo root; set release IDs so Sparkle feed is enabled.
+10 -2
View File
@@ -19,16 +19,22 @@ that prefers tool-call + image-capable models and maintains ordered fallbacks.
- show default model + aliases + fallbacks + allowlist
- `clawdbot models set <modelOrAlias>`
- writes `agent.model` in config
- `clawdbot models set-image <modelOrAlias>`
- writes `agent.imageModel` in config
- `clawdbot models aliases list|add|remove`
- writes `agent.modelAliases`
- `clawdbot models fallbacks list|add|remove|clear`
- writes `agent.modelFallbacks`
- `clawdbot models image-fallbacks list|add|remove|clear`
- writes `agent.imageModelFallbacks`
- `clawdbot models scan`
- OpenRouter :free scan; probe tool-call + image; interactive selection
## Config changes
- Add `agent.modelFallbacks: string[]` (ordered list of provider/model IDs).
- Add `agent.imageModel?: string` (optional image-capable model for image tool).
- Add `agent.imageModelFallbacks?: string[]` (ordered list for image tool).
- Keep existing:
- `agent.model` (default)
- `agent.allowedModels` (list filter)
@@ -49,8 +55,8 @@ Probes (direct pi-ai complete)
- Prompt includes 1x1 PNG; success if no "unsupported image" error.
Scoring/selection
- Prefer models passing tool + image.
- Fallback to tool-only if no tool+image pass.
- Prefer models passing tool + image for text/tool fallbacks.
- Prefer image-only models for image tool fallback (even if tool probe fails).
- Rank by: image ok, then lower tool latency, then larger context, then params.
Interactive selection (TTY)
@@ -61,7 +67,9 @@ Interactive selection (TTY)
Output
- Writes `agent.modelFallbacks` ordered.
- Writes `agent.imageModelFallbacks` ordered (image-capable models).
- Optional `--set-default` to set `agent.model`.
- Optional `--set-image` to set `agent.imageModel`.
## Runtime fallback
+57 -11
View File
@@ -1,26 +1,66 @@
---
summary: "Running Clawdbot under Nix (config, state, and packaging expectations)"
summary: "Install Clawdbot declaratively with Nix"
read_when:
- Building Clawdbot with Nix
- Debugging Nix-mode behavior
- You want reproducible, rollback-able installs
- You're already using Nix/NixOS/Home Manager
- You want everything pinned and managed declaratively
---
# Nix mode
# Nix Installation
The recommended way to run Clawdbot with Nix is via **[nix-clawdbot](https://github.com/clawdbot/nix-clawdbot)** — a batteries-included Home Manager module.
## Quick Start
Paste this to your AI agent (Claude, Cursor, etc.):
```text
I want to set up nix-clawdbot on my Mac.
Repository: github:clawdbot/nix-clawdbot
What I need you to do:
1. Check if Determinate Nix is installed (if not, install it)
2. Create a local flake at ~/code/clawdbot-local using templates/agent-first/flake.nix
3. Help me create a Telegram bot (@BotFather) and get my chat ID (@userinfobot)
4. Set up secrets (bot token, Anthropic key) - plain files at ~/.secrets/ is fine
5. Fill in the template placeholders and run home-manager switch
6. Verify: launchd running, bot responds to messages
Reference the nix-clawdbot README for module options.
```
> **📦 Full guide: [github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot)**
>
> The nix-clawdbot repo is the source of truth for Nix installation. This page is just a quick overview.
## What you get
- Gateway + macOS app + tools (whisper, spotify, cameras) — all pinned
- Launchd service that survives reboots
- Plugin system with declarative config
- Instant rollback: `home-manager switch --rollback`
---
## Nix Mode Runtime Behavior
When `CLAWDBOT_NIX_MODE=1` is set (automatic with nix-clawdbot):
Clawdbot supports a **Nix mode** that makes configuration deterministic and disables auto-install flows.
Enable it by exporting:
```
```bash
CLAWDBOT_NIX_MODE=1
```
On macOS, the GUI app does not automatically inherit shell env vars. You can
also enable Nix mode via defaults:
```
```bash
defaults write com.clawdbot.mac clawdbot.nixMode -bool true
```
## Config + state paths
### Config + state paths
Clawdbot reads JSON5 config from `CLAWDBOT_CONFIG_PATH` and stores mutable data in `CLAWDBOT_STATE_DIR`.
@@ -30,11 +70,11 @@ Clawdbot reads JSON5 config from `CLAWDBOT_CONFIG_PATH` and stores mutable data
When running under Nix, set these explicitly to Nix-managed locations so runtime state and config
stay out of the immutable store.
## Runtime behavior in Nix mode
### Runtime behavior in Nix mode
- Auto-install and self-mutation flows should be disabled.
- Missing dependencies should surface Nix-specific remediation messages.
- UI surfaces a read-only Nix mode banner when present.
- Auto-install and self-mutation flows are disabled
- Missing dependencies surface Nix-specific remediation messages
- UI surfaces a read-only Nix mode banner when present
## Packaging note (macOS)
@@ -47,3 +87,9 @@ apps/macos/Sources/Clawdbot/Resources/Info.plist
`scripts/package-mac-app.sh` copies this template into the app bundle and patches dynamic fields
(bundle ID, version/build, Git SHA, Sparkle keys). This keeps the plist deterministic for SwiftPM
packaging and Nix builds (which do not rely on a full Xcode toolchain).
## Related
- [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) — full setup guide
- [Wizard](./wizard.md) — non-Nix CLI setup
- [Docker](./docker.md) — containerized setup
+28 -25
View File
@@ -1,18 +1,17 @@
---
summary: "Planned first-run onboarding flow for Clawdbot (local vs remote, Anthropic OAuth, workspace bootstrap ritual)"
summary: "Planned first-run onboarding flow for Clawdbot (local vs remote, OAuth auth, workspace bootstrap ritual)"
read_when:
- Designing the macOS onboarding assistant
- Implementing Anthropic auth or identity setup
- Implementing Anthropic/OpenAI auth or identity setup
---
<!-- {% raw %} -->
# Onboarding (macOS app)
This doc describes the intended **first-run onboarding** for Clawdbot. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for the embedded agent runtime, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
This doc describes the intended **first-run onboarding** for Clawdbot. The goal is a good “day 0” experience: pick where the Gateway runs, bind subscription auth (Anthropic or OpenAI) for the embedded agent runtime, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
## Page order (high level)
1) **Local vs Remote**
2) **(Local only)** Connect Claude (Anthropic OAuth) — optional, but recommended
2) **(Local only)** Connect subscription auth (Anthropic / OpenAI OAuth) — optional, but recommended
3) **Connect Gmail (optional)** — run `clawdbot hooks gmail setup` to configure Pub/Sub hooks
4) **Onboarding chat** — dedicated session where the agent introduces itself and guides setup
@@ -20,7 +19,7 @@ This doc describes the intended **first-run onboarding** for Clawdbot. The goal
First question: where does the **Gateway** run?
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdbot token store locally.
- **Local (this Mac):** onboarding can run OAuth flows and write the Clawdbot auth store locally.
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
Gateway auth tip:
@@ -29,20 +28,28 @@ Gateway auth tip:
Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user).
## 2) Local-only: Connect Claude (Anthropic OAuth)
## 2) Local-only: Connect subscription auth (Anthropic / OpenAI OAuth)
This is the “bind Clawdbot to Anthropic” step. It is explicitly the **Anthropic (Claude Pro/Max) OAuth flow**, not a generic “login”.
This is the “bind Clawdbot to subscription auth” step. It is explicitly the **Anthropic (Claude Pro/Max)** or **OpenAI (ChatGPT/Codex)** OAuth flow, not a generic “login”.
### Recommended: OAuth
### Recommended: OAuth (Anthropic)
The macOS app should:
- Start the Anthropic OAuth (PKCE) flow in the user’s browser.
- Ask the user to paste the `code#state` value.
- Exchange it for tokens and write credentials to:
- `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
- `~/.clawdbot/agent/auth.json` (file mode `0600`, directory mode `0700`)
Why this location matters: it’s the Clawdbot-owned OAuth store.
On first run, Clawdbot can import existing OAuth tokens from legacy p/Claude locations if present.
Why this location matters: it’s the Clawdbot-owned auth store (OAuth + API keys).
Clawdbot auto-migrates legacy OAuth tokens from `~/.clawdbot/credentials/oauth.json` (and older pi/Claude locations) into `auth.json` on first use.
### Recommended: OAuth (OpenAI Codex)
The macOS app should:
- Start the OpenAI Codex OAuth (PKCE) flow in the user’s browser.
- Auto-capture the callback on `http://127.0.0.1:1455/auth/callback` when possible.
- If the callback fails, prompt the user to paste the redirect URL or code.
- Store credentials in `~/.clawdbot/agent/auth.json` (same auth store as Anthropic).
### Alternative: API key (instructions only)
@@ -137,30 +144,27 @@ Daily memory lives under `memory/` in the workspace:
## Remote mode note (why OAuth is hidden)
If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where the agent runtime runs).
If the Gateway runs on another machine, OAuth credentials must be created/stored on that host (where the agent runtime runs).
For now, remote onboarding should:
- explain why OAuth isn't shown
- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the workspace location on the gateway host
- point the user at the credential location (`~/.clawdbot/agent/auth.json`) and the workspace location on the gateway host
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
### Manual credential setup
On the gateway host, create `~/.clawdbot/credentials/oauth.json` with this exact format:
On the gateway host, create `~/.clawdbot/agent/auth.json` with this exact format:
```json
{
"anthropic": {
"access": "sk-ant-oat01-...",
"refresh": "sk-ant-ort01-...",
"expires": 1767304352803
}
"anthropic": { "type": "oauth", "access": "sk-ant-oat01-...", "refresh": "sk-ant-ort01-...", "expires": 1767304352803 },
"openai-codex": { "type": "oauth", "access": "eyJhbGciOi...", "refresh": "oai-refresh-...", "expires": 1767304352803, "accountId": "acct_..." }
}
```
Set permissions: `chmod 600 ~/.clawdbot/credentials/oauth.json`
Set permissions: `chmod 600 ~/.clawdbot/agent/auth.json`
**Note:** Clawdbot can auto-import from legacy pi-coding-agent paths (`~/.pi/agent/oauth.json`, etc.) but this does NOT work with Claude Code credentials — different file and format.
**Note:** Clawdbot auto-imports from legacy pi-coding-agent paths (`~/.pi/agent/oauth.json`, etc.) but this does NOT work with Claude Code credentials — different file and format.
### Using Claude Code credentials
@@ -173,8 +177,8 @@ cat ~/.claude/.credentials.json | jq '{
refresh: .claudeAiOauth.refreshToken,
expires: .claudeAiOauth.expiresAt
}
}' > ~/.clawdbot/credentials/oauth.json
chmod 600 ~/.clawdbot/credentials/oauth.json
}' > ~/.clawdbot/agent/auth.json
chmod 600 ~/.clawdbot/agent/auth.json
```
| Claude Code field | Clawdbot field |
@@ -182,4 +186,3 @@ chmod 600 ~/.clawdbot/credentials/oauth.json
| `accessToken` | `access` |
| `refreshToken` | `refresh` |
| `expiresAt` | `expires` |
<!-- {% endraw %} -->
+65
View File
@@ -0,0 +1,65 @@
---
summary: "Refactor plan: unify agent lifecycle events and wait semantics"
read_when:
- Refactoring agent lifecycle events or wait behavior
---
# Refactor: Agent Loop
Goal: align Clawdis run lifecycle with pi/mom semantics, remove ambiguity between "job" and "agent_end".
## Problem
- Two lifecycles today:
- `job` (gateway wrapper) => used by `agent.wait` + chat final
- pi-agent `agent_end` (inner loop) => only logged
- This can finalize early (job done) while late assistant deltas still arrive.
- `afterMs` and timeouts can cause false timeouts in `agent.wait`.
## Reference (mom)
- Single lifecycle: `agent_start`/`agent_end` from pi-agent-core event stream.
- `waitForIdle()` resolves on `agent_end`.
- No separate job state exposed to clients.
## Proposed refactor (breaking allowed)
1) Replace public `job` stream with `lifecycle` stream
- `stream: "lifecycle"`
- `data: { phase: "start" | "end" | "error", startedAt, endedAt, error? }`
2) `agent.wait` waits on lifecycle end/error only
- remove `afterMs`
- return `{ runId, status, startedAt, endedAt, error? }`
3) Chat final emitted on lifecycle end only
- deltas still from `assistant` stream
4) Centralize run registry
- one map keyed by runId: sessionKey, startedAt, lastSeq, bufferedText
- clear on lifecycle end
## Implementation outline
- `src/agents/pi-embedded-subscribe.ts`
- emit lifecycle start/end events (translate pi `agent_start`/`agent_end`)
- `src/infra/agent-events.ts`
- add `"lifecycle"` to stream type
- `src/gateway/protocol/schema.ts`
- update AgentEvent schema; update AgentWait params (remove afterMs, add status)
- `src/gateway/server-methods/agent-job.ts`
- rename to `agent-wait.ts` or similar; wait on lifecycle end/error
- `src/gateway/server-chat.ts`
- finalize on lifecycle end (not job)
- `src/commands/agent.ts`
- stop emitting `job` externally (keep internal log if needed)
## Migration notes (breaking)
- Update all callers of `agent.wait` to new response shape.
- Update tests that expect `timeout` based on job events.
- If any UI relies on job state, map lifecycle instead.
## Risks
- If lifecycle events are dropped, wait/chat could hang; add timeout in `agent.wait` to fail fast.
- Late deltas after lifecycle end should be ignored; keep seq tracking + drop.
## Acceptance
- One lifecycle visible to clients.
- `agent.wait` resolves when agent loop ends, not wrapper completion.
- Chat final never emits before last assistant delta.
## Rollout (if we wanted safety)
- Gate with config flag `agent.lifecycleMode = "legacy"|"refactor"`.
- Remove legacy after one release.
-2
View File
@@ -3,7 +3,6 @@ summary: "WebChat session migration notes (Gateway WS-only)"
read_when:
- Changing WebChat Gateway methods/events
---
<!-- {% raw %} -->
# WebAgent session migration (WS-only)
Context: web chat currently lives in a WKWebView that loads the pi-web bundle. Sends go over HTTP `/rpc` to the webchat server, and updates come from `/socket` snapshots based on session JSONL file changes. The Gateway itself already speaks WebSocket to the webchat server, and Pi writes the session JSONL files. This doc tracks the plan to move WebChat to a single Gateway WebSocket and drop the HTTP shim/file-watching.
@@ -43,4 +42,3 @@ Context: web chat currently lives in a WKWebView that loads the pi-web bundle. S
- Streaming granularity: start with `state:"final"` only, or include token/tool deltas immediately?
- Attachments over WS: text-only initially is OK; confirm before wiring binary/upload path.
- Error shape: use `res ok:false` for validation/timeout, `chat state:"error"` for model/runtime failures.
<!-- {% endraw %} -->
+1 -1
View File
@@ -111,7 +111,7 @@ Example:
## Retain
- W @Peter: Currently in Marrakech (Nov 27–Dec 1, 2025) for Andy’s birthday.
- B @warelay: I fixed the Baileys WS crash by wrapping connection.update handlers in try/catch (see memory/2025-11-27.md).
- O(c=0.95) @Peter: Prefers concise replies (<1500 chars) on WhatsApp; long content goes into files.
- O(c=0.95) @Peter: Prefers concise replies (&lt;1500 chars) on WhatsApp; long content goes into files.
```
Minimal parsing:
-2
View File
@@ -4,7 +4,6 @@ read_when:
- Adding or modifying skills
- Changing skill gating or load rules
---
<!-- {% raw %} -->
# Skills (Clawdbot)
Clawdbot uses **AgentSkills-compatible** skill folders to teach the agent how to use tools. Each skill is a directory containing a `SKILL.md` with YAML frontmatter and instructions. Clawdbot loads **bundled skills** plus optional local overrides, and filters them at load time based on environment, config, and binary presence.
@@ -146,4 +145,3 @@ copy). Workspace skills are user-owned and override both on name conflicts.
See `docs/skills-config.md` for the full configuration schema.
---
<!-- {% endraw %} -->
+2 -2
View File
@@ -103,7 +103,7 @@ When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every ti
**When to reach out:**
- Important email arrived
- Calendar event coming up (<2h)
- Calendar event coming up (&lt;2h)
- Something interesting you found
- It's been >8h since you said anything
@@ -111,7 +111,7 @@ When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every ti
- Late night (23:00-08:00) unless urgent
- Human is clearly busy
- Nothing new since last check
- You just checked <30 minutes ago
- You just checked &lt;30 minutes ago
**Proactive work you can do without asking:**
- Read and organize memory files
+28
View File
@@ -11,6 +11,21 @@ Clawdbot exposes **first-class agent tools** for browser, canvas, nodes, and cro
These replace the old `clawdbot-*` skills: the tools are typed, no shelling,
and the agent should rely on them directly.
## Disabling tools
You can globally allow/deny tools via `agent.tools` in `clawdbot.json`
(deny wins). This prevents disallowed tools from being sent to providers.
```json5
{
agent: {
tools: {
deny: ["browser"]
}
}
}
```
## Tool inventory
### `bash`
@@ -101,6 +116,19 @@ Notes:
- Videos return `FILE:<path>` (mp4).
- Location returns a JSON payload (lat/lon/accuracy/timestamp).
### `image`
Analyze an image with the configured image model.
Core parameters:
- `image` (required path or URL)
- `prompt` (optional; defaults to "Describe the image.")
- `model` (optional override)
- `maxBytesMb` (optional size cap)
Notes:
- Only available when `agent.imageModel` or `agent.imageModelFallbacks` is set.
- Uses the image model directly (independent of the main chat model).
### `cron`
Manage Gateway cron jobs and wakeups.
+2
View File
@@ -33,6 +33,8 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
**Tip:** The number only needs to receive one verification SMS. After that, WhatsApp Web sessions persist via `creds.json`.
**WhatsApp Business:** You can use WhatsApp Business on the same phone with a different number. This is a great option if you want to keep your personal WhatsApp separate — just install WhatsApp Business and register it with Clawdbot's dedicated number.
## Login + credentials
- Login command: `clawdbot login` (QR via Linked Devices).
- Credentials stored in `~/.clawdbot/credentials/creds.json`.
+4 -1
View File
@@ -26,7 +26,7 @@ clawdbot configure
## What the wizard does
**Local mode (default)** walks you through:
- Model/auth (Anthropic OAuth recommended, API key optional, Minimax M2.1 via LM Studio)
- Model/auth (Anthropic or OpenAI Codex OAuth recommended, API key optional, Minimax M2.1 via LM Studio)
- Workspace location + bootstrap files
- Gateway settings (port/bind/auth/tailscale)
- Providers (WhatsApp, Telegram, Discord, Signal)
@@ -48,9 +48,11 @@ It does **not** install or change anything on the remote host.
2) **Model/Auth**
- **Anthropic OAuth (recommended)**: browser flow; paste the `code#state`.
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
- **API key**: stores the key for you.
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
- **Skip**: no auth configured yet.
- OAuth + API keys are stored in `~/.clawdbot/agent/auth.json`.
3) **Workspace**
- Default `~/clawd` (configurable).
@@ -83,6 +85,7 @@ It does **not** install or change anything on the remote host.
9) **Finish**
- Summary + next steps, including iOS/Android/macOS apps for extra features.
- If no GUI is detected, the wizard prints SSH port-forward instructions for the Control UI instead of opening a browser.
## Remote mode
+26 -18
View File
@@ -1,11 +1,11 @@
{
"name": "clawdbot",
"version": "2.0.0-beta5",
"version": "2026.1.5-3",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
"bin": {
"clawdbot": "dist/index.js"
"clawdbot": "dist/entry.js"
},
"files": [
"dist/agents/**",
@@ -15,17 +15,25 @@
"dist/cli/**",
"dist/commands/**",
"dist/config/**",
"dist/control-ui/**",
"dist/cron/**",
"dist/daemon/**",
"dist/discord/**",
"dist/gateway/**",
"dist/hooks/**",
"dist/imessage/**",
"dist/infra/**",
"dist/macos/**",
"dist/media/**",
"dist/process/**",
"dist/sessions/**",
"dist/providers/**",
"dist/signal/**",
"dist/slack/**",
"dist/telegram/**",
"dist/tui/**",
"dist/web/**",
"dist/wizard/**",
"dist/*.js",
"dist/*.json",
"docs/**",
@@ -36,19 +44,19 @@
"LICENSE"
],
"scripts": {
"dev": "tsx src/index.ts",
"dev": "tsx src/entry.ts",
"docs:list": "tsx scripts/docs-list.ts",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && mint broken-links",
"docs:build": "cd docs && pnpm dlx mint broken-links",
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
"release:check": "tsx scripts/release-check.ts",
"ui:install": "pnpm -C ui install",
"ui:dev": "pnpm -C ui dev",
"ui:build": "pnpm -C ui build",
"start": "tsx src/index.ts",
"clawdbot": "tsx src/index.ts",
"gateway:watch": "tsx watch --clear-screen=false --include 'src/**/*.ts' src/index.ts gateway --force",
"clawdbot:rpc": "tsx src/index.ts agent --mode rpc --json",
"start": "tsx src/entry.ts",
"clawdbot": "tsx src/entry.ts",
"gateway:watch": "tsx watch --clear-screen=false --include 'src/**/*.ts' src/entry.ts gateway --force",
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
"lint": "biome check src test && oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:fix": "biome check --write --unsafe src && biome format --write src",
@@ -75,13 +83,13 @@
"@clack/prompts": "^0.11.0",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.32.3",
"@mariozechner/pi-ai": "^0.32.3",
"@mariozechner/pi-coding-agent": "^0.32.3",
"@mariozechner/pi-tui": "^0.32.3",
"@slack/bolt": "^4.5.0",
"@slack/web-api": "^7.11.1",
"@mariozechner/pi-agent-core": "^0.36.0",
"@mariozechner/pi-ai": "^0.36.0",
"@mariozechner/pi-coding-agent": "^0.36.0",
"@mariozechner/pi-tui": "^0.36.0",
"@sinclair/typebox": "0.34.46",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.13.0",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ajv": "^8.17.1",
"body-parser": "^2.2.1",
@@ -91,11 +99,11 @@
"commander": "^14.0.2",
"croner": "^9.1.0",
"detect-libc": "^2.1.2",
"discord-api-types": "^0.38.33",
"discord-api-types": "^0.38.37",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"file-type": "^21.2.0",
"file-type": "^21.3.0",
"grammy": "^1.39.2",
"json5": "^2.2.3",
"long": "5.3.2",
@@ -105,10 +113,10 @@
"tslog": "^4.10.2",
"undici": "^7.16.0",
"ws": "^8.18.3",
"zod": "^4.3.4"
"zod": "^4.3.5"
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@biomejs/biome": "^2.3.11",
"@lit-labs/signals": "^0.2.0",
"@lit/context": "^1.1.6",
"@mariozechner/mini-lit": "0.2.1",
+202 -95
View File
@@ -26,25 +26,25 @@ importers:
specifier: ^1.3.4
version: 1.3.4
'@mariozechner/pi-agent-core':
specifier: ^0.32.3
version: 0.32.3(ws@8.18.3)(zod@4.3.4)
specifier: ^0.36.0
version: 0.36.0(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: ^0.32.3
version: 0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)
specifier: ^0.36.0
version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
specifier: ^0.32.3
version: 0.32.3(ws@8.18.3)(zod@4.3.4)
specifier: ^0.36.0
version: 0.36.0(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-tui':
specifier: ^0.32.3
version: 0.32.3
specifier: ^0.36.0
version: 0.36.0
'@sinclair/typebox':
specifier: 0.34.46
version: 0.34.46
'@slack/bolt':
specifier: ^4.5.0
specifier: ^4.6.0
version: 4.6.0(@types/express@5.0.6)
'@slack/web-api':
specifier: ^7.11.1
specifier: ^7.13.0
version: 7.13.0
'@whiskeysockets/baileys':
specifier: 7.0.0-rc.9
@@ -74,7 +74,7 @@ importers:
specifier: ^2.1.2
version: 2.1.2
discord-api-types:
specifier: ^0.38.33
specifier: ^0.38.37
version: 0.38.37
discord.js:
specifier: ^14.25.1
@@ -86,8 +86,8 @@ importers:
specifier: ^5.2.1
version: 5.2.1
file-type:
specifier: ^21.2.0
version: 21.2.0
specifier: ^21.3.0
version: 21.3.0
grammy:
specifier: ^1.39.2
version: 1.39.2
@@ -116,12 +116,12 @@ importers:
specifier: ^8.18.3
version: 8.18.3
zod:
specifier: ^4.3.4
version: 4.3.4
specifier: ^4.3.5
version: 4.3.5
devDependencies:
'@biomejs/biome':
specifier: ^2.3.10
version: 2.3.10
specifier: ^2.3.11
version: 2.3.11
'@lit-labs/signals':
specifier: ^0.2.0
version: 0.2.0
@@ -200,9 +200,15 @@ importers:
ui:
dependencies:
dompurify:
specifier: ^3.3.1
version: 3.3.1
lit:
specifier: ^3.3.2
version: 3.3.2
marked:
specifier: ^17.0.1
version: 17.0.1
devDependencies:
'@vitest/browser-playwright':
specifier: 4.0.16
@@ -260,55 +266,55 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@biomejs/biome@2.3.10':
resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==}
'@biomejs/biome@2.3.11':
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.3.10':
resolution: {integrity: sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==}
'@biomejs/cli-darwin-arm64@2.3.11':
resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.3.10':
resolution: {integrity: sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==}
'@biomejs/cli-darwin-x64@2.3.11':
resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.3.10':
resolution: {integrity: sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==}
'@biomejs/cli-linux-arm64-musl@2.3.11':
resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.3.10':
resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==}
'@biomejs/cli-linux-arm64@2.3.11':
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.3.10':
resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==}
'@biomejs/cli-linux-x64-musl@2.3.11':
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.3.10':
resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==}
'@biomejs/cli-linux-x64@2.3.11':
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.3.10':
resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==}
'@biomejs/cli-win32-arm64@2.3.11':
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.3.10':
resolution: {integrity: sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==}
'@biomejs/cli-win32-x64@2.3.11':
resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -332,6 +338,57 @@ packages:
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@crosscopy/clipboard-darwin-arm64@0.2.8':
resolution: {integrity: sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@crosscopy/clipboard-darwin-universal@0.2.8':
resolution: {integrity: sha512-btGV1tLpJWZ4iKa66niahvpZpVRJzgQnYUE+PUX3YYZzaWD0ESuHuVtKVC8sR+b4dsXIiWW5skXbcRmLsF4rtA==}
engines: {node: '>= 10'}
os: [darwin]
'@crosscopy/clipboard-darwin-x64@0.2.8':
resolution: {integrity: sha512-0QMKf0XrLZrprYYXU4lgaTuzbnYPh9wH6PvsfDB1FZvWf6rOi0syTaBZYnoghbQe700qwLPEfBRjgljJ3Tn6oA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@crosscopy/clipboard-linux-arm64-gnu@0.2.8':
resolution: {integrity: sha512-8YrU03MRsygymqEcHkNgqCqSCQbYRmJCnMXeS4i8FYeOkAxBEeRvPbHoNmI10uppXJZNZgfIKM7Qqk9tEHiwqQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@crosscopy/clipboard-linux-riscv64-gnu@0.2.8':
resolution: {integrity: sha512-/QWLhnb0QYVjEv5GOAC1q+1DaezYU8Th+IoDKUCsR5i43Cqm+g+N/I2K35yo3J+HHkK9XNbtIDZDXlFgK6tRUg==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@crosscopy/clipboard-linux-x64-gnu@0.2.8':
resolution: {integrity: sha512-j17eaF/onP/6VAGGKtxA1KmmkErmdjta9gMdMV/yUmgeBYzJ9fMpWUzbk2vmaOyXfhaSzR/sk1P6VLBmvCpqHg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@crosscopy/clipboard-win32-arm64-msvc@0.2.8':
resolution: {integrity: sha512-MVkMyuYN3y5v0s4HrijM0iA8hZVmpUhHd8X4zKG30t4nE6MbOjOt/8EabMrVmGZlsLeOL2sa0o8Wo9bvhWU+vA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@crosscopy/clipboard-win32-x64-msvc@0.2.8':
resolution: {integrity: sha512-/GpiB4B3lSgg7eCLDQw9NfFjtQFjo0S88IL+EK54Hx7ZgAP4Ad/ezP/8dw0cA+N/M6iPYy0reCIjW9st82/uxw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@crosscopy/clipboard@0.2.8':
resolution: {integrity: sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==}
engines: {node: '>= 10'}
'@discordjs/builders@1.13.1':
resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==}
engines: {node: '>=16.11.0'}
@@ -360,11 +417,11 @@ packages:
resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==}
engines: {node: '>=16.11.0'}
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
'@emnapi/runtime@1.7.1':
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
'@emnapi/runtime@1.8.1':
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@@ -744,22 +801,22 @@ packages:
peerDependencies:
lit: ^3.3.1
'@mariozechner/pi-agent-core@0.32.3':
resolution: {integrity: sha512-NN/fd8eBISnRbQYblLjiaRbcFjz+SEFOGhZDZCXYgHJikTV8VyEon0nZFh7agBZJ7sXZaumMTjw/pGFrBFKcmA==}
'@mariozechner/pi-agent-core@0.36.0':
resolution: {integrity: sha512-86BI1/j/MLxQHSWRXVLz8+NuSmDvLQebNb40+lFDI9XI9YBh8+r5fkYgU43u4j2TvANZ7iW6SFFnhWhzy8y6dg==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.32.3':
resolution: {integrity: sha512-njaa4/pN7U1TbjI1PKqII3M/W1CQ/obPAu5A4KHsX74e8sKjDEiEELUWzLTa5xOj+l1OXp/FtzI0hfJDq682rQ==}
'@mariozechner/pi-ai@0.36.0':
resolution: {integrity: sha512-xkzTgvdMzAZ/L/TgMH8z9Zi+aH0EWc54l5ygiafwvCgDk7xvfbylQG6pa9yn5zEn9T4NF9byJNk+nMHnycZvMQ==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.32.3':
resolution: {integrity: sha512-kOuzflbWc8GWHGOu/7KJQ1P3IodDk4AdOvibkJDcAn2aw5kG+LZAjLGt72tIMQRQna51i9XzL4nSvbxqV+mgWw==}
'@mariozechner/pi-coding-agent@0.36.0':
resolution: {integrity: sha512-lKdpuGE0yVs/96GnDhrPLEEFhRteHRtnkfX04KIBpcsEXXg2vyAlpxtjtZ9nlhYqLLIY7qJRkeyjbhcFFfbAAA==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-tui@0.32.3':
resolution: {integrity: sha512-aq7e6qXcwSHvaTUKj5Ut9BY+ZezQ2rL+V4Sd2R5SnBW6JMCt5GnR/8uYBC2PcdxBQzGL9TnzjrC4av5jR+ilow==}
'@mariozechner/pi-tui@0.36.0':
resolution: {integrity: sha512-4n+nmTd36q0AVCbqWmjtTHTjIEwlGayKKhc+4QbpN9U3Z9jyQQa8Za1P2OHRmi6Jeu+ISuf4VBDvgmgCaxPZYg==}
engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.10.0':
@@ -1627,6 +1684,9 @@ packages:
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
@@ -1747,8 +1807,8 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
file-type@21.2.0:
resolution: {integrity: sha512-vCYBgFOrJQLoTzDyAXAL/RFfKnXXpUYt4+tipVy26nJJhT7ftgGETf2tAQF59EEL61i3MrorV/PG6tf7LJK7eg==}
file-type@21.3.0:
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
engines: {node: '>=20'}
fill-range@7.1.1:
@@ -2200,6 +2260,11 @@ packages:
engines: {node: '>= 20'}
hasBin: true
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -3045,16 +3110,16 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.4:
resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==}
zod@4.3.5:
resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
snapshots:
'@anthropic-ai/sdk@0.71.2(zod@4.3.4)':
'@anthropic-ai/sdk@0.71.2(zod@4.3.5)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.3.4
zod: 4.3.5
'@babel/code-frame@7.27.1':
dependencies:
@@ -3080,39 +3145,39 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.3.10':
'@biomejs/biome@2.3.11':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.10
'@biomejs/cli-darwin-x64': 2.3.10
'@biomejs/cli-linux-arm64': 2.3.10
'@biomejs/cli-linux-arm64-musl': 2.3.10
'@biomejs/cli-linux-x64': 2.3.10
'@biomejs/cli-linux-x64-musl': 2.3.10
'@biomejs/cli-win32-arm64': 2.3.10
'@biomejs/cli-win32-x64': 2.3.10
'@biomejs/cli-darwin-arm64': 2.3.11
'@biomejs/cli-darwin-x64': 2.3.11
'@biomejs/cli-linux-arm64': 2.3.11
'@biomejs/cli-linux-arm64-musl': 2.3.11
'@biomejs/cli-linux-x64': 2.3.11
'@biomejs/cli-linux-x64-musl': 2.3.11
'@biomejs/cli-win32-arm64': 2.3.11
'@biomejs/cli-win32-x64': 2.3.11
'@biomejs/cli-darwin-arm64@2.3.10':
'@biomejs/cli-darwin-arm64@2.3.11':
optional: true
'@biomejs/cli-darwin-x64@2.3.10':
'@biomejs/cli-darwin-x64@2.3.11':
optional: true
'@biomejs/cli-linux-arm64-musl@2.3.10':
'@biomejs/cli-linux-arm64-musl@2.3.11':
optional: true
'@biomejs/cli-linux-arm64@2.3.10':
'@biomejs/cli-linux-arm64@2.3.11':
optional: true
'@biomejs/cli-linux-x64-musl@2.3.10':
'@biomejs/cli-linux-x64-musl@2.3.11':
optional: true
'@biomejs/cli-linux-x64@2.3.10':
'@biomejs/cli-linux-x64@2.3.11':
optional: true
'@biomejs/cli-win32-arm64@2.3.10':
'@biomejs/cli-win32-arm64@2.3.11':
optional: true
'@biomejs/cli-win32-x64@2.3.10':
'@biomejs/cli-win32-x64@2.3.11':
optional: true
'@borewit/text-codec@0.2.1': {}
@@ -3146,6 +3211,41 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
'@crosscopy/clipboard-darwin-arm64@0.2.8':
optional: true
'@crosscopy/clipboard-darwin-universal@0.2.8':
optional: true
'@crosscopy/clipboard-darwin-x64@0.2.8':
optional: true
'@crosscopy/clipboard-linux-arm64-gnu@0.2.8':
optional: true
'@crosscopy/clipboard-linux-riscv64-gnu@0.2.8':
optional: true
'@crosscopy/clipboard-linux-x64-gnu@0.2.8':
optional: true
'@crosscopy/clipboard-win32-arm64-msvc@0.2.8':
optional: true
'@crosscopy/clipboard-win32-x64-msvc@0.2.8':
optional: true
'@crosscopy/clipboard@0.2.8':
optionalDependencies:
'@crosscopy/clipboard-darwin-arm64': 0.2.8
'@crosscopy/clipboard-darwin-universal': 0.2.8
'@crosscopy/clipboard-darwin-x64': 0.2.8
'@crosscopy/clipboard-linux-arm64-gnu': 0.2.8
'@crosscopy/clipboard-linux-riscv64-gnu': 0.2.8
'@crosscopy/clipboard-linux-x64-gnu': 0.2.8
'@crosscopy/clipboard-win32-arm64-msvc': 0.2.8
'@crosscopy/clipboard-win32-x64-msvc': 0.2.8
'@discordjs/builders@1.13.1':
dependencies:
'@discordjs/formatters': 0.6.2
@@ -3195,13 +3295,13 @@ snapshots:
- bufferutil
- utf-8-validate
'@emnapi/core@1.7.1':
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.7.1':
'@emnapi/runtime@1.8.1':
dependencies:
tslib: 2.8.1
optional: true
@@ -3409,7 +3509,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.7.1
'@emnapi/runtime': 1.8.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -3485,10 +3585,10 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@mariozechner/pi-agent-core@0.32.3(ws@8.18.3)(zod@4.3.4)':
'@mariozechner/pi-agent-core@0.36.0(ws@8.18.3)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-tui': 0.32.3
'@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-tui': 0.36.0
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3497,18 +3597,18 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)':
'@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.4)
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@google/genai': 1.34.0
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.46
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
chalk: 5.6.2
openai: 6.10.0(ws@8.18.3)(zod@4.3.4)
openai: 6.10.0(ws@8.18.3)(zod@4.3.5)
partial-json: 0.1.7
zod-to-json-schema: 3.25.1(zod@4.3.4)
zod-to-json-schema: 3.25.1(zod@4.3.5)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3517,15 +3617,16 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.32.3(ws@8.18.3)(zod@4.3.4)':
'@mariozechner/pi-coding-agent@0.36.0(ws@8.18.3)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-agent-core': 0.32.3(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai': 0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-tui': 0.32.3
'@crosscopy/clipboard': 0.2.8
'@mariozechner/pi-agent-core': 0.36.0(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-tui': 0.36.0
chalk: 5.6.2
cli-highlight: 2.1.11
diff: 8.0.2
file-type: 21.2.0
file-type: 21.3.0
glob: 11.1.0
jiti: 2.6.1
marked: 15.0.12
@@ -3538,7 +3639,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-tui@0.32.3':
'@mariozechner/pi-tui@0.36.0':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -3553,8 +3654,8 @@ snapshots:
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.7.1
'@emnapi/runtime': 1.7.1
'@emnapi/core': 1.8.1
'@emnapi/runtime': 1.8.1
'@tybys/wasm-util': 0.10.1
optional: true
@@ -4418,6 +4519,10 @@ snapshots:
dom-accessibility-api@0.5.16:
optional: true
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
dotenv@17.2.3: {}
dunder-proto@1.0.1:
@@ -4568,7 +4673,7 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
file-type@21.2.0:
file-type@21.3.0:
dependencies:
'@tokenizer/inflate': 0.4.1
strtok3: 10.3.4
@@ -5032,6 +5137,8 @@ snapshots:
marked@16.4.2: {}
marked@17.0.1: {}
math-intrinsics@1.1.0: {}
mdurl@2.0.0: {}
@@ -5086,7 +5193,7 @@ snapshots:
'@tokenizer/token': 0.3.0
content-type: 1.0.5
debug: 4.4.3
file-type: 21.2.0
file-type: 21.3.0
media-typer: 1.1.0
strtok3: 10.3.4
token-types: 6.1.2
@@ -5149,10 +5256,10 @@ snapshots:
dependencies:
wrappy: 1.0.2
openai@6.10.0(ws@8.18.3)(zod@4.3.4):
openai@6.10.0(ws@8.18.3)(zod@4.3.5):
optionalDependencies:
ws: 8.18.3
zod: 4.3.4
zod: 4.3.5
opus-decoder@0.7.11:
dependencies:
@@ -5910,10 +6017,10 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1(zod@4.3.4):
zod-to-json-schema@3.25.1(zod@4.3.5):
dependencies:
zod: 4.3.4
zod: 4.3.5
zod@3.25.76: {}
zod@4.3.4: {}
zod@4.3.5: {}
+7 -1
View File
@@ -16,7 +16,10 @@ GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || ec
APP_VERSION="${APP_VERSION:-$PKG_VERSION}"
APP_BUILD="${APP_BUILD:-$GIT_BUILD_NUMBER}"
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-arm64 x86_64}"
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-$(uname -m)}"
if [[ "${BUILD_ARCHS_VALUE}" == "all" ]]; then
BUILD_ARCHS_VALUE="arm64 x86_64"
fi
IFS=' ' read -r -a BUILD_ARCHS <<< "$BUILD_ARCHS_VALUE"
PRIMARY_ARCH="${BUILD_ARCHS[0]}"
SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}"
@@ -251,6 +254,9 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
fi
rm -rf "$RELAY_BUILD_DIR"
echo "🧪 Smoke testing bundled relay QR modules"
CLAWDBOT_SMOKE_QR=1 "$RELAY_OUT" >/dev/null
echo "🎨 Copying gateway A2UI host assets"
rm -rf "$RELAY_DIR/a2ui"
cp -R "$ROOT_DIR/src/canvas-host/a2ui" "$RELAY_DIR/a2ui"
+6 -1
View File
@@ -22,7 +22,12 @@ VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Co
ZIP="$ROOT_DIR/dist/Clawdbot-$VERSION.zip"
DMG="$ROOT_DIR/dist/Clawdbot-$VERSION.dmg"
NOTARY_ZIP="$ROOT_DIR/dist/Clawdbot-$VERSION.notary.zip"
NOTARIZE="${NOTARIZE:-0}"
SKIP_NOTARIZE="${SKIP_NOTARIZE:-0}"
NOTARIZE=1
if [[ "$SKIP_NOTARIZE" == "1" ]]; then
NOTARIZE=0
fi
if [[ "$NOTARIZE" == "1" ]]; then
echo "📦 Notary zip: $NOTARY_ZIP"
+3 -3
View File
@@ -27,7 +27,7 @@ const outPath = path.join(
"GatewayModels.swift",
);
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable {\n${Object.values(ErrorCodes)
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(ErrorCodes)
.map((c) => ` case ${camelCase(c)} = "${c}"`)
.join("\n")}\n}\n`;
@@ -106,7 +106,7 @@ function emitStruct(name: string, schema: JsonSchema): string {
const props = schema.properties ?? {};
const required = new Set(schema.required ?? []);
const lines: string[] = [];
lines.push(`public struct ${name}: Codable {`);
lines.push(`public struct ${name}: Codable, Sendable {`);
if (Object.keys(props).length === 0) {
lines.push("}\n");
return lines.join("\n");
@@ -188,7 +188,7 @@ function emitGatewayFrame(): string {
`;
return [
"public enum GatewayFrame: Codable {",
"public enum GatewayFrame: Codable, Sendable {",
...caseLines,
" case unknown(type: String, raw: [String: AnyCodable])",
initLines,
+1 -2
View File
@@ -228,7 +228,6 @@ When submitting PRs to external repos, use this format for quality & maintainer-
# Example
command example
```
````
## Feature intent (maintainer-friendly)
[Why useful, how it fits, workflows it enables]
@@ -264,7 +263,7 @@ command example
---
*Submitted by Razor 🥷 - Mariano's AI agent*
```
````
**Key principles:**
1. Human-written description (no AI slop)
+14 -16
View File
@@ -5,16 +5,20 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
session: {
mainKey: "main",
scope: "per-sender",
agentToAgent: { maxPingPongTurns: 2 },
},
}),
resolveGatewayPort: () => 18789,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
session: {
mainKey: "main",
scope: "per-sender",
agentToAgent: { maxPingPongTurns: 2 },
},
}),
resolveGatewayPort: () => 18789,
};
});
import { createClawdbotTools } from "./clawdbot-tools.js";
@@ -261,12 +265,6 @@ describe("sessions tools", () => {
).toBe(true);
expect(waitCalls).toHaveLength(8);
expect(historyOnlyCalls).toHaveLength(8);
expect(
waitCalls.some(
(call) =>
typeof (call.params as { afterMs?: number })?.afterMs === "number",
),
).toBe(true);
expect(sendCallCount).toBe(0);
});
+5
View File
@@ -1,9 +1,11 @@
import type { ClawdbotConfig } from "../config/config.js";
import { createBrowserTool } from "./tools/browser-tool.js";
import { createCanvasTool } from "./tools/canvas-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool } from "./tools/cron-tool.js";
import { createDiscordTool } from "./tools/discord-tool.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createNodesTool } from "./tools/nodes-tool.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
@@ -14,7 +16,9 @@ export function createClawdbotTools(options?: {
browserControlUrl?: string;
agentSessionKey?: string;
agentSurface?: string;
config?: ClawdbotConfig;
}): AnyAgentTool[] {
const imageTool = createImageTool({ config: options?.config });
return [
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
createCanvasTool(),
@@ -29,5 +33,6 @@ export function createClawdbotTools(options?: {
agentSessionKey: options?.agentSessionKey,
agentSurface: options?.agentSurface,
}),
...(imageTool ? [imageTool] : []),
];
}
+66
View File
@@ -0,0 +1,66 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
const oauthFixture = {
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct_123",
};
describe("getApiKeyForModel", () => {
it("migrates legacy oauth.json into auth.json", async () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-"));
try {
process.env.CLAWDBOT_STATE_DIR = tempDir;
const oauthDir = path.join(tempDir, "credentials");
await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 });
await fs.writeFile(
path.join(oauthDir, "oauth.json"),
`${JSON.stringify({ "openai-codex": oauthFixture }, null, 2)}\n`,
"utf8",
);
const agentDir = path.join(tempDir, "agent");
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
const authStorage = discoverAuthStorage(agentDir);
vi.resetModules();
const { getApiKeyForModel } = await import("./model-auth.js");
const model = {
id: "codex-mini-latest",
provider: "openai-codex",
api: "openai-codex-responses",
} as Model<Api>;
const apiKey = await getApiKeyForModel(model, authStorage);
expect(apiKey).toBe(oauthFixture.access);
const authJson = await fs.readFile(
path.join(agentDir, "auth.json"),
"utf8",
);
const authData = JSON.parse(authJson) as Record<string, unknown>;
expect(authData["openai-codex"]).toMatchObject({
type: "oauth",
access: oauthFixture.access,
refresh: oauthFixture.refresh,
});
} finally {
if (previousStateDir === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
+179
View File
@@ -0,0 +1,179 @@
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import {
type Api,
getEnvApiKey,
getOAuthApiKey,
type Model,
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import type { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
const OAUTH_FILENAME = "oauth.json";
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
let oauthStorageConfigured = false;
let oauthStorageMigrated = false;
type OAuthStorage = Record<string, OAuthCredentials>;
function resolveClawdbotOAuthPath(): string {
const overrideDir =
process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME);
}
function loadOAuthStorageAt(pathname: string): OAuthStorage | null {
if (!fsSync.existsSync(pathname)) return null;
try {
const content = fsSync.readFileSync(pathname, "utf8");
const json = JSON.parse(content) as OAuthStorage;
if (!json || typeof json !== "object") return null;
return json;
} catch {
return null;
}
}
function hasAnthropicOAuth(storage: OAuthStorage): boolean {
const entry = storage.anthropic as
| {
refresh?: string;
refresh_token?: string;
refreshToken?: string;
access?: string;
access_token?: string;
accessToken?: string;
}
| undefined;
if (!entry) return false;
const refresh =
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
const access = entry.access ?? entry.access_token ?? entry.accessToken ?? "";
return Boolean(refresh.trim() && access.trim());
}
function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void {
const dir = path.dirname(pathname);
fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 });
fsSync.writeFileSync(
pathname,
`${JSON.stringify(storage, null, 2)}\n`,
"utf8",
);
fsSync.chmodSync(pathname, 0o600);
}
function legacyOAuthPaths(): string[] {
const paths: string[] = [];
const piOverride = process.env.PI_CODING_AGENT_DIR?.trim();
if (piOverride) {
paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME));
}
paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME));
return Array.from(new Set(paths));
}
function importLegacyOAuthIfNeeded(destPath: string): void {
if (fsSync.existsSync(destPath)) return;
for (const legacyPath of legacyOAuthPaths()) {
const storage = loadOAuthStorageAt(legacyPath);
if (!storage || !hasAnthropicOAuth(storage)) continue;
saveOAuthStorageAt(destPath, storage);
return;
}
}
export function ensureOAuthStorage(): void {
if (oauthStorageConfigured) return;
oauthStorageConfigured = true;
const oauthPath = resolveClawdbotOAuthPath();
importLegacyOAuthIfNeeded(oauthPath);
}
function isValidOAuthCredential(
entry: OAuthCredentials | undefined,
): entry is OAuthCredentials {
if (!entry) return false;
return Boolean(
entry.access?.trim() &&
entry.refresh?.trim() &&
Number.isFinite(entry.expires),
);
}
function migrateOAuthStorageToAuthStorage(
authStorage: ReturnType<typeof discoverAuthStorage>,
): void {
if (oauthStorageMigrated) return;
oauthStorageMigrated = true;
const oauthPath = resolveClawdbotOAuthPath();
const storage = loadOAuthStorageAt(oauthPath);
if (!storage) return;
for (const [provider, creds] of Object.entries(storage)) {
if (!isValidOAuthCredential(creds)) continue;
if (authStorage.get(provider)) continue;
authStorage.set(provider, { type: "oauth", ...creds });
}
}
export function hydrateAuthStorage(
authStorage: ReturnType<typeof discoverAuthStorage>,
): void {
ensureOAuthStorage();
migrateOAuthStorageToAuthStorage(authStorage);
}
function isOAuthProvider(provider: string): provider is OAuthProvider {
return (
provider === "anthropic" ||
provider === "anthropic-oauth" ||
provider === "google" ||
provider === "openai" ||
provider === "openai-compatible" ||
provider === "openai-codex" ||
provider === "github-copilot" ||
provider === "google-gemini-cli" ||
provider === "google-antigravity"
);
}
export async function getApiKeyForModel(
model: Model<Api>,
authStorage: ReturnType<typeof discoverAuthStorage>,
): Promise<string> {
ensureOAuthStorage();
migrateOAuthStorageToAuthStorage(authStorage);
const storedKey = await authStorage.getApiKey(model.provider);
if (storedKey) return storedKey;
if (model.provider === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return oauthEnv.trim();
}
const envKey = getEnvApiKey(model.provider);
if (envKey) return envKey;
if (isOAuthProvider(model.provider)) {
const oauthPath = resolveClawdbotOAuthPath();
const storage = loadOAuthStorageAt(oauthPath);
if (storage) {
try {
const result = await getOAuthApiKey(model.provider, storage);
if (result?.apiKey) {
storage[model.provider] = result.newCredentials;
saveOAuthStorageAt(oauthPath, storage);
return result.apiKey;
}
} catch {
// fall through to error below
}
}
}
throw new Error(`No API key found for provider "${model.provider}"`);
}
+127 -1
View File
@@ -44,6 +44,54 @@ function buildAllowedModelKeys(
return keys.size > 0 ? keys : null;
}
function resolveImageFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined;
defaultProvider: string;
modelOverride?: string;
}): ModelCandidate[] {
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider: params.defaultProvider,
});
const allowlist = buildAllowedModelKeys(params.cfg, params.defaultProvider);
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
const addCandidate = (
candidate: ModelCandidate,
enforceAllowlist: boolean,
) => {
if (!candidate.provider || !candidate.model) return;
const key = modelKey(candidate.provider, candidate.model);
if (seen.has(key)) return;
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
seen.add(key);
candidates.push(candidate);
};
const addRaw = (raw: string, enforceAllowlist: boolean) => {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: params.defaultProvider,
aliasIndex,
});
if (!resolved) return;
addCandidate(resolved.ref, enforceAllowlist);
};
if (params.modelOverride?.trim()) {
addRaw(params.modelOverride, false);
} else if (params.cfg?.agent?.imageModel?.trim()) {
addRaw(params.cfg.agent.imageModel, false);
}
for (const raw of params.cfg?.agent?.imageModelFallbacks ?? []) {
addRaw(raw, true);
}
return candidates;
}
function resolveFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
@@ -59,7 +107,10 @@ function resolveFallbackCandidates(params: {
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
const addCandidate = (
candidate: ModelCandidate,
enforceAllowlist: boolean,
) => {
if (!candidate.provider || !candidate.model) return;
const key = modelKey(candidate.provider, candidate.model);
if (seen.has(key)) return;
@@ -148,3 +199,78 @@ export async function runWithModelFallback<T>(params: {
{ cause: lastError instanceof Error ? lastError : undefined },
);
}
export async function runWithImageModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined;
modelOverride?: string;
run: (provider: string, model: string) => Promise<T>;
onError?: (attempt: {
provider: string;
model: string;
error: unknown;
attempt: number;
total: number;
}) => void | Promise<void>;
}): Promise<{
result: T;
provider: string;
model: string;
attempts: FallbackAttempt[];
}> {
const candidates = resolveImageFallbackCandidates({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
modelOverride: params.modelOverride,
});
if (candidates.length === 0) {
throw new Error(
"No image model configured. Set agent.imageModel or agent.imageModelFallbacks.",
);
}
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i] as ModelCandidate;
try {
const result = await params.run(candidate.provider, candidate.model);
return {
result,
provider: candidate.provider,
model: candidate.model,
attempts,
};
} catch (err) {
if (isAbortError(err)) throw err;
lastError = err;
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: err instanceof Error ? err.message : String(err),
});
await params.onError?.({
provider: candidate.provider,
model: candidate.model,
error: err,
attempt: i + 1,
total: candidates.length,
});
}
}
if (attempts.length <= 1 && lastError) throw lastError;
const summary =
attempts.length > 0
? attempts
.map(
(attempt) =>
`${attempt.provider}/${attempt.model}: ${attempt.error}`,
)
.join(" | ")
: "unknown";
throw new Error(
`All image models failed (${attempts.length || candidates.length}): ${summary}`,
{ cause: lastError instanceof Error ? lastError : undefined },
);
}
+8 -14
View File
@@ -1,13 +1,13 @@
import { Type } from "@sinclair/typebox";
import {
type Context,
complete,
getEnvApiKey,
getModel,
type Context,
type Model,
type Tool,
type OpenAICompletionsOptions,
type Tool,
} from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
const DEFAULT_TIMEOUT_MS = 12_000;
@@ -130,9 +130,7 @@ async function fetchOpenRouterModels(
const id = typeof obj.id === "string" ? obj.id.trim() : "";
if (!id) return null;
const name =
typeof obj.name === "string" && obj.name.trim()
? obj.name.trim()
: id;
typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
const contextLength =
typeof obj.context_length === "number" &&
@@ -274,7 +272,7 @@ async function mapWithConcurrency<T, R>(
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const limit = Math.max(1, Math.floor(concurrency));
const results: R[] = new Array(items.length);
const results = Array.from({ length: items.length }) as R[];
let nextIndex = 0;
const worker = async () => {
@@ -296,8 +294,7 @@ export async function scanOpenRouterModels(
options: OpenRouterScanOptions = {},
): Promise<ModelScanResult[]> {
const fetchImpl = options.fetchImpl ?? fetch;
const apiKey =
options.apiKey?.trim() || getEnvApiKey("openrouter") || "";
const apiKey = options.apiKey?.trim() || getEnvApiKey("openrouter") || "";
if (!apiKey) {
throw new Error(
"Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan.",
@@ -337,10 +334,7 @@ export async function scanOpenRouterModels(
return true;
});
const baseModel = getModel(
"openrouter",
"openrouter/auto",
) as OpenAIModel;
const baseModel = getModel("openrouter", "openrouter/auto") as OpenAIModel;
return mapWithConcurrency(filtered, concurrency, async (entry) => {
const model: OpenAIModel = {
@@ -354,7 +348,7 @@ export async function scanOpenRouterModels(
};
const toolResult = await probeTool(model, apiKey, timeoutMs);
const imageResult = toolResult.ok
const imageResult = model.input.includes("image")
? await probeImage(ensureImageInput(model), apiKey, timeoutMs)
: { ok: false, latencyMs: null, skipped: true };
+77
View File
@@ -0,0 +1,77 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import {
isRateLimitAssistantError,
pickFallbackThinkingLevel,
} from "./pi-embedded-helpers.js";
const asAssistant = (overrides: Partial<AssistantMessage>) =>
({
role: "assistant",
stopReason: "error",
...overrides,
}) as AssistantMessage;
describe("isRateLimitAssistantError", () => {
it("detects 429 rate limit payloads", () => {
const msg = asAssistant({
errorMessage:
'429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account\'s rate limit. Please try again later."}}',
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("detects human-readable rate limit messages", () => {
const msg = asAssistant({
errorMessage: "Too many requests. Rate limit exceeded.",
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("detects quota exceeded messages", () => {
const msg = asAssistant({
errorMessage:
"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("returns false for non-error messages", () => {
const msg = asAssistant({
stopReason: "end_turn",
errorMessage: "rate limit",
});
expect(isRateLimitAssistantError(msg)).toBe(false);
});
});
describe("pickFallbackThinkingLevel", () => {
it("selects the first supported thinking level", () => {
const attempted = new Set<ThinkLevel>(["low"]);
const next = pickFallbackThinkingLevel({
message:
"Unsupported value: 'low' is not supported with the 'gpt-5.2-pro' model. Supported values are: 'medium', 'high', and 'xhigh'.",
attempted,
});
expect(next).toBe("medium");
});
it("skips already attempted levels", () => {
const attempted = new Set<ThinkLevel>(["low", "medium"]);
const next = pickFallbackThinkingLevel({
message: "Supported values are: 'medium', 'high', and 'xhigh'.",
attempted,
});
expect(next).toBe("high");
});
it("returns undefined when no supported values are found", () => {
const attempted = new Set<ThinkLevel>(["low"]);
const next = pickFallbackThinkingLevel({
message: "Request failed.",
attempted,
});
expect(next).toBeUndefined();
});
});
+51
View File
@@ -6,6 +6,10 @@ import type {
AgentToolResult,
} from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import {
normalizeThinkLevel,
type ThinkLevel,
} from "../auto-reply/thinking.js";
import { sanitizeContentBlocksImages } from "./tool-images.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
@@ -109,3 +113,50 @@ export function formatAssistantErrorText(
// Keep it short for WhatsApp.
return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw;
}
export function isRateLimitAssistantError(
msg: AssistantMessage | undefined,
): boolean {
if (!msg || msg.stopReason !== "error") return false;
const raw = (msg.errorMessage ?? "").toLowerCase();
if (!raw) return false;
return (
/rate[_ ]limit|too many requests|429/.test(raw) ||
raw.includes("exceeded your current quota")
);
}
function extractSupportedValues(raw: string): string[] {
const match =
raw.match(/supported values are:\s*([^\n.]+)/i) ??
raw.match(/supported values:\s*([^\n.]+)/i);
if (!match?.[1]) return [];
const fragment = match[1];
const quoted = Array.from(fragment.matchAll(/['"]([^'"]+)['"]/g)).map(
(entry) => entry[1]?.trim(),
);
if (quoted.length > 0) {
return quoted.filter((entry): entry is string => Boolean(entry));
}
return fragment
.split(/,|\band\b/gi)
.map((entry) => entry.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, "").trim())
.filter(Boolean);
}
export function pickFallbackThinkingLevel(params: {
message?: string;
attempted: Set<ThinkLevel>;
}): ThinkLevel | undefined {
const raw = params.message?.trim();
if (!raw) return undefined;
const supported = extractSupportedValues(raw);
if (supported.length === 0) return undefined;
for (const entry of supported) {
const normalized = normalizeThinkLevel(entry);
if (!normalized) continue;
if (params.attempted.has(normalized)) continue;
return normalized;
}
return undefined;
}
+345 -423
View File
@@ -1,18 +1,8 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import {
type Api,
type AssistantMessage,
getEnvApiKey,
getOAuthApiKey,
type Model,
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
import {
buildSystemPrompt,
createAgentSession,
@@ -32,15 +22,18 @@ import {
type enqueueCommand,
enqueueCommandInLane,
} from "../process/command-queue.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import type { BashElevatedDefaults } from "./bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import { getApiKeyForModel } from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
import {
buildBootstrapContextFiles,
ensureSessionHeader,
formatAssistantErrorText,
isRateLimitAssistantError,
pickFallbackThinkingLevel,
sanitizeSessionMessagesImages,
} from "./pi-embedded-helpers.js";
import {
@@ -105,11 +98,6 @@ type EmbeddedRunWaiter = {
};
const EMBEDDED_RUN_WAITERS = new Map<string, Set<EmbeddedRunWaiter>>();
const OAUTH_FILENAME = "oauth.json";
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
let oauthStorageConfigured = false;
type OAuthStorage = Record<string, OAuthCredentials>;
type EmbeddedSandboxInfo = {
enabled: boolean;
workspaceDir?: string;
@@ -139,92 +127,6 @@ export function buildEmbeddedSandboxInfo(
};
}
function resolveClawdbotOAuthPath(): string {
const overrideDir =
process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME);
}
function loadOAuthStorageAt(pathname: string): OAuthStorage | null {
if (!fsSync.existsSync(pathname)) return null;
try {
const content = fsSync.readFileSync(pathname, "utf8");
const json = JSON.parse(content) as OAuthStorage;
if (!json || typeof json !== "object") return null;
return json;
} catch {
return null;
}
}
function hasAnthropicOAuth(storage: OAuthStorage): boolean {
const entry = storage.anthropic as
| {
refresh?: string;
refresh_token?: string;
refreshToken?: string;
access?: string;
access_token?: string;
accessToken?: string;
}
| undefined;
if (!entry) return false;
const refresh =
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
const access = entry.access ?? entry.access_token ?? entry.accessToken ?? "";
return Boolean(refresh.trim() && access.trim());
}
function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void {
const dir = path.dirname(pathname);
fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 });
fsSync.writeFileSync(
pathname,
`${JSON.stringify(storage, null, 2)}\n`,
"utf8",
);
fsSync.chmodSync(pathname, 0o600);
}
function legacyOAuthPaths(): string[] {
const paths: string[] = [];
const piOverride = process.env.PI_CODING_AGENT_DIR?.trim();
if (piOverride) {
paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME));
}
paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME));
return Array.from(new Set(paths));
}
function importLegacyOAuthIfNeeded(destPath: string): void {
if (fsSync.existsSync(destPath)) return;
for (const legacyPath of legacyOAuthPaths()) {
const storage = loadOAuthStorageAt(legacyPath);
if (!storage || !hasAnthropicOAuth(storage)) continue;
saveOAuthStorageAt(destPath, storage);
return;
}
}
function ensureOAuthStorage(): void {
if (oauthStorageConfigured) return;
oauthStorageConfigured = true;
const oauthPath = resolveClawdbotOAuthPath();
importLegacyOAuthIfNeeded(oauthPath);
}
function isOAuthProvider(provider: string): provider is OAuthProvider {
return (
provider === "anthropic" ||
provider === "github-copilot" ||
provider === "google-gemini-cli" ||
provider === "google-antigravity"
);
}
export function queueEmbeddedPiMessage(
sessionId: string,
text: string,
@@ -327,38 +229,6 @@ function resolveModel(
return { model, authStorage, modelRegistry };
}
async function getApiKeyForModel(
model: Model<Api>,
authStorage: ReturnType<typeof discoverAuthStorage>,
): Promise<string> {
const storedKey = await authStorage.getApiKey(model.provider);
if (storedKey) return storedKey;
ensureOAuthStorage();
if (model.provider === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return oauthEnv.trim();
}
const envKey = getEnvApiKey(model.provider);
if (envKey) return envKey;
if (isOAuthProvider(model.provider)) {
const oauthPath = resolveClawdbotOAuthPath();
const storage = loadOAuthStorageAt(oauthPath);
if (storage) {
try {
const result = await getOAuthApiKey(model.provider, storage);
if (result?.apiKey) {
storage[model.provider] = result.newCredentials;
saveOAuthStorageAt(oauthPath, storage);
return result.apiKey;
}
} catch {
// fall through to error below
}
}
}
throw new Error(`No API key found for provider "${model.provider}"`);
}
function resolvePromptSkills(
snapshot: SkillSnapshot,
entries: SkillEntry[],
@@ -449,298 +319,350 @@ export async function runEmbeddedPiAgent(params: {
const apiKey = await getApiKeyForModel(model, authStorage);
authStorage.setRuntimeApiKey(model.provider, apiKey);
const thinkingLevel = mapThinkingLevel(params.thinkLevel);
let thinkLevel = params.thinkLevel ?? "off";
const attemptedThinking = new Set<ThinkLevel>();
log.debug(
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} surface=${params.surface ?? "unknown"}`,
);
await fs.mkdir(resolvedWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: resolvedWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace);
try {
const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(resolvedWorkspace)
: [];
const skillsSnapshot =
params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
config: params.config,
entries: skillEntries,
});
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
config: params.config,
})
: applySkillEnvOverrides({
skills: skillEntries ?? [],
config: params.config,
});
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
elevated: params.bashElevated,
},
sandbox,
surface: params.surface,
sessionKey: params.sessionKey ?? params.sessionId,
});
const machineName = await getMachineDisplayName();
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${provider}/${modelId}`,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
runtimeInfo,
sandboxInfo,
}),
contextFiles,
skills: promptSkills,
cwd: resolvedWorkspace,
tools,
});
const sessionManager = SessionManager.open(params.sessionFile);
const settingsManager = SettingsManager.create(
resolvedWorkspace,
agentDir,
);
const { session } = await createAgentSession({
cwd: resolvedWorkspace,
agentDir,
authStorage,
modelRegistry,
model,
thinkingLevel,
systemPrompt,
// Custom tool set: extra bash/process + read image sanitization.
tools,
sessionManager,
settingsManager,
skills: promptSkills,
contextFiles,
});
const prior = await sanitizeSessionMessagesImages(
session.messages,
"session:history",
);
if (prior.length > 0) {
session.agent.replaceMessages(prior);
}
let aborted = Boolean(params.abortSignal?.aborted);
const abortRun = () => {
aborted = true;
void session.abort();
};
const queueHandle: EmbeddedPiQueueHandle = {
queueMessage: async (text: string) => {
await session.steer(text);
},
isStreaming: () => session.isStreaming,
abort: abortRun,
};
ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
const {
assistantTexts,
toolMetas,
unsubscribe,
waitForCompactionRetry,
} = subscribeEmbeddedPiSession({
session,
runId: params.runId,
verboseLevel: params.verboseLevel,
shouldEmitToolResult: params.shouldEmitToolResult,
onToolResult: params.onToolResult,
onBlockReply: params.onBlockReply,
blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: params.onPartialReply,
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
});
let abortWarnTimer: NodeJS.Timeout | undefined;
const abortTimer = setTimeout(
() => {
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
abortRun();
if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => {
if (!session.isStreaming) return;
log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
}, 10_000);
}
},
Math.max(1, params.timeoutMs),
);
let messagesSnapshot: AgentMessage[] = [];
let sessionIdUsed = session.sessionId;
const onAbort = () => {
abortRun();
};
if (params.abortSignal) {
if (params.abortSignal.aborted) {
onAbort();
} else {
params.abortSignal.addEventListener("abort", onAbort, {
once: true,
});
}
}
let promptError: unknown = null;
try {
const promptStartedAt = Date.now();
log.debug(
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`,
);
try {
await session.prompt(params.prompt);
} catch (err) {
promptError = err;
} finally {
log.debug(
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,
);
}
await waitForCompactionRetry();
messagesSnapshot = session.messages.slice();
sessionIdUsed = session.sessionId;
} finally {
clearTimeout(abortTimer);
if (abortWarnTimer) {
clearTimeout(abortWarnTimer);
abortWarnTimer = undefined;
}
unsubscribe();
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
notifyEmbeddedRunEnded(params.sessionId);
}
session.dispose();
params.abortSignal?.removeEventListener?.("abort", onAbort);
}
if (promptError && !aborted) {
throw promptError;
}
const lastAssistant = messagesSnapshot
.slice()
.reverse()
.find((m) => (m as AgentMessage)?.role === "assistant") as
| AssistantMessage
| undefined;
const usage = lastAssistant?.usage;
const agentMeta: EmbeddedPiAgentMeta = {
sessionId: sessionIdUsed,
provider: lastAssistant?.provider ?? provider,
model: lastAssistant?.model ?? model.id,
usage: usage
? {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
total: usage.totalTokens,
}
: undefined,
};
const replyItems: Array<{ text: string; media?: string[] }> = [];
const errorText = lastAssistant
? formatAssistantErrorText(lastAssistant)
: undefined;
if (errorText) replyItems.push({ text: errorText });
const inlineToolResults =
params.verboseLevel === "on" &&
!params.onPartialReply &&
!params.onToolResult &&
toolMetas.length > 0;
if (inlineToolResults) {
for (const { toolName, meta } of toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg);
if (cleanedText)
replyItems.push({ text: cleanedText, media: mediaUrls });
}
}
for (const text of assistantTexts.length
? assistantTexts
: lastAssistant
? [extractAssistantText(lastAssistant)]
: []) {
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) continue;
replyItems.push({ text: cleanedText, media: mediaUrls });
}
const payloads = replyItems
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
}))
.filter(
(p) =>
p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0),
);
while (true) {
const thinkingLevel = mapThinkingLevel(thinkLevel);
attemptedThinking.add(thinkLevel);
log.debug(
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} surface=${params.surface ?? "unknown"}`,
);
return {
payloads: payloads.length ? payloads : undefined,
meta: {
durationMs: Date.now() - started,
agentMeta,
aborted,
},
};
} finally {
restoreSkillEnv?.();
process.chdir(prevCwd);
await fs.mkdir(resolvedWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: resolvedWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace);
try {
const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(resolvedWorkspace)
: [];
const skillsSnapshot =
params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
config: params.config,
entries: skillEntries,
});
const sandboxSessionKey =
params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
config: params.config,
})
: applySkillEnvOverrides({
skills: skillEntries ?? [],
config: params.config,
});
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(
skillsSnapshot,
skillEntries,
);
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
elevated: params.bashElevated,
},
sandbox,
surface: params.surface,
sessionKey: params.sessionKey ?? params.sessionId,
config: params.config,
});
const machineName = await getMachineDisplayName();
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${provider}/${modelId}`,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace,
defaultThinkLevel: thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
runtimeInfo,
sandboxInfo,
toolNames: tools.map((tool) => tool.name),
}),
contextFiles,
skills: promptSkills,
cwd: resolvedWorkspace,
tools,
});
const sessionManager = SessionManager.open(params.sessionFile);
const settingsManager = SettingsManager.create(
resolvedWorkspace,
agentDir,
);
const { session } = await createAgentSession({
cwd: resolvedWorkspace,
agentDir,
authStorage,
modelRegistry,
model,
thinkingLevel,
systemPrompt,
// Custom tool set: extra bash/process + read image sanitization.
tools,
sessionManager,
settingsManager,
skills: promptSkills,
contextFiles,
});
const prior = await sanitizeSessionMessagesImages(
session.messages,
"session:history",
);
if (prior.length > 0) {
session.agent.replaceMessages(prior);
}
let aborted = Boolean(params.abortSignal?.aborted);
const abortRun = () => {
aborted = true;
void session.abort();
};
const queueHandle: EmbeddedPiQueueHandle = {
queueMessage: async (text: string) => {
await session.steer(text);
},
isStreaming: () => session.isStreaming,
abort: abortRun,
};
ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
const {
assistantTexts,
toolMetas,
unsubscribe,
waitForCompactionRetry,
} = subscribeEmbeddedPiSession({
session,
runId: params.runId,
verboseLevel: params.verboseLevel,
shouldEmitToolResult: params.shouldEmitToolResult,
onToolResult: params.onToolResult,
onBlockReply: params.onBlockReply,
blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: params.onPartialReply,
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
});
let abortWarnTimer: NodeJS.Timeout | undefined;
const abortTimer = setTimeout(
() => {
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
abortRun();
if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => {
if (!session.isStreaming) return;
log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
}, 10_000);
}
},
Math.max(1, params.timeoutMs),
);
let messagesSnapshot: AgentMessage[] = [];
let sessionIdUsed = session.sessionId;
const onAbort = () => {
abortRun();
};
if (params.abortSignal) {
if (params.abortSignal.aborted) {
onAbort();
} else {
params.abortSignal.addEventListener("abort", onAbort, {
once: true,
});
}
}
let promptError: unknown = null;
try {
const promptStartedAt = Date.now();
log.debug(
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`,
);
try {
await session.prompt(params.prompt);
} catch (err) {
promptError = err;
} finally {
log.debug(
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,
);
}
await waitForCompactionRetry();
messagesSnapshot = session.messages.slice();
sessionIdUsed = session.sessionId;
} finally {
clearTimeout(abortTimer);
if (abortWarnTimer) {
clearTimeout(abortWarnTimer);
abortWarnTimer = undefined;
}
unsubscribe();
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
notifyEmbeddedRunEnded(params.sessionId);
}
session.dispose();
params.abortSignal?.removeEventListener?.("abort", onAbort);
}
if (promptError && !aborted) {
const fallbackThinking = pickFallbackThinkingLevel({
message:
promptError instanceof Error
? promptError.message
: String(promptError),
attempted: attemptedThinking,
});
if (fallbackThinking) {
log.warn(
`unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`,
);
thinkLevel = fallbackThinking;
continue;
}
throw promptError;
}
const lastAssistant = messagesSnapshot
.slice()
.reverse()
.find((m) => (m as AgentMessage)?.role === "assistant") as
| AssistantMessage
| undefined;
const fallbackThinking = pickFallbackThinkingLevel({
message: lastAssistant?.errorMessage,
attempted: attemptedThinking,
});
if (fallbackThinking && !aborted) {
log.warn(
`unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`,
);
thinkLevel = fallbackThinking;
continue;
}
const fallbackConfigured =
(params.config?.agent?.modelFallbacks?.length ?? 0) > 0;
if (fallbackConfigured && isRateLimitAssistantError(lastAssistant)) {
const message =
lastAssistant?.errorMessage?.trim() ||
(lastAssistant ? formatAssistantErrorText(lastAssistant) : "") ||
"LLM request rate limited.";
throw new Error(message);
}
const usage = lastAssistant?.usage;
const agentMeta: EmbeddedPiAgentMeta = {
sessionId: sessionIdUsed,
provider: lastAssistant?.provider ?? provider,
model: lastAssistant?.model ?? model.id,
usage: usage
? {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
total: usage.totalTokens,
}
: undefined,
};
const replyItems: Array<{ text: string; media?: string[] }> = [];
const errorText = lastAssistant
? formatAssistantErrorText(lastAssistant)
: undefined;
if (errorText) replyItems.push({ text: errorText });
const inlineToolResults =
params.verboseLevel === "on" &&
!params.onPartialReply &&
!params.onToolResult &&
toolMetas.length > 0;
if (inlineToolResults) {
for (const { toolName, meta } of toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
const { text: cleanedText, mediaUrls } =
splitMediaFromOutput(agg);
if (cleanedText)
replyItems.push({ text: cleanedText, media: mediaUrls });
}
}
for (const text of assistantTexts.length
? assistantTexts
: lastAssistant
? [extractAssistantText(lastAssistant)]
: []) {
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0))
continue;
replyItems.push({ text: cleanedText, media: mediaUrls });
}
const payloads = replyItems
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
}))
.filter(
(p) =>
p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0),
);
log.debug(
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
);
return {
payloads: payloads.length ? payloads : undefined,
meta: {
durationMs: Date.now() - started,
agentMeta,
aborted,
},
};
} finally {
restoreSkillEnv?.();
process.chdir(prevCwd);
}
}
}),
);
+131
View File
@@ -182,6 +182,110 @@ describe("subscribeEmbeddedPiSession", () => {
expect(subscription.assistantTexts).toEqual(["Hello block"]);
});
it("does not duplicate when message_end flushes and a late text_end arrives", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "text_end",
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: "Hello block",
},
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Hello block" }],
} as AssistantMessage;
// Simulate a provider that ends the message without emitting text_end.
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(subscription.assistantTexts).toEqual(["Hello block"]);
// Some providers can still emit a late text_end; this must not re-emit.
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_end",
content: "Hello block",
},
});
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(subscription.assistantTexts).toEqual(["Hello block"]);
});
it("clears block reply state on message_start", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "text_end",
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "OK" },
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },
});
expect(onBlockReply).toHaveBeenCalledTimes(1);
// New assistant message with identical output should still emit.
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "OK" },
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },
});
expect(onBlockReply).toHaveBeenCalledTimes(2);
});
it("does not emit duplicate block replies when text_end repeats", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
@@ -231,6 +335,33 @@ describe("subscribeEmbeddedPiSession", () => {
expect(subscription.assistantTexts).toEqual(["Hello block"]);
});
it("does not duplicate assistantTexts when message_end repeats", () => {
let handler: SessionEventHandler | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run",
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Hello world" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
handler?.({ type: "message_end", message: assistantMessage });
expect(subscription.assistantTexts).toEqual(["Hello world"]);
});
it("does not append when text_end content is a prefix of deltas", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
+42 -2
View File
@@ -317,6 +317,20 @@ export function subscribeEmbeddedPiSession(params: {
const unsubscribe = params.session.subscribe(
(evt: AgentEvent | { type: string; [k: string]: unknown }) => {
if (evt.type === "message_start") {
const msg = (evt as AgentEvent & { message: AgentMessage }).message;
if (msg?.role === "assistant") {
// Start-of-message is a safer reset point than message_end: some providers
// may deliver late text_end updates after message_end, which would
// otherwise re-trigger block replies.
deltaBuffer = "";
blockBuffer = "";
lastStreamedAssistant = undefined;
lastBlockReplyText = undefined;
assistantTextBaseline = assistantTexts.length;
}
}
if (evt.type === "tool_execution_start") {
const toolName = String(
(evt as AgentEvent & { toolName: string }).toolName,
@@ -557,7 +571,10 @@ export function subscribeEmbeddedPiSession(params: {
const addedDuringMessage =
assistantTexts.length > assistantTextBaseline;
if (!addedDuringMessage && text) assistantTexts.push(text);
if (!addedDuringMessage && text) {
const last = assistantTexts.at(-1);
if (!last || last !== text) assistantTexts.push(text);
}
assistantTextBaseline = assistantTexts.length;
if (
@@ -582,7 +599,6 @@ export function subscribeEmbeddedPiSession(params: {
deltaBuffer = "";
blockBuffer = "";
lastStreamedAssistant = undefined;
lastBlockReplyText = undefined;
}
}
@@ -600,6 +616,18 @@ export function subscribeEmbeddedPiSession(params: {
if (evt.type === "agent_start") {
log.debug(`embedded run agent start: runId=${params.runId}`);
emitAgentEvent({
runId: params.runId,
stream: "lifecycle",
data: {
phase: "start",
startedAt: Date.now(),
},
});
params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "start" },
});
}
if (evt.type === "auto_compaction_start") {
@@ -622,6 +650,18 @@ export function subscribeEmbeddedPiSession(params: {
if (evt.type === "agent_end") {
log.debug(`embedded run agent end: runId=${params.runId}`);
emitAgentEvent({
runId: params.runId,
stream: "lifecycle",
data: {
phase: "end",
endedAt: Date.now(),
},
});
params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "end" },
});
if (pendingCompactionRetry > 0) {
resolveCompactionRetry();
} else {
+64 -36
View File
@@ -5,8 +5,16 @@ import path from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js";
describe("createClawdbotCodingTools", () => {
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
const browser = createBrowserTool();
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
expect(schema.type).toBe("object");
expect(schema.anyOf).toBeUndefined();
});
it("merges properties for union tool schemas", () => {
const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser");
@@ -23,54 +31,47 @@ describe("createClawdbotCodingTools", () => {
expect(parameters.required ?? []).toContain("action");
});
it("preserves union action values in merged schema", () => {
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway"];
const collectActionValues = (
schema: unknown,
values: Set<string>,
): void => {
if (!schema || typeof schema !== "object") return;
const record = schema as Record<string, unknown>;
if (typeof record.const === "string") values.add(record.const);
if (Array.isArray(record.enum)) {
for (const value of record.enum) {
if (typeof value === "string") values.add(value);
}
}
if (Array.isArray(record.anyOf)) {
for (const variant of record.anyOf) {
collectActionValues(variant, values);
}
}
};
for (const name of toolNames) {
const tool = tools.find((candidate) => candidate.name === name);
expect(tool).toBeDefined();
const parameters = tool?.parameters as {
anyOf?: Array<{ properties?: Record<string, unknown> }>;
properties?: Record<string, unknown>;
};
if (!Array.isArray(parameters.anyOf) || parameters.anyOf.length === 0) {
continue;
}
const actionValues = new Set<string>();
for (const variant of parameters.anyOf ?? []) {
const action = variant?.properties?.action as
| { const?: unknown; enum?: unknown[] }
| undefined;
if (typeof action?.const === "string") actionValues.add(action.const);
if (Array.isArray(action?.enum)) {
for (const value of action.enum) {
if (typeof value === "string") actionValues.add(value);
}
}
}
if (actionValues.size <= 1) {
continue;
}
const mergedAction = parameters.properties?.action as
const action = parameters.properties?.action as
| { const?: unknown; enum?: unknown[] }
| undefined;
const mergedValues = new Set<string>();
if (typeof mergedAction?.const === "string") {
mergedValues.add(mergedAction.const);
}
if (Array.isArray(mergedAction?.enum)) {
for (const value of mergedAction.enum) {
if (typeof value === "string") mergedValues.add(value);
}
}
const values = new Set<string>();
collectActionValues(action, values);
expect(actionValues.size).toBeGreaterThan(1);
expect(mergedValues.size).toBe(actionValues.size);
for (const value of actionValues) {
expect(mergedValues.has(value)).toBe(true);
}
const min =
name === "gateway"
? 1
: // Most tools expose multiple actions; keep this signal so schemas stay useful to models.
2;
expect(values.size).toBeGreaterThanOrEqual(min);
}
});
@@ -80,6 +81,25 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "process")).toBe(true);
});
it("provides top-level object schemas for all tools", () => {
const tools = createClawdbotCodingTools();
const offenders = tools
.map((tool) => {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: null;
return {
name: tool.name,
type: schema?.type,
keys: schema ? Object.keys(schema).sort() : null,
};
})
.filter((entry) => entry.type !== "object");
expect(offenders).toEqual([]);
});
it("scopes discord tool to discord surface", () => {
const other = createClawdbotCodingTools({ surface: "whatsapp" });
expect(other.some((tool) => tool.name === "discord")).toBe(false);
@@ -195,4 +215,12 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "read")).toBe(false);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } },
});
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
});
+53 -7
View File
@@ -7,7 +7,7 @@ import {
readTool,
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../config/config.js";
import { detectMime } from "../media/mime.js";
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
import {
@@ -217,6 +217,13 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
: undefined;
if (!schema) return tool;
// Provider quirks:
// - Gemini rejects several JSON Schema keywords, so we scrub those.
// - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`.
// (TypeBox root unions compile to `{ anyOf: [...] }` without `type`).
//
// Normalize once here so callers can always pass `tools` through unchanged.
// If schema already has type + properties (no top-level anyOf to merge),
// still clean it for Gemini compatibility
if (
@@ -230,12 +237,32 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
};
}
if (!Array.isArray(schema.anyOf)) return tool;
// Some tool schemas (esp. unions) may omit `type` at the top-level. If we see
// object-ish fields, force `type: "object"` so OpenAI accepts the schema.
if (
!("type" in schema) &&
(typeof schema.properties === "object" || Array.isArray(schema.required)) &&
!Array.isArray(schema.anyOf) &&
!Array.isArray(schema.oneOf)
) {
return {
...tool,
parameters: cleanSchemaForGemini({ ...schema, type: "object" }),
};
}
const variantKey = Array.isArray(schema.anyOf)
? "anyOf"
: Array.isArray(schema.oneOf)
? "oneOf"
: null;
if (!variantKey) return tool;
const variants = schema[variantKey] as unknown[];
const mergedProperties: Record<string, unknown> = {};
const requiredCounts = new Map<string, number>();
let objectVariants = 0;
for (const entry of schema.anyOf) {
for (const entry of variants) {
if (!entry || typeof entry !== "object") continue;
const props = (entry as { properties?: unknown }).properties;
if (!props || typeof props !== "object") continue;
@@ -276,9 +303,18 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
const nextSchema: Record<string, unknown> = { ...schema };
return {
...tool,
// Flatten union schemas into a single object schema:
// - Gemini doesn't allow top-level `type` together with `anyOf`.
// - OpenAI rejects schemas without top-level `type: "object"`.
// Merging properties preserves useful enums like `action` while keeping schemas portable.
parameters: cleanSchemaForGemini({
...nextSchema,
type: nextSchema.type ?? "object",
type: "object",
...(typeof nextSchema.title === "string"
? { title: nextSchema.title }
: {}),
...(typeof nextSchema.description === "string"
? { description: nextSchema.description }
: {}),
properties:
Object.keys(mergedProperties).length > 0
? mergedProperties
@@ -452,6 +488,7 @@ export function createClawdbotCodingTools(options?: {
surface?: string;
sandbox?: SandboxContext | null;
sessionKey?: string;
config?: ClawdbotConfig;
}): AnyAgentTool[] {
const bashToolName = "bash";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
@@ -497,6 +534,7 @@ export function createClawdbotCodingTools(options?: {
browserControlUrl: sandbox?.browser?.controlUrl,
agentSessionKey: options?.sessionKey,
agentSurface: options?.surface,
config: options?.config,
}),
];
const allowDiscord = shouldIncludeDiscordTool(options?.surface);
@@ -506,8 +544,16 @@ export function createClawdbotCodingTools(options?: {
if (tool.name === "slack") return allowSlack;
return true;
});
const globallyFiltered =
options?.config?.agent?.tools &&
(options.config.agent.tools.allow?.length ||
options.config.agent.tools.deny?.length)
? filterToolsByPolicy(filtered, options.config.agent.tools)
: filtered;
const sandboxed = sandbox
? filterToolsByPolicy(filtered, sandbox.tools)
: filtered;
? filterToolsByPolicy(globallyFiltered, sandbox.tools)
: globallyFiltered;
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
return sandboxed.map(normalizeToolParameters);
}
+30
View File
@@ -99,6 +99,11 @@ export type SandboxContext = {
browser?: SandboxBrowserContext;
};
export type SandboxWorkspaceInfo = {
workspaceDir: string;
containerWorkdir: string;
};
const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(
os.homedir(),
".clawdbot",
@@ -866,3 +871,28 @@ export async function resolveSandboxContext(params: {
browser: browser ?? undefined,
};
}
export async function ensureSandboxWorkspaceForSession(params: {
config?: ClawdbotConfig;
sessionKey?: string;
workspaceDir?: string;
}): Promise<SandboxWorkspaceInfo | null> {
const rawSessionKey = params.sessionKey?.trim();
if (!rawSessionKey) return null;
const cfg = defaultSandboxConfig(params.config);
const mainKey = params.config?.session?.mainKey?.trim() || "main";
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const workspaceDir = cfg.perSession
? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey)
: workspaceRoot;
const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
await ensureSandboxWorkspace(workspaceDir, seedWorkspace);
return {
workspaceDir,
containerWorkdir: cfg.docker.workdir,
};
}
+13
View File
@@ -33,4 +33,17 @@ describe("buildAgentSystemPromptAppend", () => {
expect(prompt).toContain("<think>...</think>");
expect(prompt).toContain("<final>...</final>");
});
it("lists available and unavailable tools when provided", () => {
const prompt = buildAgentSystemPromptAppend({
workspaceDir: "/tmp/clawd",
toolNames: ["bash", "sessions_list", "sessions_history", "sessions_send"],
});
expect(prompt).toContain("Tool availability (filtered by policy):");
expect(prompt).toContain("sessions_list");
expect(prompt).toContain("sessions_history");
expect(prompt).toContain("sessions_send");
expect(prompt).toContain("Unavailable tools (do not call):");
});
});
+86 -11
View File
@@ -6,6 +6,7 @@ export function buildAgentSystemPromptAppend(params: {
extraSystemPrompt?: string;
ownerNumbers?: string[];
reasoningTagHint?: boolean;
toolNames?: string[];
runtimeInfo?: {
host?: string;
os?: string;
@@ -20,6 +21,69 @@ export function buildAgentSystemPromptAppend(params: {
browserNoVncUrl?: string;
};
}) {
const toolSummaries: Record<string, string> = {
read: "Read file contents",
write: "Create or overwrite files",
edit: "Make precise edits to files",
grep: "Search file contents for patterns",
find: "Find files by glob pattern",
ls: "List directory contents",
bash: "Run shell commands",
process: "Manage background bash sessions",
whatsapp_login: "Generate and wait for WhatsApp QR login",
browser: "Control the dedicated clawd browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events",
gateway: "Restart the running Gateway process",
sessions_list: "List sessions with filters and last messages",
sessions_history: "Fetch message history for a session",
sessions_send: "Send a message into another session",
image: "Analyze an image with the configured image model",
discord: "Send Discord reactions/messages and manage threads",
slack: "Send Slack messages and manage channels",
};
const toolOrder = [
"read",
"write",
"edit",
"grep",
"find",
"ls",
"bash",
"process",
"whatsapp_login",
"browser",
"canvas",
"nodes",
"cron",
"gateway",
"sessions_list",
"sessions_history",
"sessions_send",
"image",
"discord",
"slack",
];
const normalizedTools = (params.toolNames ?? [])
.map((tool) => tool.trim().toLowerCase())
.filter(Boolean);
const availableTools = new Set(normalizedTools);
const extraTools = Array.from(
new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))),
);
const enabledTools = toolOrder.filter((tool) => availableTools.has(tool));
const disabledTools = toolOrder.filter((tool) => !availableTools.has(tool));
const toolLines = enabledTools.map((tool) => {
const summary = toolSummaries[tool];
return summary ? `- ${tool}: ${summary}` : `- ${tool}`;
});
for (const tool of extraTools.sort()) {
toolLines.push(`- ${tool}`);
}
const thinkHint =
params.defaultThinkLevel && params.defaultThinkLevel !== "off"
? `Default thinking level: ${params.defaultThinkLevel}.`
@@ -61,17 +125,28 @@ export function buildAgentSystemPromptAppend(params: {
"You are Clawd, a personal assistant running inside Clawdbot.",
"",
"## Tooling",
"Pi lists the standard tools above. This runtime enables:",
"- grep: search file contents for patterns",
"- find: find files by glob pattern",
"- ls: list directory contents",
"- bash: run shell commands (supports background via yieldMs/background)",
"- process: manage background bash sessions",
"- whatsapp_login: generate a WhatsApp QR code and wait for linking",
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events",
"Tool availability (filtered by policy):",
toolLines.length > 0
? toolLines.join("\n")
: [
"Pi lists the standard tools above. This runtime enables:",
"- grep: search file contents for patterns",
"- find: find files by glob pattern",
"- ls: list directory contents",
"- bash: run shell commands (supports background via yieldMs/background)",
"- process: manage background bash sessions",
"- whatsapp_login: generate a WhatsApp QR code and wait for linking",
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
].join("\n"),
disabledTools.length > 0
? `Unavailable tools (do not call): ${disabledTools.join(", ")}`
: "",
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
"",
"## Workspace",
+38 -93
View File
@@ -97,99 +97,44 @@ const BrowserActSchema = Type.Union([
}),
]);
const BrowserToolSchema = Type.Union([
Type.Object({
action: Type.Literal("status"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("start"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("stop"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("tabs"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("open"),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.String(),
}),
Type.Object({
action: Type.Literal("focus"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.String(),
}),
Type.Object({
action: Type.Literal("close"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("snapshot"),
controlUrl: Type.Optional(Type.String()),
format: Type.Optional(
Type.Union([Type.Literal("aria"), Type.Literal("ai")]),
),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("screenshot"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: Type.Optional(
Type.Union([Type.Literal("png"), Type.Literal("jpeg")]),
),
}),
Type.Object({
action: Type.Literal("navigate"),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.String(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("console"),
controlUrl: Type.Optional(Type.String()),
level: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("pdf"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("upload"),
controlUrl: Type.Optional(Type.String()),
paths: Type.Array(Type.String()),
ref: Type.Optional(Type.String()),
inputRef: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("dialog"),
controlUrl: Type.Optional(Type.String()),
accept: Type.Boolean(),
promptText: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("act"),
controlUrl: Type.Optional(Type.String()),
request: BrowserActSchema,
}),
]);
// IMPORTANT: OpenAI function tool schemas must have a top-level `type: "object"`.
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
const BrowserToolSchema = Type.Object({
action: Type.Union([
Type.Literal("status"),
Type.Literal("start"),
Type.Literal("stop"),
Type.Literal("tabs"),
Type.Literal("open"),
Type.Literal("focus"),
Type.Literal("close"),
Type.Literal("snapshot"),
Type.Literal("screenshot"),
Type.Literal("navigate"),
Type.Literal("console"),
Type.Literal("pdf"),
Type.Literal("upload"),
Type.Literal("dialog"),
Type.Literal("act"),
]),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
format: Type.Optional(Type.Union([Type.Literal("aria"), Type.Literal("ai")])),
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpeg")])),
level: Type.Optional(Type.String()),
paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
accept: Type.Optional(Type.Boolean()),
promptText: Type.Optional(Type.String()),
request: Type.Optional(BrowserActSchema),
});
function resolveBrowserBaseUrl(controlUrl?: string) {
const cfg = loadConfig();
+15 -4
View File
@@ -16,12 +16,12 @@ describe("cron tool", () => {
it.each([
[
"update",
{ action: "update", jobId: "job-1", patch: { foo: "bar" } },
{ action: "update", id: "job-1", patch: { foo: "bar" } },
{ id: "job-1", patch: { foo: "bar" } },
],
["remove", { action: "remove", jobId: "job-1" }, { id: "job-1" }],
["run", { action: "run", jobId: "job-1" }, { id: "job-1" }],
["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }],
["remove", { action: "remove", id: "job-1" }, { id: "job-1" }],
["run", { action: "run", id: "job-1" }, { id: "job-1" }],
["runs", { action: "runs", id: "job-1" }, { id: "job-1" }],
])("%s sends id to gateway", async (action, args, expectedParams) => {
const tool = createCronTool();
await tool.execute("call1", args);
@@ -34,4 +34,15 @@ describe("cron tool", () => {
expect(call.method).toBe(`cron.${action}`);
expect(call.params).toEqual(expectedParams);
});
it("rejects jobId params", async () => {
const tool = createCronTool();
await expect(
tool.execute("call2", {
action: "update",
jobId: "job-1",
patch: { foo: "bar" },
}),
).rejects.toThrow("id required");
});
});
+8 -8
View File
@@ -29,7 +29,7 @@ const CronToolSchema = Type.Union([
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
id: Type.String(),
patch: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
@@ -37,21 +37,21 @@ const CronToolSchema = Type.Union([
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
id: Type.String(),
}),
Type.Object({
action: Type.Literal("run"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
id: Type.String(),
}),
Type.Object({
action: Type.Literal("runs"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
id: Type.String(),
}),
Type.Object({
action: Type.Literal("wake"),
@@ -102,7 +102,7 @@ export function createCronTool(): AnyAgentTool {
);
}
case "update": {
const id = readStringParam(params, "jobId", { required: true });
const id = readStringParam(params, "id", { required: true });
if (!params.patch || typeof params.patch !== "object") {
throw new Error("patch required");
}
@@ -114,19 +114,19 @@ export function createCronTool(): AnyAgentTool {
);
}
case "remove": {
const id = readStringParam(params, "jobId", { required: true });
const id = readStringParam(params, "id", { required: true });
return jsonResult(
await callGatewayTool("cron.remove", gatewayOpts, { id }),
);
}
case "run": {
const id = readStringParam(params, "jobId", { required: true });
const id = readStringParam(params, "id", { required: true });
return jsonResult(
await callGatewayTool("cron.run", gatewayOpts, { id }),
);
}
case "runs": {
const id = readStringParam(params, "jobId", { required: true });
const id = readStringParam(params, "id", { required: true });
return jsonResult(
await callGatewayTool("cron.runs", gatewayOpts, { id }),
);
+183
View File
@@ -0,0 +1,183 @@
import {
type Api,
type AssistantMessage,
type Context,
complete,
type Model,
} from "@mariozechner/pi-ai";
import {
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveUserPath } from "../../utils.js";
import { loadWebMedia } from "../../web/media.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { getApiKeyForModel } from "../model-auth.js";
import { runWithImageModelFallback } from "../model-fallback.js";
import { ensureClawdbotModelsJson } from "../models-config.js";
import { extractAssistantText } from "../pi-embedded-utils.js";
import type { AnyAgentTool } from "./common.js";
const DEFAULT_PROMPT = "Describe the image.";
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
const primary = cfg?.agent?.imageModel?.trim();
const fallbacks = cfg?.agent?.imageModelFallbacks ?? [];
return Boolean(primary || fallbacks.length > 0);
}
function pickMaxBytes(
cfg?: ClawdbotConfig,
maxBytesMb?: number,
): number | undefined {
if (
typeof maxBytesMb === "number" &&
Number.isFinite(maxBytesMb) &&
maxBytesMb > 0
) {
return Math.floor(maxBytesMb * 1024 * 1024);
}
const configured = cfg?.agent?.mediaMaxMb;
if (
typeof configured === "number" &&
Number.isFinite(configured) &&
configured > 0
) {
return Math.floor(configured * 1024 * 1024);
}
return undefined;
}
function buildImageContext(
prompt: string,
base64: string,
mimeType: string,
): Context {
return {
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image", data: base64, mimeType },
],
timestamp: Date.now(),
},
],
};
}
async function runImagePrompt(params: {
cfg?: ClawdbotConfig;
modelOverride?: string;
prompt: string;
base64: string;
mimeType: string;
}): Promise<{ text: string; provider: string; model: string }> {
const agentDir = resolveClawdbotAgentDir();
await ensureClawdbotModelsJson(params.cfg);
const authStorage = discoverAuthStorage(agentDir);
const modelRegistry = discoverModels(authStorage, agentDir);
const result = await runWithImageModelFallback({
cfg: params.cfg,
modelOverride: params.modelOverride,
run: async (provider, modelId) => {
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
if (!model) {
throw new Error(`Unknown model: ${provider}/${modelId}`);
}
if (!model.input?.includes("image")) {
throw new Error(
`Model does not support images: ${provider}/${modelId}`,
);
}
const apiKey = await getApiKeyForModel(model, authStorage);
authStorage.setRuntimeApiKey(model.provider, apiKey);
const context = buildImageContext(
params.prompt,
params.base64,
params.mimeType,
);
const message = (await complete(model, context, {
apiKey,
maxTokens: 512,
temperature: 0,
})) as AssistantMessage;
return message;
},
});
const text = extractAssistantText(result.result);
return {
text: text || "(no text returned)",
provider: result.provider,
model: result.model,
};
}
export function createImageTool(options?: {
config?: ClawdbotConfig;
}): AnyAgentTool | null {
if (!ensureImageToolConfigured(options?.config)) return null;
return {
label: "Image",
name: "image",
description:
"Analyze an image with the configured image model (agent.imageModel). Provide a prompt and image path or URL.",
parameters: Type.Object({
prompt: Type.Optional(Type.String()),
image: Type.String(),
model: Type.Optional(Type.String()),
maxBytesMb: Type.Optional(Type.Number()),
}),
execute: async (_toolCallId, args) => {
const record =
args && typeof args === "object"
? (args as Record<string, unknown>)
: {};
const imageRaw =
typeof record.image === "string" ? record.image.trim() : "";
if (!imageRaw) throw new Error("image required");
const promptRaw =
typeof record.prompt === "string" && record.prompt.trim()
? record.prompt.trim()
: DEFAULT_PROMPT;
const modelOverride =
typeof record.model === "string" && record.model.trim()
? record.model.trim()
: undefined;
const maxBytesMb =
typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
const maxBytes = pickMaxBytes(options?.config, maxBytesMb);
const resolvedImage = imageRaw.startsWith("~")
? resolveUserPath(imageRaw)
: imageRaw;
const media = await loadWebMedia(resolvedImage, maxBytes);
if (media.kind !== "image") {
throw new Error(`Unsupported media type: ${media.kind}`);
}
const mimeType = media.contentType ?? "image/png";
const base64 = media.buffer.toString("base64");
const result = await runImagePrompt({
cfg: options?.config,
modelOverride,
prompt: promptRaw,
base64,
mimeType,
});
return {
content: [{ type: "text", text: result.text }],
details: {
model: `${result.provider}/${result.model}`,
image: resolvedImage,
},
};
},
};
}
+2 -17
View File
@@ -151,16 +151,11 @@ export function createSessionsSendTool(opts?: {
typeof response?.runId === "string" && response.runId
? response.runId
: stepIdem;
const stepAcceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
const stepWaitMs = Math.min(step.timeoutMs, 60_000);
const wait = (await callGateway({
method: "agent.wait",
params: {
runId: stepRunId,
afterMs: stepAcceptedAt,
timeoutMs: stepWaitMs,
},
timeoutMs: stepWaitMs + 2000,
@@ -171,7 +166,7 @@ export function createSessionsSendTool(opts?: {
const runAgentToAgentFlow = async (
roundOneReply?: string,
runInfo?: { runId: string; acceptedAt?: number },
runInfo?: { runId: string },
) => {
try {
let primaryReply = roundOneReply;
@@ -182,7 +177,6 @@ export function createSessionsSendTool(opts?: {
method: "agent.wait",
params: {
runId: runInfo.runId,
afterMs: runInfo.acceptedAt,
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
@@ -277,14 +271,10 @@ export function createSessionsSendTool(opts?: {
params: sendParams,
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const acceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
void runAgentToAgentFlow(undefined, { runId, acceptedAt });
void runAgentToAgentFlow(undefined, { runId });
return jsonResult({
runId,
status: "accepted",
@@ -306,7 +296,6 @@ export function createSessionsSendTool(opts?: {
}
}
let acceptedAt: number | undefined;
try {
const response = (await callGateway({
method: "agent",
@@ -316,9 +305,6 @@ export function createSessionsSendTool(opts?: {
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
if (typeof response?.acceptedAt === "number") {
acceptedAt = response.acceptedAt;
}
} catch (err) {
const messageText =
err instanceof Error
@@ -341,7 +327,6 @@ export function createSessionsSendTool(opts?: {
method: "agent.wait",
params: {
runId,
afterMs: acceptedAt,
timeoutMs,
},
timeoutMs: timeoutMs + 2000,
+12 -1
View File
@@ -5,7 +5,18 @@ import { fileURLToPath } from "node:url";
import { resolveUserPath } from "../utils.js";
export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(os.homedir(), "clawd");
export function resolveDefaultAgentWorkspaceDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const profile = env.CLAWDBOT_PROFILE?.trim();
if (profile && profile.toLowerCase() !== "default") {
return path.join(homedir(), `clawd-${profile}`);
}
return path.join(homedir(), "clawd");
}
export const DEFAULT_AGENT_WORKSPACE_DIR = resolveDefaultAgentWorkspaceDir();
export const DEFAULT_AGENTS_FILENAME = "AGENTS.md";
export const DEFAULT_SOUL_FILENAME = "SOUL.md";
export const DEFAULT_TOOLS_FILENAME = "TOOLS.md";
+28
View File
@@ -0,0 +1,28 @@
const CONTROL_COMMAND_RE =
/(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i;
const CONTROL_COMMAND_EXACT = new Set([
"help",
"/help",
"status",
"/status",
"restart",
"/restart",
"activation",
"/activation",
"send",
"/send",
"reset",
"/reset",
"new",
"/new",
]);
export function hasControlCommand(text?: string): boolean {
if (!text) return false;
const trimmed = text.trim();
if (!trimmed) return false;
const lowered = trimmed.toLowerCase();
if (CONTROL_COMMAND_EXACT.has(lowered)) return true;
return CONTROL_COMMAND_RE.test(text);
}
+28
View File
@@ -578,6 +578,7 @@ describe("directive parsing", () => {
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");
expect(text).toContain("auth:");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -604,6 +605,33 @@ describe("directive parsing", () => {
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");
expect(text).toContain("auth:");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("lists allowlisted models on /model list", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("auth:");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,111 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
result: await run(provider, model),
provider,
model,
}),
}));
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
const webMocks = vi.hoisted(() => ({
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
}));
vi.mock("../web/session.js", () => webMocks);
import { getReplyFromConfig } from "./reply.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-typing-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
runEmbeddedPiAgentMock.mockClear();
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
}
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
whatsapp: {
allowFrom: ["*"],
},
session: { store: join(home, "sessions.json") },
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("getReplyFromConfig typing (heartbeat)", () => {
it("starts typing for normal runs", async () => {
await withTempHome(async (home) => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {},
});
const onReplyStart = vi.fn();
await getReplyFromConfig(
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
{ onReplyStart, isHeartbeat: false },
makeCfg(home),
);
expect(onReplyStart).toHaveBeenCalled();
});
});
it("does not start typing for heartbeat runs", async () => {
await withTempHome(async (home) => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {},
});
const onReplyStart = vi.fn();
await getReplyFromConfig(
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
{ onReplyStart, isHeartbeat: true },
makeCfg(home),
);
expect(onReplyStart).not.toHaveBeenCalled();
});
});
});
+119 -3
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../agents/pi-embedded.js", () => ({
@@ -14,6 +14,8 @@ vi.mock("../agents/pi-embedded.js", () => ({
}));
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { resolveSessionKey } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
@@ -107,6 +109,41 @@ describe("trigger handling", () => {
});
});
it("reports status when /status appears inline", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "please /status now",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("returns help without invoking the agent", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/help",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Help");
expect(text).toContain("Shortcuts");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("allows owner to set send policy", async () => {
await withTempHome(async (home) => {
const cfg = {
@@ -485,7 +522,7 @@ describe("trigger handling", () => {
});
});
it("ignores /activation from non-owners in groups", async () => {
it("allows /activation from allowFrom in groups", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
@@ -500,7 +537,8 @@ describe("trigger handling", () => {
{},
cfg,
);
expect(res).toBeUndefined();
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("⚙️ Group activation set to mention.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -694,6 +732,84 @@ describe("trigger handling", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("stages inbound media into the sandbox workspace", async () => {
await withTempHome(async (home) => {
const inboundDir = join(home, ".clawdbot", "media", "inbound");
await fs.mkdir(inboundDir, { recursive: true });
const mediaPath = join(inboundDir, "photo.jpg");
await fs.writeFile(mediaPath, "test");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
},
whatsapp: {
allowFrom: ["*"],
},
session: {
store: join(home, "sessions.json"),
},
};
const ctx = {
Body: "hi",
From: "group:whatsapp:demo",
To: "+2000",
ChatType: "group" as const,
Surface: "whatsapp" as const,
MediaPath: mediaPath,
MediaType: "image/jpeg",
MediaUrl: mediaPath,
};
const res = await getReplyFromConfig(ctx, {}, cfg);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const stagedPath = `media/inbound/${basename(mediaPath)}`;
expect(prompt).toContain(stagedPath);
expect(prompt).not.toContain(mediaPath);
const sessionKey = resolveSessionKey(
cfg.session?.scope ?? "per-sender",
ctx,
cfg.session?.mainKey,
);
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir: cfg.agent.workspace,
});
expect(sandbox).not.toBeNull();
if (!sandbox) {
throw new Error("Expected sandbox to be set");
}
const stagedFullPath = join(
sandbox.workspaceDir,
"media",
"inbound",
basename(mediaPath),
);
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
});
});
});
describe("group intro prompts", () => {
+103 -7
View File
@@ -1,4 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveModelRefFromString } from "../agents/model-selection.js";
import {
@@ -7,6 +10,7 @@ import {
isEmbeddedPiRunStreaming,
resolveEmbeddedSessionLane,
} from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
@@ -20,6 +24,7 @@ import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { logVerbose } from "../globals.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { hasControlCommand } from "./command-detection.js";
import { getAbortMemory } from "./reply/abort.js";
import { runReplyAgent } from "./reply/agent-runner.js";
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
@@ -48,7 +53,7 @@ import {
prependSystemEvents,
} from "./reply/session-updates.js";
import { createTypingController } from "./reply/typing.js";
import type { MsgContext } from "./templating.js";
import type { MsgContext, TemplateContext } from "./templating.js";
import {
type ElevatedLevel,
normalizeThinkLevel,
@@ -252,11 +257,22 @@ export async function getReplyFromConfig(
triggerBodyNormalized,
} = sessionState;
const directives = parseInlineDirectives(
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "",
);
sessionCtx.Body = directives.cleaned;
sessionCtx.BodyStripped = directives.cleaned;
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const commandAuthorized = ctx.CommandAuthorized ?? true;
const parsedDirectives = parseInlineDirectives(rawBody);
const directives = commandAuthorized
? parsedDirectives
: {
...parsedDirectives,
hasThinkDirective: false,
hasVerboseDirective: false,
hasStatusDirective: false,
hasModelDirective: false,
hasQueueDirective: false,
queueReset: false,
};
sessionCtx.Body = parsedDirectives.cleaned;
sessionCtx.BodyStripped = parsedDirectives.cleaned;
const surfaceKey =
sessionCtx.Surface?.trim().toLowerCase() ??
@@ -424,6 +440,7 @@ export async function getReplyFromConfig(
sessionKey,
isGroup,
triggerBodyNormalized,
commandAuthorized,
});
const isEmptyConfig = Object.keys(cfg).length === 0;
if (
@@ -445,6 +462,7 @@ export async function getReplyFromConfig(
ctx,
cfg,
command,
directives,
sessionEntry,
sessionStore,
sessionKey,
@@ -454,6 +472,7 @@ export async function getReplyFromConfig(
defaultGroupActivation: () => defaultActivation,
resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedElevatedLevel,
resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel,
provider,
model,
@@ -464,10 +483,20 @@ export async function getReplyFromConfig(
typing.cleanup();
return commandResult.reply;
}
await stageSandboxMedia({
ctx,
sessionCtx,
cfg,
sessionKey,
workspaceDir,
});
const isFirstTurnInSession = isNewSession || !systemSent;
const isGroupChat = sessionCtx.ChatType === "group";
const wasMentioned = ctx.WasMentioned === true;
const shouldEagerType = !isGroupChat || wasMentioned;
const isHeartbeat = opts?.isHeartbeat === true;
const shouldEagerType = (!isGroupChat || wasMentioned) && !isHeartbeat;
const shouldInjectGroupIntro = Boolean(
isGroupChat &&
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
@@ -483,6 +512,10 @@ export async function getReplyFromConfig(
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const rawBodyTrimmed = (ctx.Body ?? "").trim();
const baseBodyTrimmedRaw = baseBody.trim();
if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) {
typing.cleanup();
return undefined;
}
const isBareSessionReset =
isNewSession &&
baseBodyTrimmedRaw.length === 0 &&
@@ -515,6 +548,7 @@ export async function getReplyFromConfig(
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
prefixedBodyBase = await prependSystemEvents({
cfg,
sessionKey,
isMainSession,
isNewSession,
prefixedBodyBase,
@@ -661,3 +695,65 @@ export async function getReplyFromConfig(
shouldInjectGroupIntro,
});
}
async function stageSandboxMedia(params: {
ctx: MsgContext;
sessionCtx: TemplateContext;
cfg: ClawdbotConfig;
sessionKey?: string;
workspaceDir: string;
}) {
const { ctx, sessionCtx, cfg, sessionKey, workspaceDir } = params;
const rawPath = ctx.MediaPath?.trim();
if (!rawPath || !sessionKey) return;
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir,
});
if (!sandbox) return;
let source = rawPath;
if (source.startsWith("file://")) {
try {
source = fileURLToPath(source);
} catch {
return;
}
}
if (!path.isAbsolute(source)) return;
const originalMediaPath = ctx.MediaPath;
const originalMediaUrl = ctx.MediaUrl;
try {
const fileName = path.basename(source);
if (!fileName) return;
const destDir = path.join(sandbox.workspaceDir, "media", "inbound");
await fs.mkdir(destDir, { recursive: true });
const dest = path.join(destDir, fileName);
await fs.copyFile(source, dest);
const relative = path.posix.join("media", "inbound", fileName);
ctx.MediaPath = relative;
sessionCtx.MediaPath = relative;
if (originalMediaUrl) {
let normalizedUrl = originalMediaUrl;
if (normalizedUrl.startsWith("file://")) {
try {
normalizedUrl = fileURLToPath(normalizedUrl);
} catch {
normalizedUrl = originalMediaUrl;
}
}
if (normalizedUrl === originalMediaPath || normalizedUrl === source) {
ctx.MediaUrl = relative;
sessionCtx.MediaUrl = relative;
}
}
} catch (err) {
logVerbose(`Failed to stage inbound media for sandbox: ${String(err)}`);
}
}
@@ -0,0 +1,156 @@
import { describe, expect, it, vi } from "vitest";
import type { TemplateContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import type { TypingController } from "./typing.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
result: await run(provider, model),
provider,
model,
}),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
scheduleFollowupDrain: vi.fn(),
};
});
import { runReplyAgent } from "./agent-runner.js";
function createTyping(): TypingController {
return {
onReplyStart: vi.fn(async () => {}),
startTypingLoop: vi.fn(async () => {}),
startTypingOnText: vi.fn(async () => {}),
refreshTypingTtl: vi.fn(),
cleanup: vi.fn(),
};
}
type EmbeddedPiAgentParams = {
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
};
function createMinimalRun(params?: { opts?: GetReplyOptions }) {
const typing = createTyping();
const opts = params?.opts;
const sessionCtx = {
Surface: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
surface: "whatsapp",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
return {
typing,
opts,
run: () =>
runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
opts,
typing,
sessionCtx,
defaultModel: "anthropic/claude-opus-4-5",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
}),
};
}
describe("runReplyAgent typing (heartbeat)", () => {
it("signals typing for normal runs", async () => {
const onPartialReply = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
},
);
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: false, onPartialReply },
});
await run();
expect(onPartialReply).toHaveBeenCalled();
expect(typing.startTypingOnText).toHaveBeenCalledWith("hi");
expect(typing.startTypingLoop).toHaveBeenCalled();
});
it("never signals typing for heartbeat runs", async () => {
const onPartialReply = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
},
);
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: true, onPartialReply },
});
await run();
expect(onPartialReply).toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
});
+23 -11
View File
@@ -1,11 +1,11 @@
import crypto from "node:crypto";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import {
queueEmbeddedPiMessage,
runEmbeddedPiAgent,
} from "../../agents/pi-embedded.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import {
loadSessionStore,
type SessionEntry,
@@ -84,6 +84,8 @@ export async function runReplyAgent(params: {
shouldInjectGroupIntro,
} = params;
const isHeartbeat = opts?.isHeartbeat === true;
const shouldEmitToolResult = () => {
if (!sessionKey || !storePath) {
return resolvedVerboseLevel === "on";
@@ -203,13 +205,15 @@ export async function runReplyAgent(params: {
onPartialReply: opts?.onPartialReply
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
logVerbose(
"Stripped stray HEARTBEAT_OK token from reply",
);
}
if (
stripped.shouldSkip &&
@@ -219,7 +223,9 @@ export async function runReplyAgent(params: {
}
text = stripped.text;
}
await typing.startTypingOnText(text);
if (!isHeartbeat) {
await typing.startTypingOnText(text);
}
await opts.onPartialReply?.({
text,
mediaUrls: payload.mediaUrls,
@@ -230,7 +236,7 @@ export async function runReplyAgent(params: {
blockStreamingEnabled && opts?.onBlockReply
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
@@ -268,7 +274,9 @@ export async function runReplyAgent(params: {
}
pendingStreamedPayloadKeys.add(payloadKey);
const task = (async () => {
await typing.startTypingOnText(cleaned);
if (!isHeartbeat) {
await typing.startTypingOnText(cleaned);
}
await opts.onBlockReply?.(blockPayload);
})()
.then(() => {
@@ -291,13 +299,15 @@ export async function runReplyAgent(params: {
onToolResult: opts?.onToolResult
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
logVerbose(
"Stripped stray HEARTBEAT_OK token from reply",
);
}
if (
stripped.shouldSkip &&
@@ -307,7 +317,9 @@ export async function runReplyAgent(params: {
}
text = stripped.text;
}
await typing.startTypingOnText(text);
if (!isHeartbeat) {
await typing.startTypingOnText(text);
}
await opts.onToolResult?.({
text,
mediaUrls: payload.mediaUrls,
@@ -352,7 +364,7 @@ export async function runReplyAgent(params: {
await Promise.allSettled(pendingBlockTasks);
}
const sanitizedPayloads = opts?.isHeartbeat
const sanitizedPayloads = isHeartbeat
? payloadArray
: payloadArray.flatMap((payload) => {
const text = payload.text;
@@ -406,7 +418,7 @@ export async function runReplyAgent(params: {
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
return false;
});
if (shouldSignalTyping) {
if (shouldSignalTyping && !isHeartbeat) {
await typing.startTypingLoop();
}
+137 -32
View File
@@ -1,4 +1,9 @@
import fs from "node:fs";
import { getEnvApiKey } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveOAuthPath } from "../../config/paths.js";
import {
type SessionEntry,
type SessionScope,
@@ -15,18 +20,19 @@ import {
parseActivationCommand,
} from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { buildStatusMessage } from "../status.js";
import { buildHelpMessage, buildStatusMessage } from "../status.js";
import type { MsgContext } from "../templating.js";
import type { ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js";
import type { InlineDirectives } from "./directive-handling.js";
import { stripMentions } from "./mentions.js";
export type CommandContext = {
surface: string;
isWhatsAppSurface: boolean;
ownerList: string[];
isOwnerSender: boolean;
isAuthorizedSender: boolean;
senderE164?: string;
abortKey?: string;
rawBodyNormalized: string;
@@ -35,14 +41,75 @@ export type CommandContext = {
to?: string;
};
function hasOAuthCredentials(provider: string): boolean {
try {
const oauthPath = resolveOAuthPath();
if (!fs.existsSync(oauthPath)) return false;
const raw = fs.readFileSync(oauthPath, "utf8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const entry = parsed?.[provider] as
| {
refresh?: string;
refresh_token?: string;
refreshToken?: string;
access?: string;
access_token?: string;
accessToken?: string;
}
| undefined;
if (!entry) return false;
const refresh =
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
const access =
entry.access ?? entry.access_token ?? entry.accessToken ?? "";
return Boolean(refresh.trim() && access.trim());
} catch {
return false;
}
}
function resolveModelAuthLabel(provider?: string): string | undefined {
const resolved = provider?.trim();
if (!resolved) return undefined;
try {
const authStorage = discoverAuthStorage(resolveClawdbotAgentDir());
const stored = authStorage.get(resolved);
if (stored?.type === "oauth") return "oauth";
if (stored?.type === "api_key") return "api-key";
} catch {
// ignore auth storage errors
}
if (resolved === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return "oauth";
}
if (hasOAuthCredentials(resolved)) return "oauth";
const envKey = getEnvApiKey(resolved);
if (envKey?.trim()) return "api-key";
return "unknown";
}
export function buildCommandContext(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
sessionKey?: string;
isGroup: boolean;
triggerBodyNormalized: string;
commandAuthorized: boolean;
}): CommandContext {
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
const {
ctx,
cfg,
sessionKey,
isGroup,
triggerBodyNormalized,
commandAuthorized,
} = params;
const surface = (ctx.Surface ?? "").trim().toLowerCase();
const isWhatsAppSurface =
surface === "whatsapp" ||
@@ -54,16 +121,12 @@ export function buildCommandContext(params: {
: undefined;
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
const defaultAllowFrom =
isWhatsAppSurface &&
(!configuredAllowFrom || configuredAllowFrom.length === 0) &&
to
? [to]
: undefined;
const allowFrom =
configuredAllowFrom && configuredAllowFrom.length > 0
? configuredAllowFrom
: defaultAllowFrom;
const allowFromList =
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
const allowAll =
!isWhatsAppSurface ||
allowFromList.length === 0 ||
allowFromList.some((entry) => entry.trim() === "*");
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
@@ -71,23 +134,28 @@ export function buildCommandContext(params: {
? stripMentions(rawBodyNormalized, ctx, cfg)
: rawBodyNormalized;
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
const ownerCandidates = isWhatsAppSurface
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
: [];
if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
const ownerCandidates =
isWhatsAppSurface && !allowAll
? allowFromList.filter((entry) => entry !== "*")
: [];
if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) {
ownerCandidates.push(to);
}
const ownerList = ownerCandidates
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry));
const isOwnerSender =
Boolean(senderE164) && ownerList.includes(senderE164 ?? "");
const isOwner =
!isWhatsAppSurface ||
allowAll ||
ownerList.length === 0 ||
(senderE164 ? ownerList.includes(senderE164) : false);
const isAuthorizedSender = commandAuthorized && isOwner;
return {
surface,
isWhatsAppSurface,
ownerList,
isOwnerSender,
isAuthorizedSender,
senderE164: senderE164 || undefined,
abortKey,
rawBodyNormalized,
@@ -101,6 +169,7 @@ export async function handleCommands(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
command: CommandContext;
directives: InlineDirectives;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
@@ -110,6 +179,7 @@ export async function handleCommands(params: {
defaultGroupActivation: () => "always" | "mention";
resolvedThinkLevel?: ThinkLevel;
resolvedVerboseLevel: VerboseLevel;
resolvedElevatedLevel?: ElevatedLevel;
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
provider: string;
model: string;
@@ -122,6 +192,7 @@ export async function handleCommands(params: {
const {
cfg,
command,
directives,
sessionEntry,
sessionStore,
sessionKey,
@@ -131,7 +202,9 @@ export async function handleCommands(params: {
defaultGroupActivation,
resolvedThinkLevel,
resolvedVerboseLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup,
@@ -151,9 +224,22 @@ export async function handleCommands(params: {
reply: { text: "⚙️ Group activation only applies to group chats." },
};
}
if (!command.isOwnerSender) {
const activationOwnerList = command.ownerList;
const activationSenderE164 = command.senderE164
? normalizeE164(command.senderE164)
: "";
const isActivationOwner =
!command.isWhatsAppSurface || activationOwnerList.length === 0
? command.isAuthorizedSender
: Boolean(activationSenderE164) &&
activationOwnerList.includes(activationSenderE164);
if (
!command.isAuthorizedSender ||
(command.isWhatsAppSurface && !isActivationOwner)
) {
logVerbose(
`Ignoring /activation from non-owner in group: ${command.senderE164 || "<unknown>"}`,
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -179,9 +265,9 @@ export async function handleCommands(params: {
}
if (sendPolicyCommand.hasCommand) {
if (!command.isOwnerSender) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /send from non-owner: ${command.senderE164 || "<unknown>"}`,
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -220,9 +306,9 @@ export async function handleCommands(params: {
command.commandBodyNormalized === "restart" ||
command.commandBodyNormalized.startsWith("/restart ")
) {
if (isGroup && !command.isOwnerSender) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /restart from non-owner in group: ${command.senderE164 || "<unknown>"}`,
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -235,14 +321,29 @@ export async function handleCommands(params: {
};
}
if (
const helpRequested =
command.commandBodyNormalized === "/help" ||
command.commandBodyNormalized === "help" ||
/(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized);
if (helpRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /help from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildHelpMessage() } };
}
const statusRequested =
directives.hasStatusDirective ||
command.commandBodyNormalized === "/status" ||
command.commandBodyNormalized === "status" ||
command.commandBodyNormalized.startsWith("/status ")
) {
if (isGroup && !command.isOwnerSender) {
command.commandBodyNormalized.startsWith("/status ");
if (statusRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /status from non-owner in group: ${command.senderE164 || "<unknown>"}`,
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -255,10 +356,12 @@ export async function handleCommands(params: {
: undefined;
const statusText = buildStatusMessage({
agent: {
...cfg.agent,
model,
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
},
workspaceDir,
sessionEntry,
@@ -269,6 +372,8 @@ export async function handleCommands(params: {
resolvedThink:
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
resolvedVerbose: resolvedVerboseLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider),
webLinked,
webAuthAgeMs,
heartbeatSeconds,
+83 -4
View File
@@ -1,9 +1,13 @@
import { getEnvApiKey } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import { lookupContextTokens } from "../../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../../agents/defaults.js";
import { hydrateAuthStorage } from "../../agents/model-auth.js";
import {
buildModelAliasIndex,
type ModelAliasIndex,
@@ -14,12 +18,14 @@ import {
import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { shortenHomePath } from "../../utils.js";
import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import {
type ElevatedLevel,
extractElevatedDirective,
extractStatusDirective,
extractThinkDirective,
extractVerboseDirective,
type ThinkLevel,
@@ -38,6 +44,55 @@ import {
const SYSTEM_MARK = "⚙️";
const maskApiKey = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return "missing";
if (trimmed.length <= 16) return trimmed;
return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`;
};
const resolveAuthLabel = async (
provider: string,
authStorage: ReturnType<typeof discoverAuthStorage>,
authPaths: { authPath: string; modelsPath: string },
): Promise<{ label: string; source: string }> => {
const formatPath = (value: string) => shortenHomePath(value);
const stored = authStorage.get(provider);
if (stored?.type === "oauth") {
const email = stored.email?.trim();
return {
label: email ? `OAuth ${email}` : "OAuth (unknown)",
source: `auth.json: ${formatPath(authPaths.authPath)}`,
};
}
if (stored?.type === "api_key") {
return {
label: maskApiKey(stored.key),
source: `auth.json: ${formatPath(authPaths.authPath)}`,
};
}
const envKey = getEnvApiKey(provider);
if (envKey) return { label: maskApiKey(envKey), source: "env" };
if (provider === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
if (oauthEnv) {
return { label: "OAuth (env)", source: "env: ANTHROPIC_OAUTH_TOKEN" };
}
}
try {
const key = await authStorage.getApiKey(provider);
if (key) {
return {
label: maskApiKey(key),
source: `models.json: ${formatPath(authPaths.modelsPath)}`,
};
}
} catch {
// ignore missing auth
}
return { label: "missing", source: "missing" };
};
export type InlineDirectives = {
cleaned: string;
hasThinkDirective: boolean;
@@ -49,6 +104,7 @@ export type InlineDirectives = {
hasElevatedDirective: boolean;
elevatedLevel?: ElevatedLevel;
rawElevatedLevel?: string;
hasStatusDirective: boolean;
hasModelDirective: boolean;
rawModelDirective?: string;
hasQueueDirective: boolean;
@@ -83,11 +139,13 @@ export function parseInlineDirectives(body: string): InlineDirectives {
rawLevel: rawElevatedLevel,
hasDirective: hasElevatedDirective,
} = extractElevatedDirective(verboseCleaned);
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } =
extractStatusDirective(elevatedCleaned);
const {
cleaned: modelCleaned,
rawModel,
hasDirective: hasModelDirective,
} = extractModelDirective(elevatedCleaned);
} = extractModelDirective(statusCleaned);
const {
cleaned: queueCleaned,
queueMode,
@@ -114,6 +172,7 @@ export function parseInlineDirectives(body: string): InlineDirectives {
hasElevatedDirective,
elevatedLevel,
rawElevatedLevel,
hasStatusDirective,
hasModelDirective,
rawModelDirective: rawModel,
hasQueueDirective,
@@ -191,12 +250,30 @@ export async function handleDirectiveOnly(params: {
} = params;
if (directives.hasModelDirective) {
const modelDirective = directives.rawModelDirective?.trim().toLowerCase();
const isModelListAlias =
directives.rawModelDirective?.trim().toLowerCase() === "status";
modelDirective === "status" || modelDirective === "list";
if (!directives.rawModelDirective || isModelListAlias) {
if (allowedModelCatalog.length === 0) {
return { text: "No models available." };
}
const agentDir = resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const authPaths = {
authPath: `${agentDir}/auth.json`,
modelsPath: `${agentDir}/models.json`,
};
hydrateAuthStorage(authStorage);
const authByProvider = new Map<string, string>();
for (const entry of allowedModelCatalog) {
if (authByProvider.has(entry.provider)) continue;
const auth = await resolveAuthLabel(
entry.provider,
authStorage,
authPaths,
);
authByProvider.set(entry.provider, `${auth.label} (${auth.source})`);
}
const current = `${params.provider}/${params.model}`;
const defaultLabel = `${defaultProvider}/${defaultModel}`;
const header =
@@ -214,9 +291,11 @@ export async function handleDirectiveOnly(params: {
aliases && aliases.length > 0
? ` (alias: ${aliases.join(", ")})`
: "";
const suffix =
const nameSuffix =
entry.name && entry.name !== entry.id ? ` — ${entry.name}` : "";
lines.push(`- ${label}${aliasSuffix}${suffix}`);
const authLabel = authByProvider.get(entry.provider) ?? "missing";
const authSuffix = ` — auth: ${authLabel}`;
lines.push(`- ${label}${aliasSuffix}${nameSuffix}${authSuffix}`);
}
return { text: lines.join("\n") };
}
+15
View File
@@ -74,4 +74,19 @@ export function extractElevatedDirective(body?: string): {
};
}
export function extractStatusDirective(body?: string): {
cleaned: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const match = body.match(/(?:^|\s)\/status(?=$|\s|:)\b/i);
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim();
return {
cleaned,
hasDirective: !!match,
};
}
export type { ElevatedLevel, ThinkLevel, VerboseLevel };
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { GroupKeyResolution } from "../../config/sessions.js";
import type { TemplateContext } from "../templating.js";
import { resolveGroupRequireMention } from "./groups.js";
describe("resolveGroupRequireMention", () => {
it("respects Discord guild/channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
discord: {
guilds: {
"145": {
requireMention: false,
channels: {
general: { allow: true },
},
},
},
},
};
const ctx: TemplateContext = {
Surface: "discord",
From: "group:123",
GroupRoom: "#general",
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
surface: "discord",
id: "123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
false,
);
});
it("respects Slack channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
slack: {
channels: {
C123: { requireMention: false },
},
},
};
const ctx: TemplateContext = {
Surface: "slack",
From: "slack:channel:C123",
GroupSubject: "#general",
};
const groupResolution: GroupKeyResolution = {
surface: "slack",
id: "C123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
false,
);
});
});
+91
View File
@@ -6,6 +6,43 @@ import type {
import { normalizeGroupActivation } from "../group-activation.js";
import type { TemplateContext } from "../templating.js";
function normalizeDiscordSlug(value?: string | null) {
if (!value) return "";
let text = value.trim().toLowerCase();
if (!text) return "";
text = text.replace(/^[@#]+/, "");
text = text.replace(/[\s_]+/g, "-");
text = text.replace(/[^a-z0-9-]+/g, "-");
text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
return text;
}
function normalizeSlackSlug(raw?: string | null) {
const trimmed = raw?.trim().toLowerCase() ?? "";
if (!trimmed) return "";
const dashed = trimmed.replace(/\s+/g, "-");
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
}
function resolveDiscordGuildEntry(
guilds: NonNullable<ClawdbotConfig["discord"]>["guilds"],
groupSpace?: string,
) {
if (!guilds || Object.keys(guilds).length === 0) return null;
const space = groupSpace?.trim();
if (space && guilds[space]) return guilds[space];
const normalized = normalizeDiscordSlug(space);
if (normalized && guilds[normalized]) return guilds[normalized];
if (normalized) {
const match = Object.values(guilds).find(
(entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized,
);
if (match) return match;
}
return guilds["*"] ?? null;
}
export function resolveGroupRequireMention(params: {
cfg: ClawdbotConfig;
ctx: TemplateContext;
@@ -14,6 +51,8 @@ export function resolveGroupRequireMention(params: {
const { cfg, ctx, groupResolution } = params;
const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase();
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
if (surface === "telegram") {
if (groupId) {
const groupConfig = cfg.telegram?.groups?.[groupId];
@@ -47,6 +86,58 @@ export function resolveGroupRequireMention(params: {
if (typeof groupDefault === "boolean") return groupDefault;
return true;
}
if (surface === "discord") {
const guildEntry = resolveDiscordGuildEntry(
cfg.discord?.guilds,
groupSpace,
);
const channelEntries = guildEntry?.channels;
if (channelEntries && Object.keys(channelEntries).length > 0) {
const channelSlug = normalizeDiscordSlug(groupRoom);
const entry =
(groupId ? channelEntries[groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(groupRoom
? channelEntries[normalizeDiscordSlug(groupRoom)]
: undefined);
if (entry && typeof entry.requireMention === "boolean") {
return entry.requireMention;
}
}
if (typeof guildEntry?.requireMention === "boolean") {
return guildEntry.requireMention;
}
return true;
}
if (surface === "slack") {
const channels = cfg.slack?.channels ?? {};
const keys = Object.keys(channels);
if (keys.length === 0) return true;
const channelId = groupId?.trim();
const channelName = groupRoom?.replace(/^#/, "");
const normalizedName = normalizeSlackSlug(channelName);
const candidates = [
channelId ?? "",
channelName ? `#${channelName}` : "",
channelName ?? "",
normalizedName,
].filter(Boolean);
let matched: { requireMention?: boolean } | undefined;
for (const candidate of candidates) {
if (candidate && channels[candidate]) {
matched = channels[candidate];
break;
}
}
const fallback = channels["*"];
const resolved = matched ?? fallback;
if (typeof resolved?.requireMention === "boolean") {
return resolved.requireMention;
}
return true;
}
return true;
}
+3 -4
View File
@@ -8,12 +8,11 @@ import { drainSystemEvents } from "../../infra/system-events.js";
export async function prependSystemEvents(params: {
cfg: ClawdbotConfig;
sessionKey: string;
isMainSession: boolean;
isNewSession: boolean;
prefixedBodyBase: string;
}): Promise<string> {
if (!params.isMainSession) return params.prefixedBodyBase;
const compactSystemEvent = (line: string): string | null => {
const trimmed = line.trim();
if (!trimmed) return null;
@@ -27,11 +26,11 @@ export async function prependSystemEvents(params: {
};
const systemLines: string[] = [];
const queued = drainSystemEvents();
const queued = drainSystemEvents(params.sessionKey);
systemLines.push(
...queued.map(compactSystemEvent).filter((v): v is string => Boolean(v)),
);
if (params.isNewSession) {
if (params.isMainSession && params.isNewSession) {
const summary = await buildProviderSummary(params.cfg);
if (summary.length > 0) systemLines.unshift(...summary);
}
+3
View File
@@ -36,12 +36,15 @@ describe("buildStatusMessage", () => {
expect(text).toContain("⚙️ Status");
expect(text).toContain("Agent: embedded pi");
expect(text).toContain("Runtime: direct");
expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("Session: main");
expect(text).toContain("Web: linked");
expect(text).toContain("heartbeat 45s");
expect(text).toContain("thinking=medium");
expect(text).toContain("verbose=off");
expect(text).not.toContain("Shortcuts:");
expect(text).not.toContain("set with");
});
it("handles missing agent config gracefully", () => {
+46 -7
View File
@@ -14,12 +14,13 @@ import {
} from "../agents/usage.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
resolveMainSessionKey,
resolveSessionTranscriptPath,
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import { shortenHomePath } from "../utils.js";
import type { ThinkLevel, VerboseLevel } from "./thinking.js";
import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
@@ -33,6 +34,8 @@ type StatusArgs = {
groupActivation?: "mention" | "always";
resolvedThink?: ThinkLevel;
resolvedVerbose?: VerboseLevel;
resolvedElevated?: ElevatedLevel;
modelAuth?: string;
now?: number;
webLinked?: boolean;
webAuthAgeMs?: number | null;
@@ -133,7 +136,8 @@ export function buildStatusMessage(args: StatusArgs): string {
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const provider = entry?.modelProvider ?? resolved.provider ?? DEFAULT_PROVIDER;
const provider =
entry?.modelProvider ?? resolved.provider ?? DEFAULT_PROVIDER;
let model = entry?.model ?? resolved.model ?? DEFAULT_MODEL;
let contextTokens =
entry?.contextTokens ??
@@ -163,7 +167,30 @@ export function buildStatusMessage(args: StatusArgs): string {
const verboseLevel =
args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off";
const elevatedLevel =
args.sessionEntry?.elevatedLevel ?? args.agent?.elevatedDefault ?? "on";
args.resolvedElevated ??
args.sessionEntry?.elevatedLevel ??
args.agent?.elevatedDefault ??
"on";
const runtime = (() => {
const sandboxMode = args.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off")
return { line: "Runtime: direct", sandboxed: false };
const sessionScope = args.sessionScope ?? "per-sender";
const mainKey = resolveMainSessionKey({
session: { scope: sessionScope },
});
const sessionKey = args.sessionKey?.trim();
const sandboxed = sessionKey
? sandboxMode === "all" || sessionKey !== mainKey.trim()
: false;
const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
const suffix = sandboxed ? ` • elevated ${elevatedLevel}` : "";
return {
line: `Runtime: ${runtime} (sandbox ${sandboxMode})${suffix}`,
sandboxed,
};
})();
const webLine = (() => {
if (args.webLinked === false) {
@@ -203,27 +230,39 @@ export function buildStatusMessage(args: StatusArgs): string {
contextTokens ?? null,
)}${entry?.abortedLastRun ? " • last run aborted" : ""}`;
const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | elevated=${elevatedLevel} (set with /think <level>, /verbose on|off, /elevated on|off, /model <id>)`;
const optionsLine = runtime.sandboxed
? `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | elevated=${elevatedLevel}`
: `Options: thinking=${thinkLevel} | verbose=${verboseLevel}`;
const modelLabel = model ? `${provider}/${model}` : "unknown";
const agentLine = `Agent: embedded pi • ${modelLabel}`;
const authLine = args.modelAuth ? `Model auth: ${args.modelAuth}` : undefined;
const workspaceLine = args.workspaceDir
? `Workspace: ${shortenHomePath(args.workspaceDir)}`
: undefined;
const helpersLine = "Shortcuts: /new reset | /restart relink";
return [
"⚙️ Status",
webLine,
agentLine,
authLine,
runtime.line,
workspaceLine,
contextLine,
sessionLine,
groupActivationLine,
optionsLine,
helpersLine,
]
.filter(Boolean)
.join("\n");
}
export function buildHelpMessage(): string {
return [
"ℹ️ Help",
"Shortcuts: /new reset | /restart relink",
"Options: /think <level> | /verbose on|off | /elevated on|off | /model <id>",
].join("\n");
}
+2
View File
@@ -17,11 +17,13 @@ export type MsgContext = {
GroupSpace?: string;
GroupMembers?: string;
SenderName?: string;
SenderId?: string;
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
Surface?: string;
WasMentioned?: boolean;
CommandAuthorized?: boolean;
};
export type TemplateContext = MsgContext & {
+8
View File
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { normalizeThinkLevel } from "./thinking.js";
describe("normalizeThinkLevel", () => {
it("accepts mid as medium", () => {
expect(normalizeThinkLevel("mid")).toBe("medium");
});
});
+5 -1
View File
@@ -12,7 +12,11 @@ export function normalizeThinkLevel(
if (["min", "minimal"].includes(key)) return "minimal";
if (["low", "thinkhard", "think-hard", "think_hard"].includes(key))
return "low";
if (["med", "medium", "thinkharder", "think-harder", "harder"].includes(key))
if (
["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(
key,
)
)
return "medium";
if (
[
+18
View File
@@ -19,6 +19,24 @@ describe("browser config", () => {
expect(profile?.cdpIsLoopback).toBe(true);
});
it("derives default ports from CLAWDBOT_GATEWAY_PORT when unset", () => {
const prev = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = "19001";
try {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.controlPort).toBe(19003);
const profile = resolveProfile(resolved, resolved.defaultProfile);
expect(profile?.cdpPort).toBe(19012);
expect(profile?.cdpUrl).toBe("http://127.0.0.1:19012");
} finally {
if (prev === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prev;
}
}
});
it("normalizes hex colors", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://localhost:18791",
+25 -2
View File
@@ -1,4 +1,8 @@
import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
import {
deriveDefaultBrowserCdpPortRange,
deriveDefaultBrowserControlPort,
} from "../config/port-defaults.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
@@ -89,11 +93,12 @@ function ensureDefaultProfile(
profiles: Record<string, BrowserProfileConfig> | undefined,
defaultColor: string,
legacyCdpPort?: number,
derivedDefaultCdpPort?: number,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (!result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME]) {
result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME] = {
cdpPort: legacyCdpPort ?? CDP_PORT_RANGE_START,
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START,
color: defaultColor,
};
}
@@ -103,13 +108,30 @@ export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim();
const derivedControlPort = (() => {
const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim();
if (!raw) return null;
const gatewayPort = Number.parseInt(raw, 10);
if (!Number.isFinite(gatewayPort) || gatewayPort <= 0) return null;
return deriveDefaultBrowserControlPort(gatewayPort);
})();
const derivedControlUrl = derivedControlPort
? `http://127.0.0.1:${derivedControlPort}`
: null;
const controlInfo = parseHttpUrl(
cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL,
cfg?.controlUrl ??
envControlUrl ??
derivedControlUrl ??
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
"browser.controlUrl",
);
const controlPort = controlInfo.port;
const defaultColor = normalizeHexColor(cfg?.color);
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
let cdpInfo:
| {
@@ -149,6 +171,7 @@ export function resolveBrowserConfig(
cfg?.profiles,
defaultColor,
legacyCdpPort,
derivedCdpRange.start,
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
+8 -4
View File
@@ -10,10 +10,14 @@ import type {
BrowserServerState,
} from "./server-context.js";
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(),
writeConfigFile: vi.fn(async () => {}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: vi.fn(),
writeConfigFile: vi.fn(async () => {}),
};
});
vi.mock("./trash.js", () => ({
movePathToTrash: vi.fn(async (targetPath: string) => targetPath),
+5 -1
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import type { BrowserProfileConfig, ClawdbotConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { resolveClawdUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import {
@@ -79,7 +80,10 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
profileConfig = { cdpUrl: parsed.normalized, color: profileColor };
} else {
const usedPorts = getUsedPorts(resolvedProfiles);
const cdpPort = allocateCdpPort(usedPorts);
const range = deriveDefaultBrowserCdpPortRange(
state.resolved.controlPort,
);
const cdpPort = allocateCdpPort(usedPorts, range);
if (cdpPort === null) {
throw new Error("no available CDP ports in range");
}
+11
View File
@@ -64,6 +64,17 @@ describe("port allocation", () => {
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
});
it("allocates within an explicit range", () => {
const usedPorts = new Set<number>();
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(
20000,
);
usedPorts.add(20000);
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(
20001,
);
});
it("skips used ports and returns next available", () => {
const usedPorts = new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]);
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 2);
+18 -3
View File
@@ -1,8 +1,9 @@
/**
* CDP port allocation for browser profiles.
*
* Port range: 18800-18899 (100 profiles max)
* Default port range: 18800-18899 (100 profiles max)
* Ports are allocated once at profile creation and persisted in config.
* Multi-instance: callers may pass an explicit range to avoid collisions.
*
* Reserved ports (do not use for CDP):
* 18789 - Gateway WebSocket
@@ -21,8 +22,22 @@ export function isValidProfileName(name: string): boolean {
return PROFILE_NAME_REGEX.test(name);
}
export function allocateCdpPort(usedPorts: Set<number>): number | null {
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
export function allocateCdpPort(
usedPorts: Set<number>,
range?: { start: number; end: number },
): number | null {
const start = range?.start ?? CDP_PORT_RANGE_START;
const end = range?.end ?? CDP_PORT_RANGE_END;
if (
!Number.isFinite(start) ||
!Number.isFinite(end) ||
start <= 0 ||
end <= 0
) {
return null;
}
if (start > end) return null;
for (let port = start; port <= end; port++) {
if (!usedPorts.has(port)) return port;
}
return null;
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more