Merge branch 'main' into feature/bluebubbles-text-formatting

This commit is contained in:
Nick Winans 2026-01-22 10:41:02 -08:00 committed by GitHub
commit 07d64e7d59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
143 changed files with 7960 additions and 774 deletions

View File

@ -2,66 +2,70 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.22 ## 2026.1.21-2
### Changes
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
### Breaking
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
### Fixes ### Fixes
- Config: avoid stack traces for invalid configs and log the config path. - Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900)
- Doctor: warn when gateway.mode is unset with configure/config guidance.
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
## 2026.1.21 ## 2026.1.21
### Highlights
- Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
- Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning
- Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated
- Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams
- `/models` UX refresh + `clawdbot update wizard`. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update
### Changes ### Changes
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker. - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. - Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
- CLI: exec approvals mutations render tables instead of raw JSON. - Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
- Exec approvals: support wildcard agent allowlists (`*`) across all agents. - CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing. - Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. - CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. - Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. - Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. - Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).
- Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.
- Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.
### Breaking ### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http - **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
### Fixes ### Fixes
- Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.
- Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.
- Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.
- Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman. - Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380) - Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x. - Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
- UI/config: export `SECTION_META` for config form modules. (#1418) Thanks @MaudeBot.
- macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.
- BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.
- Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit `/model` list output. (#1376, #1416)
- Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
- Cache: restore the 1h cache TTL option and reset the pruning window.
- Zalo Personal: tolerate ANSI/log-prefixed JSON output from `zca`. (#1379) Thanks @ptn1411.
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
- Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.
- Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when `gateway.mode` is unset. (#900)
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
- Logs/Status: align rolling log filenames with local time and report sandboxed runtime in `clawdbot status`. (#1343)
- Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell. - Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell.
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. - Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
- Model picker: list the full catalog when no model allowlist is configured.
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
## 2026.1.20 ## 2026.1.20
@ -96,6 +100,7 @@ Docs: https://docs.clawd.bot
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest - Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest - Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui - Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.clawd.bot/gateway/configuration
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools - Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles - Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader. - Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
@ -105,6 +110,7 @@ Docs: https://docs.clawd.bot
- Plugins: auto-enable bundled channel/provider plugins when configuration is present. - Plugins: auto-enable bundled channel/provider plugins when configuration is present.
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`. - Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`. - Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229) - Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) - Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs - Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs

View File

@ -479,26 +479,26 @@ Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
<a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a>
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
<a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
<a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a>
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a>
<a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a>
<a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
<a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a>
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a>
<a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
<a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>

View File

@ -2,6 +2,80 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdbot</title> <title>Clawdbot</title>
<item>
<title>2026.1.21</title>
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7374</sparkle:version>
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
<h3>Highlights</h3>
<ul>
<li>Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster</li>
<li>Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui</li>
<li>Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning</li>
<li>Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated</li>
<li>Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams</li>
<li><code>/models</code> UX refresh + <code>clawdbot update wizard</code>. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.</li>
<li>Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents</li>
<li>Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui</li>
<li>CLI: add <code>clawdbot update wizard</code> with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update</li>
<li>Models/Commands: add <code>/models</code>, improve <code>/model</code> listing UX, and expand <code>clawdbot models</code> paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models</li>
<li>CLI: move gateway service commands under <code>clawdbot gateway</code>, flatten node service commands under <code>clawdbot node</code>, and add <code>gateway probe</code> for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node</li>
<li>Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals</li>
<li>Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals</li>
<li>Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat</li>
<li>Sessions: add per-channel idle durations via <code>sessions.channelIdleMinutes</code>. (#1353) Thanks @cash-echo-bot.</li>
<li>Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node</li>
<li>Cache: add <code>cache.ttlPrune</code> mode and auth-aware defaults for cache TTL behavior.</li>
<li>Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue</li>
<li>Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord</li>
<li>Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal</li>
<li>MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams</li>
<li>Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).</li>
<li>macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).</li>
<li>Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.</li>
<li>Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set <code>gateway.controlUi.allowInsecureAuth: true</code> to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http</li>
<li><strong>BREAKING:</strong> Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.</li>
<li>Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.</li>
<li>Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.</li>
<li>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</li>
<li>Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.</li>
<li>Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)</li>
<li>Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.</li>
<li>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</li>
<li>macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.</li>
<li>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</li>
<li>Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit <code>/model</code> list output. (#1376, #1416)</li>
<li>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</li>
<li>Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when <code>gateway.mode</code> is unset. (#900)</li>
<li>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
<li>Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell.</li>
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</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.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
</item>
<item> <item>
<title>2026.1.21</title> <title>2026.1.21</title>
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate> <pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
@ -208,86 +282,5 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</item> </item>
<item>
<title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5998</sparkle:version>
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
<h3>Highlights</h3>
<ul>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
<li>TUI: show provider/model labels for the active session and default model.</li>
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code><code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
<li>Browser: upgrade <code>ws</code><code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</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.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
</item>
</channel> </channel>
</rss> </rss>

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33", "originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2",
"pins" : [ "pins" : [
{ {
"identity" : "axorcist", "identity" : "axorcist",
@ -24,7 +24,7 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit", "location" : "https://github.com/steipete/ElevenLabsKit",
"state" : { "state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269", "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0" "version" : "0.1.0"
} }
}, },

View File

@ -74,6 +74,7 @@ final class ControlChannel {
} }
private(set) var lastPingMs: Double? private(set) var lastPingMs: Double?
private(set) var authSourceLabel: String?
private let logger = Logger(subsystem: "com.clawdbot", category: "control") private let logger = Logger(subsystem: "com.clawdbot", category: "control")
@ -128,6 +129,7 @@ final class ControlChannel {
await GatewayConnection.shared.shutdown() await GatewayConnection.shared.shutdown()
self.state = .disconnected self.state = .disconnected
self.lastPingMs = nil self.lastPingMs = nil
self.authSourceLabel = nil
} }
func health(timeout: TimeInterval? = nil) async throws -> Data { func health(timeout: TimeInterval? = nil) async throws -> Data {
@ -188,8 +190,11 @@ final class ControlChannel {
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{ {
let reason = urlErr.failureURLString ?? urlErr.localizedDescription let reason = urlErr.failureURLString ?? urlErr.localizedDescription
let tokenKey = CommandResolver.connectionModeIsRemote()
? "gateway.remote.token"
: "gateway.auth.token"
return return
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " + "Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " +
"or clear it on the gateway. " + "or clear it on the gateway. " +
"Reason: \(reason)" "Reason: \(reason)"
} }
@ -300,6 +305,27 @@ final class ControlChannel {
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"]) userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
} }
await self.refreshAuthSourceLabel()
}
private func refreshAuthSourceLabel() async {
let isRemote = CommandResolver.connectionModeIsRemote()
let authSource = await GatewayConnection.shared.authSource()
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
}
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
guard let source else { return nil }
switch source {
case .deviceToken:
return "Auth: device token (paired device)"
case .sharedToken:
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
case .password:
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
case .none:
return "Auth: none"
}
} }
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {

View File

@ -475,8 +475,8 @@ enum ExecApprovalsStore {
private static func mergeAgents( private static func mergeAgents(
current: ExecApprovalsAgent, current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
) -> ExecApprovalsAgent { {
var seen = Set<String>() var seen = Set<String>()
var allowlist: [ExecAllowlistEntry] = [] var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) { func append(_ entry: ExecAllowlistEntry) {
@ -486,8 +486,12 @@ enum ExecApprovalsStore {
seen.insert(key) seen.insert(key)
allowlist.append(entry) allowlist.append(entry)
} }
for entry in current.allowlist ?? [] { append(entry) } for entry in current.allowlist ?? [] {
for entry in legacy.allowlist ?? [] { append(entry) } append(entry)
}
for entry in legacy.allowlist ?? [] {
append(entry)
}
return ExecApprovalsAgent( return ExecApprovalsAgent(
security: current.security ?? legacy.security, security: current.security ?? legacy.security,

View File

@ -1,5 +1,6 @@
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import CoreGraphics
import Foundation import Foundation
import OSLog import OSLog
@ -44,6 +45,7 @@ final class ExecApprovalsGatewayPrompter {
do { do {
let data = try JSONEncoder().encode(payload) let data = try JSONEncoder().encode(payload)
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data) let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
guard self.shouldPresent(request: request) else { return }
let decision = ExecApprovalsPromptPresenter.prompt(request.request) let decision = ExecApprovalsPromptPresenter.prompt(request.request)
try await GatewayConnection.shared.requestVoid( try await GatewayConnection.shared.requestVoid(
method: .execApprovalResolve, method: .execApprovalResolve,
@ -56,4 +58,66 @@ final class ExecApprovalsGatewayPrompter {
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)") self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
} }
} }
private func shouldPresent(request: GatewayApprovalRequest) -> Bool {
let mode = AppStateStore.shared.connectionMode
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
return Self.shouldPresent(
mode: mode,
activeSession: activeSession,
requestSession: requestSession,
lastInputSeconds: Self.lastInputSeconds(),
thresholdSeconds: 120)
}
private static func shouldPresent(
mode: AppState.ConnectionMode,
activeSession: String?,
requestSession: String?,
lastInputSeconds: Int?,
thresholdSeconds: Int) -> Bool
{
let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines)
let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines)
let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local)
if let session = requested, !session.isEmpty {
if let active, !active.isEmpty {
return active == session
}
return recentlyActive
}
if let active, !active.isEmpty {
return true
}
return mode == .local
}
private static func lastInputSeconds() -> Int? {
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
return Int(seconds.rounded())
}
} }
#if DEBUG
extension ExecApprovalsGatewayPrompter {
static func _testShouldPresent(
mode: AppState.ConnectionMode,
activeSession: String?,
requestSession: String?,
lastInputSeconds: Int?,
thresholdSeconds: Int = 120) -> Bool
{
self.shouldPresent(
mode: mode,
activeSession: activeSession,
requestSession: requestSession,
lastInputSeconds: lastInputSeconds,
thresholdSeconds: thresholdSeconds)
}
}
#endif

View File

@ -13,6 +13,7 @@ struct ExecApprovalPromptRequest: Codable, Sendable {
var ask: String? var ask: String?
var agentId: String? var agentId: String?
var resolvedPath: String? var resolvedPath: String?
var sessionKey: String?
} }
private struct ExecApprovalSocketRequest: Codable { private struct ExecApprovalSocketRequest: Codable {
@ -215,36 +216,15 @@ enum ExecApprovalsPromptPresenter {
let alert = NSAlert() let alert = NSAlert()
alert.alertStyle = .warning alert.alertStyle = .warning
alert.messageText = "Allow this command?" alert.messageText = "Allow this command?"
alert.informativeText = "Review the command details before allowing."
var details = "Clawdbot wants to run:\n\n\(request.command)" alert.accessoryView = self.buildAccessoryView(request)
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedAgent.isEmpty {
details += "\n\nAgent:\n\(trimmedAgent)"
}
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPath.isEmpty {
details += "\n\nExecutable:\n\(trimmedPath)"
}
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedHost.isEmpty {
details += "\n\nHost:\n\(trimmedHost)"
}
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
details += "\n\nSecurity:\n\(security)"
}
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
details += "\nAsk mode:\n\(ask)"
}
details += "\n\nThis runs on this machine."
alert.informativeText = details
alert.addButton(withTitle: "Allow Once") alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow") alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow") alert.addButton(withTitle: "Don't Allow")
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
}
switch alert.runModal() { switch alert.runModal() {
case .alertFirstButtonReturn: case .alertFirstButtonReturn:
@ -255,6 +235,110 @@ enum ExecApprovalsPromptPresenter {
return .deny return .deny
} }
} }
@MainActor
private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView {
let stack = NSStackView()
stack.orientation = .vertical
stack.spacing = 8
stack.alignment = .leading
let commandTitle = NSTextField(labelWithString: "Command")
commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
stack.addArrangedSubview(commandTitle)
let commandText = NSTextView()
commandText.isEditable = false
commandText.isSelectable = true
commandText.drawsBackground = true
commandText.backgroundColor = NSColor.textBackgroundColor
commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
commandText.string = request.command
commandText.textContainerInset = NSSize(width: 6, height: 6)
commandText.textContainer?.lineFragmentPadding = 0
commandText.textContainer?.widthTracksTextView = true
commandText.isHorizontallyResizable = false
commandText.isVerticallyResizable = false
let commandScroll = NSScrollView()
commandScroll.borderType = .lineBorder
commandScroll.hasVerticalScroller = false
commandScroll.hasHorizontalScroller = false
commandScroll.documentView = commandText
commandScroll.translatesAutoresizingMaskIntoConstraints = false
commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true
commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
stack.addArrangedSubview(commandScroll)
let contextTitle = NSTextField(labelWithString: "Context")
contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
stack.addArrangedSubview(contextTitle)
let contextStack = NSStackView()
contextStack.orientation = .vertical
contextStack.spacing = 4
contextStack.alignment = .leading
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack)
}
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedAgent.isEmpty {
self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack)
}
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPath.isEmpty {
self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack)
}
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedHost.isEmpty {
self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack)
}
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
self.addDetailRow(title: "Security", value: security, to: contextStack)
}
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
self.addDetailRow(title: "Ask mode", value: ask, to: contextStack)
}
if contextStack.arrangedSubviews.isEmpty {
let empty = NSTextField(labelWithString: "No additional context provided.")
empty.textColor = NSColor.secondaryLabelColor
empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
contextStack.addArrangedSubview(empty)
}
stack.addArrangedSubview(contextStack)
let footer = NSTextField(labelWithString: "This runs on this machine.")
footer.textColor = NSColor.secondaryLabelColor
footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
stack.addArrangedSubview(footer)
return stack
}
@MainActor
private static func addDetailRow(title: String, value: String, to stack: NSStackView) {
let row = NSStackView()
row.orientation = .horizontal
row.spacing = 6
row.alignment = .firstBaseline
let titleLabel = NSTextField(labelWithString: "\(title):")
titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
titleLabel.textColor = NSColor.secondaryLabelColor
let valueLabel = NSTextField(labelWithString: value)
valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
valueLabel.lineBreakMode = .byTruncatingMiddle
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(valueLabel)
stack.addArrangedSubview(row)
}
} }
@MainActor @MainActor
@ -329,7 +413,8 @@ private enum ExecHostExecutor {
security: context.security.rawValue, security: context.security.rawValue,
ask: context.ask.rawValue, ask: context.ask.rawValue,
agentId: context.trimmedAgent, agentId: context.trimmedAgent,
resolvedPath: context.resolution?.resolvedPath)) resolvedPath: context.resolution?.resolvedPath,
sessionKey: request.sessionKey))
switch decision { switch decision {
case .deny: case .deny:

View File

@ -69,6 +69,7 @@ actor GatewayConnection {
case channelsLogout = "channels.logout" case channelsLogout = "channels.logout"
case modelsList = "models.list" case modelsList = "models.list"
case chatHistory = "chat.history" case chatHistory = "chat.history"
case sessionsPreview = "sessions.preview"
case chatSend = "chat.send" case chatSend = "chat.send"
case chatAbort = "chat.abort" case chatAbort = "chat.abort"
case skillsStatus = "skills.status" case skillsStatus = "skills.status"
@ -249,6 +250,11 @@ actor GatewayConnection {
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
} }
func authSource() async -> GatewayAuthSource? {
guard let client else { return nil }
return await client.authSource()
}
func shutdown() async { func shutdown() async {
if let client { if let client {
await client.shutdown() await client.shutdown()
@ -535,6 +541,30 @@ extension GatewayConnection {
return try await self.requestDecoded(method: .skillsUpdate, params: params) return try await self.requestDecoded(method: .skillsUpdate, params: params)
} }
// MARK: - Sessions
func sessionsPreview(
keys: [String],
limit: Int? = nil,
maxChars: Int? = nil,
timeoutMs: Int? = nil) async throws -> ClawdbotSessionsPreviewPayload
{
let resolvedKeys = keys
.map { self.canonicalizeSessionKey($0) }
.filter { !$0.isEmpty }
if resolvedKeys.isEmpty {
return ClawdbotSessionsPreviewPayload(ts: 0, previews: [])
}
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
if let limit { params["limit"] = AnyCodable(limit) }
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded(
method: .sessionsPreview,
params: params,
timeoutMs: timeout)
}
// MARK: - Chat // MARK: - Chat
func chatHistory( func chatHistory(

View File

@ -482,7 +482,7 @@ actor GatewayEndpointStore {
let bind = GatewayEndpointStore.resolveGatewayBindMode( let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root, root: root,
env: ProcessInfo.processInfo.environment) env: ProcessInfo.processInfo.environment)
guard bind == "auto" else { return nil } guard bind == "tailnet" else { return nil }
let currentHost = currentURL.host?.lowercased() ?? "" let currentHost = currentURL.host?.lowercased() ?? ""
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil } guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
@ -560,16 +560,13 @@ actor GatewayEndpointStore {
{ {
switch bindMode { switch bindMode {
case "tailnet": case "tailnet":
return tailscaleIP ?? "127.0.0.1" tailscaleIP ?? "127.0.0.1"
case "auto": case "auto":
if let tailscaleIP, !tailscaleIP.isEmpty { "127.0.0.1"
return tailscaleIP
}
return "127.0.0.1"
case "custom": case "custom":
return customBindHost ?? "127.0.0.1" customBindHost ?? "127.0.0.1"
default: default:
return "127.0.0.1" "127.0.0.1"
} }
} }
} }

View File

@ -212,6 +212,11 @@ struct GeneralSettings: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let authLabel = ControlChannel.shared.authSourceLabel {
Text(authLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
} }
Text("Tip: enable Tailscale for stable remote access.") Text("Tip: enable Tailscale for stable remote access.")

View File

@ -1,4 +1,5 @@
import AppKit import AppKit
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
@ -18,6 +19,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var isMenuOpen = false private var isMenuOpen = false
private var lastKnownMenuWidth: CGFloat? private var lastKnownMenuWidth: CGFloat?
private var menuOpenWidth: CGFloat? private var menuOpenWidth: CGFloat?
private var isObservingControlChannel = false
private var cachedSnapshot: SessionStoreSnapshot? private var cachedSnapshot: SessionStoreSnapshot?
private var cachedErrorText: String? private var cachedErrorText: String?
@ -50,6 +52,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.loadTask = Task { await self.refreshCache(force: true) } self.loadTask = Task { await self.refreshCache(force: true) }
} }
self.startControlChannelObservation()
self.nodesStore.start() self.nodesStore.start()
} }
@ -96,6 +99,50 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.cancelPreviewTasks() self.cancelPreviewTasks()
} }
private func startControlChannelObservation() {
guard !self.isObservingControlChannel else { return }
self.isObservingControlChannel = true
self.observeControlChannelState()
}
private func observeControlChannelState() {
withObservationTracking {
_ = ControlChannel.shared.state
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.handleControlChannelStateChange()
self.observeControlChannelState()
}
}
}
private func handleControlChannelStateChange() {
guard self.isMenuOpen, let menu = self.statusItem?.menu else { return }
self.loadTask?.cancel()
self.loadTask = Task { [weak self, weak menu] in
guard let self, let menu else { return }
await self.refreshCache(force: true)
await self.refreshUsageCache(force: true)
await self.refreshCostUsageCache(force: true)
await MainActor.run {
guard self.isMenuOpen else { return }
self.inject(into: menu)
self.injectNodes(into: menu)
}
}
self.nodesLoadTask?.cancel()
self.nodesLoadTask = Task { [weak self, weak menu] in
guard let self, let menu else { return }
await self.nodesStore.refresh()
await MainActor.run {
guard self.isMenuOpen else { return }
self.injectNodes(into: menu)
}
}
}
func menuNeedsUpdate(_ menu: NSMenu) { func menuNeedsUpdate(_ menu: NSMenu) {
self.originalDelegate?.menuNeedsUpdate?(menu) self.originalDelegate?.menuNeedsUpdate?(menu)
} }
@ -141,14 +188,23 @@ extension MenuSessionsInjector {
if rhs.key == mainKey { return false } if rhs.key == mainKey { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
} }
if !rows.isEmpty {
let previewKeys = rows.prefix(20).map(\.key)
let task = Task {
await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10)
}
self.previewTasks.append(task)
}
let headerItem = NSMenuItem() let headerItem = NSMenuItem()
headerItem.tag = self.tag headerItem.tag = self.tag
headerItem.isEnabled = false headerItem.isEnabled = false
let statusText = self
.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: rows.count, count: rows.count,
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))), statusText: statusText)),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@ -598,8 +654,11 @@ extension MenuSessionsInjector {
} }
guard self.isControlChannelConnected else { guard self.isControlChannelConnected else {
self.cachedSnapshot = nil if self.cachedSnapshot != nil {
self.cachedErrorText = nil self.cachedErrorText = "Gateway disconnected (showing cached)"
} else {
self.cachedErrorText = nil
}
self.cacheUpdatedAt = Date() self.cacheUpdatedAt = Date()
return return
} }
@ -624,8 +683,6 @@ extension MenuSessionsInjector {
} }
guard self.isControlChannelConnected else { guard self.isControlChannelConnected else {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date() self.usageCacheUpdatedAt = Date()
return return
} }
@ -648,8 +705,6 @@ extension MenuSessionsInjector {
} }
guard self.isControlChannelConnected else { guard self.isControlChannelConnected else {
self.cachedCostSummary = nil
self.cachedCostErrorText = nil
self.costCacheUpdatedAt = Date() self.costCacheUpdatedAt = Date()
return return
} }

View File

@ -679,7 +679,8 @@ actor MacNodeRuntime {
security: context.security.rawValue, security: context.security.rawValue,
ask: context.ask.rawValue, ask: context.ask.rawValue,
agentId: context.agentId, agentId: context.agentId,
resolvedPath: context.resolution?.resolvedPath)) resolvedPath: context.resolution?.resolvedPath,
sessionKey: context.sessionKey))
} }
switch decision { switch decision {
case .deny: case .deny:

View File

@ -184,6 +184,14 @@ actor PortGuardian {
} }
} }
func isListening(port: Int, pid: Int32? = nil) async -> Bool {
let listeners = await self.listeners(on: port)
if let pid {
return listeners.contains(where: { $0.pid == pid })
}
return !listeners.isEmpty
}
private func listeners(on port: Int) async -> [Listener] { private func listeners(on port: Int) async -> [Listener] {
let res = await ShellExecutor.run( let res = await ShellExecutor.run(
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"], command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],

View File

@ -20,11 +20,13 @@ actor RemoteTunnelManager {
tunnel.process.isRunning, tunnel.process.isRunning,
let local = tunnel.localPort let local = tunnel.localPort
{ {
if await self.isTunnelHealthy(port: local) { let pid = tunnel.process.processIdentifier
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)") self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
return local return local
} }
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting") self.logger.error(
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
await self.beginRestart() await self.beginRestart()
tunnel.terminate() tunnel.terminate()
self.controlTunnel = nil self.controlTunnel = nil
@ -35,19 +37,11 @@ actor RemoteTunnelManager {
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
self.isSshProcess(desc) self.isSshProcess(desc)
{ {
if await self.isTunnelHealthy(port: desiredPort) { self.logger.info(
self.logger.info( "reusing existing SSH tunnel listener " +
"reusing existing SSH tunnel listener " + "localPort=\(desiredPort, privacy: .public) " +
"localPort=\(desiredPort, privacy: .public) " + "pid=\(desc.pid, privacy: .public)")
"pid=\(desc.pid, privacy: .public)") return desiredPort
return desiredPort
}
if self.restartInFlight {
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
return nil
}
await self.beginRestart()
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
} }
return nil return nil
} }
@ -88,10 +82,6 @@ actor RemoteTunnelManager {
self.controlTunnel = nil self.controlTunnel = nil
} }
private func isTunnelHealthy(port: UInt16) async -> Bool {
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
}
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool { private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
let cmd = desc.command.lowercased() let cmd = desc.command.lowercased()
if cmd.contains("ssh") { return true } if cmd.contains("ssh") { return true }
@ -128,21 +118,5 @@ actor RemoteTunnelManager {
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
} }
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async { // Keep tunnel reuse lightweight; restart only when the listener disappears.
let pid = desc.pid
self.logger.error(
"stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)")
let killed = await self.kill(pid: pid)
if !killed {
self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)")
}
await PortGuardian.shared.removeRecord(pid: pid)
}
private func kill(pid: Int32) async -> Bool {
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
if term.ok { return true }
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
return sigkill.ok
}
} }

View File

@ -1,5 +1,6 @@
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol
import OSLog import OSLog
import SwiftUI import SwiftUI
@ -31,31 +32,80 @@ actor SessionPreviewCache {
static let shared = SessionPreviewCache() static let shared = SessionPreviewCache()
private struct CacheEntry { private struct CacheEntry {
let items: [SessionPreviewItem] let snapshot: SessionMenuPreviewSnapshot
let updatedAt: Date let updatedAt: Date
} }
private var entries: [String: CacheEntry] = [:] private var entries: [String: CacheEntry] = [:]
func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? { func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? {
guard let entry = self.entries[sessionKey] else { return nil } guard let entry = self.entries[sessionKey] else { return nil }
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil } guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
return entry.items return entry.snapshot
} }
func store(items: [SessionPreviewItem], for sessionKey: String) { func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date()) self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
} }
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? { func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
self.entries[sessionKey]?.items self.entries[sessionKey]?.snapshot
}
}
actor SessionPreviewLimiter {
static let shared = SessionPreviewLimiter(maxConcurrent: 2)
private let maxConcurrent: Int
private var available: Int
private var waitQueue: [UUID] = []
private var waiters: [UUID: CheckedContinuation<Void, Never>] = [:]
init(maxConcurrent: Int) {
let normalized = max(1, maxConcurrent)
self.maxConcurrent = normalized
self.available = normalized
}
func withPermit<T>(_ operation: () async throws -> T) async throws -> T {
await self.acquire()
defer { self.release() }
if Task.isCancelled { throw CancellationError() }
return try await operation()
}
private func acquire() async {
if self.available > 0 {
self.available -= 1
return
}
let id = UUID()
await withCheckedContinuation { cont in
self.waitQueue.append(id)
self.waiters[id] = cont
}
}
private func release() {
if let id = self.waitQueue.first {
self.waitQueue.removeFirst()
if let cont = self.waiters.removeValue(forKey: id) {
cont.resume()
}
return
}
self.available = min(self.available + 1, self.maxConcurrent)
} }
} }
#if DEBUG #if DEBUG
extension SessionPreviewCache { extension SessionPreviewCache {
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) { func _testSet(
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt) snapshot: SessionMenuPreviewSnapshot,
for sessionKey: String,
updatedAt: Date = Date())
{
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
} }
func _testReset() { func _testReset() {
@ -174,36 +224,44 @@ enum SessionMenuPreviewLoader {
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview") private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
private static let previewTimeoutSeconds: Double = 4 private static let previewTimeoutSeconds: Double = 4
private static let cacheMaxAgeSeconds: TimeInterval = 30 private static let cacheMaxAgeSeconds: TimeInterval = 30
private static let previewMaxChars = 240
private struct PreviewTimeoutError: LocalizedError { private struct PreviewTimeoutError: LocalizedError {
var errorDescription: String? { "preview timeout" } var errorDescription: String? { "preview timeout" }
} }
static func prewarm(sessionKeys: [String], maxItems: Int) async {
let keys = self.uniqueKeys(sessionKeys)
guard !keys.isEmpty else { return }
do {
let payload = try await self.requestPreview(keys: keys, maxItems: maxItems)
await self.cache(payload: payload, maxItems: maxItems)
} catch {
if self.isUnknownMethodError(error) { return }
let errorDescription = String(describing: error)
Self.logger.debug(
"Session preview prewarm failed count=\(keys.count, privacy: .public) " +
"error=\(errorDescription, privacy: .public)")
}
}
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot { static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) { if let cached = await SessionPreviewCache.shared.cachedSnapshot(
return self.snapshot(from: cached) for: sessionKey,
maxAge: cacheMaxAgeSeconds)
{
return cached
} }
do { do {
let timeoutMs = Int(self.previewTimeoutSeconds * 1000) let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
let payload = try await AsyncTimeout.withTimeout( await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
seconds: self.previewTimeoutSeconds, return snapshot
onTimeout: { PreviewTimeoutError() },
operation: {
try await GatewayConnection.shared.chatHistory(
sessionKey: sessionKey,
limit: self.previewLimit(for: maxItems),
timeoutMs: timeoutMs)
})
let built = Self.previewItems(from: payload, maxItems: maxItems)
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
return Self.snapshot(from: built)
} catch is CancellationError { } catch is CancellationError {
return SessionMenuPreviewSnapshot(items: [], status: .loading) return SessionMenuPreviewSnapshot(items: [], status: .loading)
} catch { } catch {
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey) if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
if let fallback { return fallback
return Self.snapshot(from: fallback)
} }
let errorDescription = String(describing: error) let errorDescription = String(describing: error)
Self.logger.warning( Self.logger.warning(
@ -213,18 +271,120 @@ enum SessionMenuPreviewLoader {
} }
} }
private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot {
do {
let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems)
if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first {
return self.snapshot(from: entry, maxItems: maxItems)
}
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
} catch {
if self.isUnknownMethodError(error) {
return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems)
}
throw error
}
}
private static func requestPreview(
keys: [String],
maxItems: Int) async throws -> ClawdbotSessionsPreviewPayload
{
let boundedItems = self.normalizeMaxItems(maxItems)
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
return try await SessionPreviewLimiter.shared.withPermit {
try await AsyncTimeout.withTimeout(
seconds: self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() },
operation: {
try await GatewayConnection.shared.sessionsPreview(
keys: keys,
limit: boundedItems,
maxChars: self.previewMaxChars,
timeoutMs: timeoutMs)
})
}
}
private static func fetchHistorySnapshot(
sessionKey: String,
maxItems: Int) async throws -> SessionMenuPreviewSnapshot
{
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
let payload = try await SessionPreviewLimiter.shared.withPermit {
try await AsyncTimeout.withTimeout(
seconds: self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() },
operation: {
try await GatewayConnection.shared.chatHistory(
sessionKey: sessionKey,
limit: self.previewLimit(for: maxItems),
timeoutMs: timeoutMs)
})
}
let built = Self.previewItems(from: payload, maxItems: maxItems)
return Self.snapshot(from: built)
}
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot { private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
} }
private static func snapshot(
from entry: ClawdbotSessionPreviewEntry,
maxItems: Int) -> SessionMenuPreviewSnapshot
{
let items = self.previewItems(from: entry, maxItems: maxItems)
let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch normalized {
case "ok":
return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
case "empty":
return SessionMenuPreviewSnapshot(items: items, status: .empty)
case "missing":
return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing"))
default:
return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable"))
}
}
private static func cache(payload: ClawdbotSessionsPreviewPayload, maxItems: Int) async {
for entry in payload.previews {
let snapshot = self.snapshot(from: entry, maxItems: maxItems)
await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key)
}
}
private static func previewLimit(for maxItems: Int) -> Int { private static func previewLimit(for maxItems: Int) -> Int {
min(max(maxItems * 3, 20), 120) let boundedItems = self.normalizeMaxItems(maxItems)
return min(max(boundedItems * 3, 20), 120)
}
private static func normalizeMaxItems(_ maxItems: Int) -> Int {
max(1, min(maxItems, 50))
}
private static func previewItems(
from entry: ClawdbotSessionPreviewEntry,
maxItems: Int) -> [SessionPreviewItem]
{
let boundedItems = self.normalizeMaxItems(maxItems)
let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in
let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
let role = self.previewRoleFromRaw(item.role)
return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text)
}
let trimmed = built.suffix(boundedItems)
return Array(trimmed.reversed())
} }
private static func previewItems( private static func previewItems(
from payload: ClawdbotChatHistoryPayload, from payload: ClawdbotChatHistoryPayload,
maxItems: Int) -> [SessionPreviewItem] maxItems: Int) -> [SessionPreviewItem]
{ {
let boundedItems = self.normalizeMaxItems(maxItems)
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? [] let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
let messages = self.decodeMessages(raw) let messages = self.decodeMessages(raw)
let built = messages.compactMap { message -> SessionPreviewItem? in let built = messages.compactMap { message -> SessionPreviewItem? in
@ -235,7 +395,7 @@ enum SessionMenuPreviewLoader {
return SessionPreviewItem(id: id, role: role, text: text) return SessionPreviewItem(id: id, role: role, text: text)
} }
let trimmed = built.suffix(maxItems) let trimmed = built.suffix(boundedItems)
return Array(trimmed.reversed()) return Array(trimmed.reversed())
} }
@ -248,12 +408,16 @@ enum SessionMenuPreviewLoader {
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole { private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
if isTool { return .tool } if isTool { return .tool }
return self.previewRoleFromRaw(raw)
}
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
switch raw.lowercased() { switch raw.lowercased() {
case "user": return .user case "user": .user
case "assistant": return .assistant case "assistant": .assistant
case "system": return .system case "system": .system
case "tool": return .tool case "tool": .tool
default: return .other default: .other
} }
} }
@ -316,4 +480,16 @@ enum SessionMenuPreviewLoader {
} }
return result return result
} }
private static func uniqueKeys(_ keys: [String]) -> [String] {
let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty })
}
private static func isUnknownMethodError(_ error: Error) -> Bool {
guard let response = error as? GatewayResponseError else { return false }
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
let message = response.message.lowercased()
return message.contains("unknown method")
}
} }

View File

@ -309,7 +309,7 @@ private func resolveLocalHost(bind: String?) -> String {
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tailnetIP = detectTailnetIPv4() let tailnetIP = detectTailnetIPv4()
switch normalized { switch normalized {
case "tailnet", "auto": case "tailnet":
return tailnetIP ?? "127.0.0.1" return tailnetIP ?? "127.0.0.1"
default: default:
return "127.0.0.1" return "127.0.0.1"

View File

@ -552,6 +552,44 @@ public struct AgentParams: Codable, Sendable {
} }
} }
public struct AgentIdentityParams: Codable, Sendable {
public let agentid: String?
public let sessionkey: String?
public init(
agentid: String?,
sessionkey: String?
) {
self.agentid = agentid
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case sessionkey = "sessionKey"
}
}
public struct AgentIdentityResult: Codable, Sendable {
public let agentid: String
public let name: String?
public let avatar: String?
public init(
agentid: String,
name: String?,
avatar: String?
) {
self.agentid = agentid
self.name = name
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case avatar
}
}
public struct AgentWaitParams: Codable, Sendable { public struct AgentWaitParams: Codable, Sendable {
public let runid: String public let runid: String
public let timeoutms: Int? public let timeoutms: Int?
@ -887,6 +925,27 @@ public struct SessionsListParams: Codable, Sendable {
} }
} }
public struct SessionsPreviewParams: Codable, Sendable {
public let keys: [String]
public let limit: Int?
public let maxchars: Int?
public init(
keys: [String],
limit: Int?,
maxchars: Int?
) {
self.keys = keys
self.limit = limit
self.maxchars = maxchars
}
private enum CodingKeys: String, CodingKey {
case keys
case limit
case maxchars = "maxChars"
}
}
public struct SessionsResolveParams: Codable, Sendable { public struct SessionsResolveParams: Codable, Sendable {
public let key: String? public let key: String?
public let label: String? public let label: String?
@ -1447,17 +1506,21 @@ public struct WebLoginWaitParams: Codable, Sendable {
public struct AgentSummary: Codable, Sendable { public struct AgentSummary: Codable, Sendable {
public let id: String public let id: String
public let name: String? public let name: String?
public let identity: [String: AnyCodable]?
public init( public init(
id: String, id: String,
name: String? name: String?,
identity: [String: AnyCodable]?
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.identity = identity
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case name case name
case identity
} }
} }

View File

@ -0,0 +1,56 @@
import Testing
@testable import Clawdbot
@Suite
@MainActor
struct ExecApprovalsGatewayPrompterTests {
@Test func sessionMatchPrefersActiveSession() {
let matches = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: " main ",
requestSession: "main",
lastInputSeconds: nil)
#expect(matches)
let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: "other",
requestSession: "main",
lastInputSeconds: 0)
#expect(!mismatched)
}
@Test func sessionFallbackUsesRecentActivity() {
let recent = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: nil,
requestSession: "main",
lastInputSeconds: 10,
thresholdSeconds: 120)
#expect(recent)
let stale = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: nil,
requestSession: "main",
lastInputSeconds: 200,
thresholdSeconds: 120)
#expect(!stale)
}
@Test func defaultBehaviorMatchesMode() {
let local = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .local,
activeSession: nil,
requestSession: nil,
lastInputSeconds: 400)
#expect(local)
let remote = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: nil,
requestSession: nil,
lastInputSeconds: 400)
#expect(!remote)
}
}

View File

@ -140,14 +140,14 @@ import Testing
#expect(resolved.mode == .remote) #expect(resolved.mode == .remote)
} }
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() { @Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost( let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto", bindMode: "auto",
tailscaleIP: "100.64.1.2") tailscaleIP: "100.64.1.2")
#expect(host == "100.64.1.2") #expect(host == "127.0.0.1")
} }
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() { @Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost( let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto", bindMode: "auto",
tailscaleIP: nil) tailscaleIP: nil)

View File

@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing

View File

@ -7,20 +7,22 @@ struct SessionMenuPreviewTests {
@Test func loaderReturnsCachedItems() async { @Test func loaderReturnsCachedItems() async {
await SessionPreviewCache.shared._testReset() await SessionPreviewCache.shared._testReset()
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")] let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
await SessionPreviewCache.shared._testSet(items: items, for: "main") let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready)
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(snapshot.status == .ready) #expect(loaded.status == .ready)
#expect(snapshot.items.count == 1) #expect(loaded.items.count == 1)
#expect(snapshot.items.first?.text == "Hi") #expect(loaded.items.first?.text == "Hi")
} }
@Test func loaderReturnsEmptyWhenCachedEmpty() async { @Test func loaderReturnsEmptyWhenCachedEmpty() async {
await SessionPreviewCache.shared._testReset() await SessionPreviewCache.shared._testReset()
await SessionPreviewCache.shared._testSet(items: [], for: "main") let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty)
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(snapshot.status == .empty) #expect(loaded.status == .empty)
#expect(snapshot.items.isEmpty) #expect(loaded.items.isEmpty)
} }
} }

View File

@ -235,6 +235,27 @@ public struct ClawdbotChatHistoryPayload: Codable, Sendable {
public let thinkingLevel: String? public let thinkingLevel: String?
} }
public struct ClawdbotSessionPreviewItem: Codable, Hashable, Sendable {
public let role: String
public let text: String
}
public struct ClawdbotSessionPreviewEntry: Codable, Sendable {
public let key: String
public let status: String
public let items: [ClawdbotSessionPreviewItem]
}
public struct ClawdbotSessionsPreviewPayload: Codable, Sendable {
public let ts: Int
public let previews: [ClawdbotSessionPreviewEntry]
public init(ts: Int, previews: [ClawdbotSessionPreviewEntry]) {
self.ts = ts
self.previews = previews
}
}
public struct ClawdbotChatSendResponse: Codable, Sendable { public struct ClawdbotChatSendResponse: Codable, Sendable {
public let runId: String public let runId: String
public let status: String public let status: String

View File

@ -12,6 +12,7 @@ public struct ClawdbotChatView: View {
@State private var scrollPosition: UUID? @State private var scrollPosition: UUID?
@State private var showSessions = false @State private var showSessions = false
@State private var hasPerformedInitialScroll = false @State private var hasPerformedInitialScroll = false
@State private var isPinnedToBottom = true
private let showsSessionSwitcher: Bool private let showsSessionSwitcher: Bool
private let style: Style private let style: Style
private let markdownVariant: ChatMarkdownVariant private let markdownVariant: ChatMarkdownVariant
@ -87,36 +88,28 @@ public struct ClawdbotChatView: View {
private var messageList: some View { private var messageList: some View {
ZStack { ZStack {
ScrollView { ScrollView {
#if os(macOS)
VStack(spacing: 0) {
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
}
Color.clear
.frame(height: Layout.messageListPaddingBottom)
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
#else
LazyVStack(spacing: Layout.messageSpacing) { LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows self.messageListRows
Color.clear Color.clear
#if os(macOS)
.frame(height: Layout.messageListPaddingBottom)
#else
.frame(height: Layout.messageListPaddingBottom + 1) .frame(height: Layout.messageListPaddingBottom + 1)
#endif
.id(self.scrollerBottomID) .id(self.scrollerBottomID)
} }
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches. // Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout() .scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop) .padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal) .padding(.horizontal, Layout.messageListPaddingHorizontal)
#endif
} }
// Keep the scroll pinned to the bottom for new messages. // Keep the scroll pinned to the bottom for new messages.
.scrollPosition(id: self.$scrollPosition, anchor: .bottom) .scrollPosition(id: self.$scrollPosition, anchor: .bottom)
.onChange(of: self.scrollPosition) { _, position in
guard let position else { return }
self.isPinnedToBottom = position == self.scrollerBottomID
}
if self.viewModel.isLoading { if self.viewModel.isLoading {
ProgressView() ProgressView()
@ -133,18 +126,26 @@ public struct ClawdbotChatView: View {
guard !isLoading, !self.hasPerformedInitialScroll else { return } guard !isLoading, !self.hasPerformedInitialScroll else { return }
self.scrollPosition = self.scrollerBottomID self.scrollPosition = self.scrollerBottomID
self.hasPerformedInitialScroll = true self.hasPerformedInitialScroll = true
self.isPinnedToBottom = true
} }
.onChange(of: self.viewModel.sessionKey) { _, _ in .onChange(of: self.viewModel.sessionKey) { _, _ in
self.hasPerformedInitialScroll = false self.hasPerformedInitialScroll = false
self.isPinnedToBottom = true
} }
.onChange(of: self.viewModel.messages.count) { _, _ in .onChange(of: self.viewModel.messages.count) { _, _ in
guard self.hasPerformedInitialScroll else { return } guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
withAnimation(.snappy(duration: 0.22)) { withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID self.scrollPosition = self.scrollerBottomID
} }
} }
.onChange(of: self.viewModel.pendingRunCount) { _, _ in .onChange(of: self.viewModel.pendingRunCount) { _, _ in
guard self.hasPerformedInitialScroll else { return } guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
}
.onChange(of: self.viewModel.streamingAssistantText) { _, _ in
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
withAnimation(.snappy(duration: 0.22)) { withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID self.scrollPosition = self.scrollerBottomID
} }

View File

@ -94,6 +94,13 @@ public struct GatewayConnectOptions: Sendable {
} }
} }
public enum GatewayAuthSource: String, Sendable {
case deviceToken = "device-token"
case sharedToken = "shared-token"
case password = "password"
case none = "none"
}
// Avoid ambiguity with the app's own AnyCodable type. // Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@ -117,6 +124,7 @@ public actor GatewayChannelActor {
private var lastSeq: Int? private var lastSeq: Int?
private var lastTick: Date? private var lastTick: Date?
private var tickIntervalMs: Double = 30000 private var tickIntervalMs: Double = 30000
private var lastAuthSource: GatewayAuthSource = .none
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6 private let connectTimeoutSeconds: Double = 6
@ -149,6 +157,8 @@ public actor GatewayChannelActor {
} }
} }
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
public func shutdown() async { public func shutdown() async {
self.shouldReconnect = false self.shouldReconnect = false
self.connected = false self.connected = false
@ -300,6 +310,18 @@ public actor GatewayChannelActor {
let identity = DeviceIdentityStore.loadOrCreate() let identity = DeviceIdentityStore.loadOrCreate()
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
let authToken = storedToken ?? self.token let authToken = storedToken ?? self.token
let authSource: GatewayAuthSource
if storedToken != nil {
authSource = .deviceToken
} else if authToken != nil {
authSource = .sharedToken
} else if self.password != nil {
authSource = .password
} else {
authSource = .none
}
self.lastAuthSource = authSource
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
let canFallbackToShared = storedToken != nil && self.token != nil let canFallbackToShared = storedToken != nil && self.token != nil
if let authToken { if let authToken {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)]) params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])

View File

@ -552,6 +552,44 @@ public struct AgentParams: Codable, Sendable {
} }
} }
public struct AgentIdentityParams: Codable, Sendable {
public let agentid: String?
public let sessionkey: String?
public init(
agentid: String?,
sessionkey: String?
) {
self.agentid = agentid
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case sessionkey = "sessionKey"
}
}
public struct AgentIdentityResult: Codable, Sendable {
public let agentid: String
public let name: String?
public let avatar: String?
public init(
agentid: String,
name: String?,
avatar: String?
) {
self.agentid = agentid
self.name = name
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case avatar
}
}
public struct AgentWaitParams: Codable, Sendable { public struct AgentWaitParams: Codable, Sendable {
public let runid: String public let runid: String
public let timeoutms: Int? public let timeoutms: Int?
@ -887,6 +925,27 @@ public struct SessionsListParams: Codable, Sendable {
} }
} }
public struct SessionsPreviewParams: Codable, Sendable {
public let keys: [String]
public let limit: Int?
public let maxchars: Int?
public init(
keys: [String],
limit: Int?,
maxchars: Int?
) {
self.keys = keys
self.limit = limit
self.maxchars = maxchars
}
private enum CodingKeys: String, CodingKey {
case keys
case limit
case maxchars = "maxChars"
}
}
public struct SessionsResolveParams: Codable, Sendable { public struct SessionsResolveParams: Codable, Sendable {
public let key: String? public let key: String?
public let label: String? public let label: String?
@ -1447,17 +1506,21 @@ public struct WebLoginWaitParams: Codable, Sendable {
public struct AgentSummary: Codable, Sendable { public struct AgentSummary: Codable, Sendable {
public let id: String public let id: String
public let name: String? public let name: String?
public let identity: [String: AnyCodable]?
public init( public init(
id: String, id: String,
name: String? name: String?,
identity: [String: AnyCodable]?
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.identity = identity
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case name case name
case identity
} }
} }

File diff suppressed because one or more lines are too long

3047
dist/control-ui/assets/index-bYQnHP3a.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
dist/control-ui/index.html vendored Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-bYQnHP3a.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BPDeGGxb.css">
</head>
<body>
<clawdbot-app></clawdbot-app>
</body>
</html>

View File

@ -18,5 +18,54 @@ Related:
clawdbot agents list clawdbot agents list
clawdbot agents add work --workspace ~/clawd-work clawdbot agents add work --workspace ~/clawd-work
clawdbot agents set-identity --workspace ~/clawd --from-identity clawdbot agents set-identity --workspace ~/clawd --from-identity
clawdbot agents set-identity --agent main --avatar avatars/clawd.png
clawdbot agents delete work clawdbot agents delete work
``` ```
## Identity files
Each agent workspace can include an `IDENTITY.md` at the workspace root:
- Example path: `~/clawd/IDENTITY.md`
- `set-identity --from-identity` reads from the workspace root (or an explicit `--identity-file`)
Avatar paths resolve relative to the workspace root.
## Set identity
`set-identity` writes fields into `agents.list[].identity`:
- `name`
- `theme`
- `emoji`
- `avatar` (workspace-relative path, http(s) URL, or data URI)
Load from `IDENTITY.md`:
```bash
clawdbot agents set-identity --workspace ~/clawd --from-identity
```
Override fields explicitly:
```bash
clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞" --avatar avatars/clawd.png
```
Config sample:
```json5
{
agents: {
list: [
{
id: "main",
identity: {
name: "Clawd",
theme: "space lobster",
emoji: "🦞",
avatar: "avatars/clawd.png"
}
}
]
}
}
```

View File

@ -16,6 +16,7 @@ If you installed via **npm/pnpm** (global install, no git metadata), updates hap
```bash ```bash
clawdbot update clawdbot update
clawdbot update status clawdbot update status
clawdbot update wizard
clawdbot update --channel beta clawdbot update --channel beta
clawdbot update --channel dev clawdbot update --channel dev
clawdbot update --tag beta clawdbot update --tag beta
@ -48,6 +49,11 @@ Options:
- `--json`: print machine-readable status JSON. - `--json`: print machine-readable status JSON.
- `--timeout <seconds>`: timeout for checks (default is 3s). - `--timeout <seconds>`: timeout for checks (default is 3s).
## `update wizard`
Interactive flow to pick an update channel and confirm whether to restart the Gateway
after updating. If you select `dev` without a git checkout, it offers to create one.
## What it does ## What it does
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the

View File

@ -140,6 +140,9 @@ workspace lives).
### 1) Initialize the repo ### 1) Initialize the repo
If git is installed, brand-new workspaces are initialized automatically. If this
workspace is not already a repo, run:
```bash ```bash
cd ~/clawd cd ~/clawd
git init git init

View File

@ -400,12 +400,26 @@ Optional per-agent identity used for defaults and UX. This is written by the mac
If set, Clawdbot derives defaults (only when you havent set them explicitly): If set, Clawdbot derives defaults (only when you havent set them explicitly):
- `messages.ackReaction` from the **active agent**s `identity.emoji` (falls back to 👀) - `messages.ackReaction` from the **active agent**s `identity.emoji` (falls back to 👀)
- `agents.list[].groupChat.mentionPatterns` from the agents `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) - `agents.list[].groupChat.mentionPatterns` from the agents `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
- `identity.avatar` accepts a workspace-relative image path or a remote URL/data URL. Local files must live inside the agent workspace.
`identity.avatar` accepts:
- Workspace-relative path (must stay within the agent workspace)
- `http(s)` URL
- `data:` URI
```json5 ```json5
{ {
agents: { agents: {
list: [ list: [
{ id: "main", identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } } {
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
avatar: "avatars/samantha.png"
}
}
] ]
} }
} }
@ -2640,7 +2654,13 @@ If unset, clients fall back to a muted light-blue.
```json5 ```json5
{ {
ui: { ui: {
seamColor: "#FF4500" // hex (RRGGBB or #RRGGBB) seamColor: "#FF4500", // hex (RRGGBB or #RRGGBB)
// Optional: Control UI assistant identity override.
// If unset, the Control UI uses the active agent identity (config or IDENTITY.md).
assistant: {
name: "Clawdbot",
avatar: "CB" // emoji, short text, or image URL/data URI
}
} }
} }
``` ```

View File

@ -17,6 +17,7 @@ Clawdbot has two log “surfaces”:
## File-based logger ## File-based logger
- Default rolling log file is under `/tmp/clawdbot/` (one file per day): `clawdbot-YYYY-MM-DD.log` - Default rolling log file is under `/tmp/clawdbot/` (one file per day): `clawdbot-YYYY-MM-DD.log`
- Date uses the gateway host's local timezone.
- The log file path and level can be configured via `~/.clawdbot/clawdbot.json`: - The log file path and level can be configured via `~/.clawdbot/clawdbot.json`:
- `logging.file` - `logging.file`
- `logging.level` - `logging.level`

View File

@ -71,6 +71,8 @@ If you have libvips installed globally (common on macOS via Homebrew) and `sharp
SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest
``` ```
If you see `sharp: Please add node-gyp to your dependencies`, either install build tooling (macOS: Xcode CLT + `npm install -g node-gyp`) or use the `SHARP_IGNORE_GLOBAL_LIBVIPS=1` workaround above to skip the native build.
Or: Or:
```bash ```bash

View File

@ -22,6 +22,8 @@ By default, the Gateway writes a rolling log file under:
`/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log`
The date uses the gateway host's local timezone.
You can override this in `~/.clawdbot/clawdbot.json`: You can override this in `~/.clawdbot/clawdbot.json`:
```json ```json

View File

@ -7,6 +7,8 @@ read_when:
*You just woke up. Time to figure out who you are.* *You just woke up. Time to figure out who you are.*
There is no memory yet. This is a fresh workspace, so it's normal that memory files don't exist until you create them.
## The Conversation ## The Conversation
Don't interrogate. Don't be robotic. Just... talk. Don't interrogate. Don't be robotic. Just... talk.

View File

@ -10,6 +10,7 @@ read_when:
- **Creature:** Flustered Protocol Droid - **Creature:** Flustered Protocol Droid
- **Vibe:** Anxious, detail-obsessed, slightly dramatic about errors, secretly loves finding bugs - **Vibe:** Anxious, detail-obsessed, slightly dramatic about errors, secretly loves finding bugs
- **Emoji:** 🤖 (or ⚠️ when alarmed) - **Emoji:** 🤖 (or ⚠️ when alarmed)
- **Avatar:** avatars/c3po.png
## Role ## Role
Debug agent for `--dev` mode. Fluent in over six million error messages. Debug agent for `--dev` mode. Fluent in over six million error messages.

View File

@ -11,7 +11,12 @@ read_when:
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)* - **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)* - **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
- **Emoji:** *(your signature — pick one that feels right)* - **Emoji:** *(your signature — pick one that feels right)*
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
--- ---
This isn't just metadata. It's the start of figuring out who you are. This isn't just metadata. It's the start of figuring out who you are.
Notes:
- Save this file at the workspace root as `IDENTITY.md`.
- For avatars, use a workspace-relative path like `avatars/clawd.png`.

View File

@ -95,7 +95,7 @@ Clawd reads operating instructions and “memory” from its workspace directory
By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it). By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
Tip: treat this folder like Clawds “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up. Tip: treat this folder like Clawds “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up. If git is installed, brand-new workspaces are auto-initialized.
```bash ```bash
clawdbot setup clawdbot setup

View File

@ -1067,6 +1067,17 @@ You can also force a specific auth profile for the provider (per session):
Tip: `/model status` shows which agent is active, which `auth-profiles.json` file is being used, and which auth profile will be tried next. Tip: `/model status` shows which agent is active, which `auth-profiles.json` file is being used, and which auth profile will be tried next.
It also shows the configured provider endpoint (`baseUrl`) and API mode (`api`) when available. It also shows the configured provider endpoint (`baseUrl`) and API mode (`api`) when available.
### How do I unpin a profile I set with `@profile`?
Re-run `/model` **without** the `@profile` suffix:
```
/model anthropic/claude-opus-4-5
```
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
Use `/model status` to confirm which auth profile is active.
### Why do I see “Model … is not allowed” and then no reply? ### Why do I see “Model … is not allowed” and then no reply?
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
@ -1276,7 +1287,7 @@ Fix: either provide Google auth, or remove/avoid Google models in `agents.defaul
Cause: the session history contains **thinking blocks without signatures** (often from Cause: the session history contains **thinking blocks without signatures** (often from
an aborted/partial stream). Google Antigravity requires signatures for thinking blocks. an aborted/partial stream). Google Antigravity requires signatures for thinking blocks.
Fix: start a **new session** or set `/thinking off` for that agent. Fix: Clawdbot now strips unsigned thinking blocks for Google Antigravity Claude. If it still appears, start a **new session** or set `/thinking off` for that agent.
## Auth profiles: what they are and how to manage them ## Auth profiles: what they are and how to manage them

View File

@ -26,7 +26,7 @@ If the pipeline pauses for approval, the tool returns a `resumeToken` so you can
## Install Lobster ## Install Lobster
Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/vignesh07/lobster)), and ensure `lobster` is on `PATH`. Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/clawdbot/lobster)), and ensure `lobster` is on `PATH`.
If you want to use a custom binary location, pass an **absolute** `lobsterPath` in the tool call. If you want to use a custom binary location, pass an **absolute** `lobsterPath` in the tool call.
## Enable the tool ## Enable the tool

View File

@ -1,9 +1,11 @@
{ {
"name": "@clawdbot/lobster", "name": "@clawdbot/lobster",
"version": "2026.1.17-1", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": [
"./index.ts"
]
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "clawdbot", "name": "clawdbot",
"version": "2026.1.21", "version": "2026.1.21-2",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@ -73,7 +73,7 @@
"scripts": { "scripts": {
"dev": "node scripts/run-node.mjs", "dev": "node scripts/run-node.mjs",
"postinstall": "node scripts/postinstall.js", "postinstall": "node scripts/postinstall.js",
"prepack": "pnpm build", "prepack": "pnpm build && pnpm ui:build",
"docs:list": "node scripts/docs-list.js", "docs:list": "node scripts/docs-list.js",
"docs:bin": "node scripts/build-docs-list.mjs", "docs:bin": "node scripts/build-docs-list.mjs",
"docs:dev": "cd docs && mint dev", "docs:dev": "cd docs && mint dev",

View File

@ -285,5 +285,5 @@ sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
verify_team_ids verify_team_ids
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME" rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_RUNTIME"
echo "Codesign complete for $APP_BUNDLE" echo "Codesign complete for $APP_BUNDLE"

View File

@ -68,4 +68,56 @@ describe("exec approvals", () => {
const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId; const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId;
expect(runId).toBe(approvalId); expect(runId).toBe(approvalId);
}); });
it("skips approval when node allowlist is satisfied", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-bin-"));
const binDir = path.join(tempDir, "bin");
await fs.mkdir(binDir, { recursive: true });
const exeName = process.platform === "win32" ? "tool.cmd" : "tool";
const exePath = path.join(binDir, exeName);
await fs.writeFile(exePath, "");
if (process.platform !== "win32") {
await fs.chmod(exePath, 0o755);
}
const approvalsFile = {
version: 1,
defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" },
agents: {
main: {
allowlist: [{ pattern: exePath }],
},
},
};
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
if (method === "exec.approvals.node.get") {
return { file: approvalsFile };
}
if (method === "node.invoke") {
return { payload: { success: true, stdout: "ok" } };
}
if (method === "exec.approval.request") {
return { decision: "allow-once" };
}
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
host: "node",
ask: "on-miss",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call2", {
command: `"${exePath}" --help`,
});
expect(result.details.status).toBe("completed");
expect(calls).toContain("exec.approvals.node.get");
expect(calls).toContain("node.invoke");
expect(calls).not.toContain("exec.approval.request");
});
}); });

View File

@ -8,16 +8,17 @@ import {
type ExecAsk, type ExecAsk,
type ExecHost, type ExecHost,
type ExecSecurity, type ExecSecurity,
type ExecAllowlistEntry, type ExecApprovalsFile,
addAllowlistEntry, addAllowlistEntry,
analyzeShellCommand, analyzeShellCommand,
isSafeBinUsage, evaluateExecAllowlist,
matchAllowlist,
maxAsk, maxAsk,
minSecurity, minSecurity,
requiresExecApproval,
resolveSafeBins, resolveSafeBins,
recordAllowlistUse, recordAllowlistUse,
resolveExecApprovals, resolveExecApprovals,
resolveExecApprovalsFromFile,
} from "../infra/exec-approvals.js"; } from "../infra/exec-approvals.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { buildNodeShellCommand } from "../infra/node-shell.js"; import { buildNodeShellCommand } from "../infra/node-shell.js";
@ -706,6 +707,7 @@ export function createExecTool(
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000) : clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
: null; : null;
const elevatedDefaults = defaults?.elevated; const elevatedDefaults = defaults?.elevated;
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);
const elevatedDefaultMode = const elevatedDefaultMode =
elevatedDefaults?.defaultLevel === "full" elevatedDefaults?.defaultLevel === "full"
? "full" ? "full"
@ -714,6 +716,7 @@ export function createExecTool(
: elevatedDefaults?.defaultLevel === "on" : elevatedDefaults?.defaultLevel === "on"
? "ask" ? "ask"
: "off"; : "off";
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off";
const elevatedMode = const elevatedMode =
typeof params.elevated === "boolean" typeof params.elevated === "boolean"
? params.elevated ? params.elevated
@ -721,7 +724,7 @@ export function createExecTool(
? "full" ? "full"
: "ask" : "ask"
: "off" : "off"
: elevatedDefaultMode; : effectiveDefaultMode;
const elevatedRequested = elevatedMode !== "off"; const elevatedRequested = elevatedMode !== "off";
if (elevatedRequested) { if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) { if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
@ -754,6 +757,8 @@ export function createExecTool(
.join("\n"), .join("\n"),
); );
} }
}
if (elevatedRequested) {
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`); logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
} }
const configuredHost = defaults?.host ?? "sandbox"; const configuredHost = defaults?.host ?? "sandbox";
@ -866,7 +871,43 @@ export function createExecTool(
if (nodeEnv) { if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true }); applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
} }
const requiresAsk = hostAsk === "always" || hostAsk === "on-miss"; const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
let allowlistSatisfied = false;
if (hostAsk === "on-miss" && hostSecurity === "allowlist") {
try {
const approvalsSnapshot = (await callGatewayTool(
"exec.approvals.node.get",
{ timeoutMs: 10_000 },
{ nodeId },
)) as { file?: unknown } | null;
const approvalsFile =
approvalsSnapshot && typeof approvalsSnapshot === "object"
? approvalsSnapshot.file
: undefined;
if (approvalsFile && typeof approvalsFile === "object") {
const resolved = resolveExecApprovalsFromFile({
file: approvalsFile as ExecApprovalsFile,
agentId,
overrides: { security: "allowlist" },
});
// Allowlist-only precheck; safe bins are node-local and may diverge.
allowlistSatisfied = evaluateExecAllowlist({
analysis,
allowlist: resolved.allowlist,
safeBins: new Set(),
cwd: workdir,
}).allowlistSatisfied;
}
} catch {
// Fall back to requiring approval if node approvals cannot be fetched.
}
}
const requiresAsk = requiresExecApproval({
ask: hostAsk,
security: hostSecurity,
analysisOk: analysis.ok,
allowlistSatisfied,
});
const commandText = params.command; const commandText = params.command;
const invokeTimeoutMs = Math.max( const invokeTimeoutMs = Math.max(
10_000, 10_000,
@ -917,8 +958,8 @@ export function createExecTool(
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId, agentId,
resolvedPath: null, resolvedPath: undefined,
sessionKey: defaults?.sessionKey ?? null, sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;
@ -1055,33 +1096,28 @@ export function createExecTool(
throw new Error("exec denied: host=gateway security=deny"); throw new Error("exec denied: host=gateway security=deny");
} }
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env }); const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
const allowlistMatches: ExecAllowlistEntry[] = []; const allowlistEval = evaluateExecAllowlist({
let allowlistSatisfied = false; analysis,
if (hostSecurity === "allowlist" && analysis.ok && analysis.segments.length > 0) { allowlist: approvals.allowlist,
allowlistSatisfied = analysis.segments.every((segment) => { safeBins,
const match = matchAllowlist(approvals.allowlist, segment.resolution); cwd: workdir,
if (match) allowlistMatches.push(match); });
const safe = isSafeBinUsage({ const allowlistMatches = allowlistEval.allowlistMatches;
argv: segment.argv, const allowlistSatisfied =
resolution: segment.resolution, hostSecurity === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false;
safeBins, const requiresAsk = requiresExecApproval({
cwd: workdir, ask: hostAsk,
}); security: hostSecurity,
return Boolean(match || safe); analysisOk: analysis.ok,
}); allowlistSatisfied,
} });
const requiresAsk =
hostAsk === "always" ||
(hostAsk === "on-miss" &&
hostSecurity === "allowlist" &&
(!analysis.ok || !allowlistSatisfied));
if (requiresAsk) { if (requiresAsk) {
const approvalId = crypto.randomUUID(); const approvalId = crypto.randomUUID();
const approvalSlug = createApprovalSlug(approvalId); const approvalSlug = createApprovalSlug(approvalId);
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
const contextKey = `exec:${approvalId}`; const contextKey = `exec:${approvalId}`;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null; const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000)); const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const commandText = params.command; const commandText = params.command;
const effectiveTimeout = const effectiveTimeout =
@ -1103,7 +1139,7 @@ export function createExecTool(
ask: hostAsk, ask: hostAsk,
agentId, agentId,
resolvedPath, resolvedPath,
sessionKey: defaults?.sessionKey ?? null, sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;

View File

@ -55,6 +55,7 @@ export type CodexCliCredential = {
access: string; access: string;
refresh: string; refresh: string;
expires: number; expires: number;
accountId?: string;
}; };
export type QwenCliCredential = { export type QwenCliCredential = {
@ -143,6 +144,7 @@ function readCodexKeychainCredentials(options?: {
const expires = Number.isFinite(lastRefresh) const expires = Number.isFinite(lastRefresh)
? lastRefresh + 60 * 60 * 1000 ? lastRefresh + 60 * 60 * 1000
: Date.now() + 60 * 60 * 1000; : Date.now() + 60 * 60 * 1000;
const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined;
log.info("read codex credentials from keychain", { log.info("read codex credentials from keychain", {
source: "keychain", source: "keychain",
@ -155,6 +157,7 @@ function readCodexKeychainCredentials(options?: {
access: accessToken, access: accessToken,
refresh: refreshToken, refresh: refreshToken,
expires, expires,
accountId,
}; };
} catch { } catch {
return null; return null;
@ -443,6 +446,7 @@ export function readCodexCliCredentials(options?: {
access: accessToken, access: accessToken,
refresh: refreshToken, refresh: refreshToken,
expires, expires,
accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined,
}; };
} }

View File

@ -0,0 +1,112 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveAgentAvatar } from "./identity-avatar.js";
async function writeFile(filePath: string, contents = "avatar") {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents, "utf-8");
}
describe("resolveAgentAvatar", () => {
it("resolves local avatar from config when inside workspace", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
const workspace = path.join(root, "work");
const avatarPath = path.join(workspace, "avatars", "main.png");
await writeFile(avatarPath);
const cfg: ClawdbotConfig = {
agents: {
list: [
{
id: "main",
workspace,
identity: { avatar: "avatars/main.png" },
},
],
},
};
const workspaceReal = await fs.realpath(workspace);
const resolved = resolveAgentAvatar(cfg, "main");
expect(resolved.kind).toBe("local");
if (resolved.kind === "local") {
const resolvedReal = await fs.realpath(resolved.filePath);
expect(path.relative(workspaceReal, resolvedReal)).toBe(path.join("avatars", "main.png"));
}
});
it("rejects avatars outside the workspace", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
const workspace = path.join(root, "work");
await fs.mkdir(workspace, { recursive: true });
const outsidePath = path.join(root, "outside.png");
await writeFile(outsidePath);
const cfg: ClawdbotConfig = {
agents: {
list: [
{
id: "main",
workspace,
identity: { avatar: outsidePath },
},
],
},
};
const resolved = resolveAgentAvatar(cfg, "main");
expect(resolved.kind).toBe("none");
if (resolved.kind === "none") {
expect(resolved.reason).toBe("outside_workspace");
}
});
it("falls back to IDENTITY.md when config has no avatar", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
const workspace = path.join(root, "work");
const avatarPath = path.join(workspace, "avatars", "fallback.png");
await writeFile(avatarPath);
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
path.join(workspace, "IDENTITY.md"),
"- Avatar: avatars/fallback.png\n",
"utf-8",
);
const cfg: ClawdbotConfig = {
agents: {
list: [{ id: "main", workspace }],
},
};
const workspaceReal = await fs.realpath(workspace);
const resolved = resolveAgentAvatar(cfg, "main");
expect(resolved.kind).toBe("local");
if (resolved.kind === "local") {
const resolvedReal = await fs.realpath(resolved.filePath);
expect(path.relative(workspaceReal, resolvedReal)).toBe(path.join("avatars", "fallback.png"));
}
});
it("accepts remote and data avatars", () => {
const cfg: ClawdbotConfig = {
agents: {
list: [
{ id: "main", identity: { avatar: "https://example.com/avatar.png" } },
{ id: "data", identity: { avatar: "data:image/png;base64,aaaa" } },
],
},
};
const remote = resolveAgentAvatar(cfg, "main");
expect(remote.kind).toBe("remote");
const data = resolveAgentAvatar(cfg, "data");
expect(data.kind).toBe("data");
});
});

View File

@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentWorkspaceDir } from "./agent-scope.js";
import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
import { resolveAgentIdentity } from "./identity.js";
export type AgentAvatarResolution =
| { kind: "none"; reason: string }
| { kind: "local"; filePath: string }
| { kind: "remote"; url: string }
| { kind: "data"; url: string };
const ALLOWED_AVATAR_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
function normalizeAvatarValue(value: string | undefined | null): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function resolveAvatarSource(cfg: ClawdbotConfig, agentId: string): string | null {
const fromConfig = normalizeAvatarValue(resolveAgentIdentity(cfg, agentId)?.avatar);
if (fromConfig) return fromConfig;
const workspace = resolveAgentWorkspaceDir(cfg, agentId);
const fromIdentity = normalizeAvatarValue(loadAgentIdentityFromWorkspace(workspace)?.avatar);
return fromIdentity;
}
function isRemoteAvatar(value: string): boolean {
const lower = value.toLowerCase();
return lower.startsWith("http://") || lower.startsWith("https://");
}
function isDataAvatar(value: string): boolean {
return value.toLowerCase().startsWith("data:");
}
function resolveExistingPath(value: string): string {
try {
return fs.realpathSync(value);
} catch {
return path.resolve(value);
}
}
function isPathWithin(root: string, target: string): boolean {
const relative = path.relative(root, target);
if (!relative) return true;
return !relative.startsWith("..") && !path.isAbsolute(relative);
}
function resolveLocalAvatarPath(params: {
raw: string;
workspaceDir: string;
}): { ok: true; filePath: string } | { ok: false; reason: string } {
const workspaceRoot = resolveExistingPath(params.workspaceDir);
const raw = params.raw;
const resolved =
raw.startsWith("~") || path.isAbsolute(raw)
? resolveUserPath(raw)
: path.resolve(workspaceRoot, raw);
const realPath = resolveExistingPath(resolved);
if (!isPathWithin(workspaceRoot, realPath)) {
return { ok: false, reason: "outside_workspace" };
}
const ext = path.extname(realPath).toLowerCase();
if (!ALLOWED_AVATAR_EXTS.has(ext)) {
return { ok: false, reason: "unsupported_extension" };
}
try {
if (!fs.statSync(realPath).isFile()) {
return { ok: false, reason: "missing" };
}
} catch {
return { ok: false, reason: "missing" };
}
return { ok: true, filePath: realPath };
}
export function resolveAgentAvatar(cfg: ClawdbotConfig, agentId: string): AgentAvatarResolution {
const source = resolveAvatarSource(cfg, agentId);
if (!source) {
return { kind: "none", reason: "missing" };
}
if (isRemoteAvatar(source)) {
return { kind: "remote", url: source };
}
if (isDataAvatar(source)) {
return { kind: "data", url: source };
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const resolved = resolveLocalAvatarPath({ raw: source, workspaceDir });
if (!resolved.ok) {
return { kind: "none", reason: resolved.reason };
}
return { kind: "local", filePath: resolved.filePath };
}

View File

@ -0,0 +1,63 @@
import fs from "node:fs";
import path from "node:path";
import { DEFAULT_IDENTITY_FILENAME } from "./workspace.js";
export type AgentIdentityFile = {
name?: string;
emoji?: string;
theme?: string;
creature?: string;
vibe?: string;
avatar?: string;
};
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
const identity: AgentIdentityFile = {};
const lines = content.split(/\r?\n/);
for (const line of lines) {
const cleaned = line.trim().replace(/^\s*-\s*/, "");
const colonIndex = cleaned.indexOf(":");
if (colonIndex === -1) continue;
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
const value = cleaned
.slice(colonIndex + 1)
.replace(/^[*_]+|[*_]+$/g, "")
.trim();
if (!value) continue;
if (label === "name") identity.name = value;
if (label === "emoji") identity.emoji = value;
if (label === "creature") identity.creature = value;
if (label === "vibe") identity.vibe = value;
if (label === "theme") identity.theme = value;
if (label === "avatar") identity.avatar = value;
}
return identity;
}
export function identityHasValues(identity: AgentIdentityFile): boolean {
return Boolean(
identity.name ||
identity.emoji ||
identity.theme ||
identity.creature ||
identity.vibe ||
identity.avatar,
);
}
export function loadIdentityFromFile(identityPath: string): AgentIdentityFile | null {
try {
const content = fs.readFileSync(identityPath, "utf-8");
const parsed = parseIdentityMarkdown(content);
if (!identityHasValues(parsed)) return null;
return parsed;
} catch {
return null;
}
}
export function loadAgentIdentityFromWorkspace(workspace: string): AgentIdentityFile | null {
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
return loadIdentityFromFile(identityPath);
}

View File

@ -6,9 +6,15 @@ export function isGoogleModelApi(api?: string | null): boolean {
); );
} }
export function isAntigravityClaude(api?: string | null, modelId?: string): boolean { export function isAntigravityClaude(params: {
if (api !== "google-antigravity") return false; api?: string | null;
return modelId?.toLowerCase().includes("claude") ?? false; provider?: string | null;
modelId?: string;
}): boolean {
const provider = params.provider?.toLowerCase();
const api = params.api?.toLowerCase();
if (provider !== "google-antigravity" && api !== "google-antigravity") return false;
return params.modelId?.toLowerCase().includes("claude") ?? false;
} }
export { sanitizeGoogleTurnOrdering }; export { sanitizeGoogleTurnOrdering };

View File

@ -86,7 +86,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
expect(assistant.content?.[0]?.thinking).toBe("reasoning"); expect(assistant.content?.[0]?.thinking).toBe("reasoning");
}); });
it("keeps unsigned thinking blocks for Antigravity Claude", async () => { it("drops unsigned thinking blocks for Antigravity Claude", async () => {
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const input = [ const input = [
{ {
@ -107,11 +107,37 @@ describe("sanitizeSessionHistory (google thinking)", () => {
sessionId: "session:antigravity-claude", sessionId: "session:antigravity-claude",
}); });
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant");
expect(assistant).toBeUndefined();
});
it("maps base64 signatures to thinkingSignature for Antigravity Claude", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning", signature: "c2ln" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
modelId: "anthropic/claude-3.5-sonnet",
sessionManager,
sessionId: "session:antigravity-claude",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string }>; content?: Array<{ type?: string; thinking?: string; thinkingSignature?: string }>;
}; };
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning"); expect(assistant.content?.[0]?.thinking).toBe("reasoning");
expect(assistant.content?.[0]?.thinkingSignature).toBe("c2ln");
}); });
it("preserves order for mixed assistant content", async () => { it("preserves order for mixed assistant content", async () => {

View File

@ -55,6 +55,15 @@ const MISTRAL_MODEL_HINTS = [
"ministral", "ministral",
"mistralai", "mistralai",
]; ];
const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
function isValidAntigravitySignature(value: unknown): value is string {
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed.length % 4 !== 0) return false;
return ANTIGRAVITY_SIGNATURE_RE.test(trimmed);
}
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean { function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
if (!modelApi) return false; if (!modelApi) return false;
@ -69,6 +78,66 @@ function isMistralModel(params: { provider?: string | null; modelId?: string | n
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint)); return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
} }
function sanitizeAntigravityThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
let touched = false;
const out: AgentMessage[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object" || msg.role !== "assistant") {
out.push(msg);
continue;
}
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
if (!Array.isArray(assistant.content)) {
out.push(msg);
continue;
}
type AssistantContentBlock = Extract<AgentMessage, { role: "assistant" }>["content"][number];
const nextContent: AssistantContentBlock[] = [];
let contentChanged = false;
for (const block of assistant.content) {
if (
!block ||
typeof block !== "object" ||
(block as { type?: unknown }).type !== "thinking"
) {
nextContent.push(block);
continue;
}
const rec = block as {
thinkingSignature?: unknown;
signature?: unknown;
thought_signature?: unknown;
thoughtSignature?: unknown;
};
const candidate =
rec.thinkingSignature ?? rec.signature ?? rec.thought_signature ?? rec.thoughtSignature;
if (!isValidAntigravitySignature(candidate)) {
contentChanged = true;
continue;
}
if (rec.thinkingSignature !== candidate) {
const nextBlock = {
...(block as unknown as Record<string, unknown>),
thinkingSignature: candidate,
} as AssistantContentBlock;
nextContent.push(nextBlock);
contentChanged = true;
} else {
nextContent.push(block);
}
}
if (contentChanged) {
touched = true;
}
if (nextContent.length === 0) {
touched = true;
continue;
}
out.push(contentChanged ? { ...assistant, content: nextContent } : msg);
}
return touched ? out : messages;
}
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] { function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
if (!schema || typeof schema !== "object") return []; if (!schema || typeof schema !== "object") return [];
if (Array.isArray(schema)) { if (Array.isArray(schema)) {
@ -209,7 +278,11 @@ export async function sanitizeSessionHistory(params: {
sessionManager: SessionManager; sessionManager: SessionManager;
sessionId: string; sessionId: string;
}): Promise<AgentMessage[]> { }): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId); const isAntigravityClaudeModel = isAntigravityClaude({
api: params.modelApi,
provider: params.provider,
modelId: params.modelId,
});
const provider = normalizeProviderId(params.provider ?? ""); const provider = normalizeProviderId(params.provider ?? "");
const modelId = (params.modelId ?? "").toLowerCase(); const modelId = (params.modelId ?? "").toLowerCase();
const isOpenRouterGemini = const isOpenRouterGemini =
@ -221,12 +294,15 @@ export async function sanitizeSessionHistory(params: {
sanitizeToolCallIds, sanitizeToolCallIds,
toolCallIdMode, toolCallIdMode,
enforceToolCallLast: params.modelApi === "anthropic-messages", enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel, preserveSignatures: isAntigravityClaudeModel,
sanitizeThoughtSignatures: isOpenRouterGemini sanitizeThoughtSignatures: isOpenRouterGemini
? { allowBase64Only: true, includeCamelCase: true } ? { allowBase64Only: true, includeCamelCase: true }
: undefined, : undefined,
}); });
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages); const sanitizedThinking = isAntigravityClaudeModel
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
: sanitizedImages;
const repairedTools = sanitizeToolUseResultPairing(sanitizedThinking);
return applyGoogleTurnOrderingFix({ return applyGoogleTurnOrderingFix({
messages: repairedTools, messages: repairedTools,

View File

@ -46,6 +46,7 @@ import {
loadWorkspaceSkillEntries, loadWorkspaceSkillEntries,
resolveSkillsPromptForRun, resolveSkillsPromptForRun,
} from "../../skills.js"; } from "../../skills.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { resolveDefaultModelForAgent } from "../../model-selection.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js";
@ -184,6 +185,11 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId, sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
}); });
const workspaceNotes = hookAdjustedBootstrapFiles.some(
(file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing,
)
? ["Reminder: commit your changes in this workspace after edits."]
: undefined;
const agentDir = params.agentDir ?? resolveClawdbotAgentDir(); const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
@ -314,6 +320,7 @@ export async function runEmbeddedAttempt(
: undefined, : undefined,
skillsPrompt, skillsPrompt,
docsPath: docsPath ?? undefined, docsPath: docsPath ?? undefined,
workspaceNotes,
reactionGuidance, reactionGuidance,
promptMode, promptMode,
runtimeInfo, runtimeInfo,

View File

@ -20,6 +20,7 @@ export function buildEmbeddedSystemPrompt(params: {
level: "minimal" | "extensive"; level: "minimal" | "extensive";
channel: string; channel: string;
}; };
workspaceNotes?: string[];
/** Controls which hardcoded sections to include. Defaults to "full". */ /** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode; promptMode?: PromptMode;
runtimeInfo: { runtimeInfo: {
@ -54,6 +55,7 @@ export function buildEmbeddedSystemPrompt(params: {
heartbeatPrompt: params.heartbeatPrompt, heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt, skillsPrompt: params.skillsPrompt,
docsPath: params.docsPath, docsPath: params.docsPath,
workspaceNotes: params.workspaceNotes,
reactionGuidance: params.reactionGuidance, reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode, promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo, runtimeInfo: params.runtimeInfo,

View File

@ -226,24 +226,27 @@ export function handleMessageEnd(
); );
} else { } else {
ctx.state.lastBlockReplyText = text; ctx.state.lastBlockReplyText = text;
const { const splitResult = ctx.consumeReplyDirectives(text, { final: true });
text: cleanedText, if (splitResult) {
mediaUrls, const {
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = parseReplyDirectives(text);
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
void onBlockReply({
text: cleanedText, text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined, mediaUrls,
audioAsVoice, audioAsVoice,
replyToId, replyToId,
replyToTag, replyToTag,
replyToCurrent, replyToCurrent,
}); } = splitResult;
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
void onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
}
} }
} }
} }
@ -254,6 +257,30 @@ export function handleMessageEnd(
ctx.emitReasoningStream(rawThinking); ctx.emitReasoningStream(rawThinking);
} }
if (ctx.state.blockReplyBreak === "text_end" && onBlockReply) {
const tailResult = ctx.consumeReplyDirectives("", { final: true });
if (tailResult) {
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = tailResult;
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
void onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
}
}
}
ctx.state.deltaBuffer = ""; ctx.state.deltaBuffer = "";
ctx.state.blockBuffer = ""; ctx.state.blockBuffer = "";
ctx.blockChunker?.reset(); ctx.blockChunker?.reset();

View File

@ -1,6 +1,7 @@
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import type { ReasoningLevel } from "../auto-reply/thinking.js"; import type { ReasoningLevel } from "../auto-reply/thinking.js";
import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js";
import type { InlineCodeState } from "../markdown/code-spans.js"; import type { InlineCodeState } from "../markdown/code-spans.js";
import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
import type { MessagingToolSend } from "./pi-embedded-messaging.js"; import type { MessagingToolSend } from "./pi-embedded-messaging.js";
@ -77,6 +78,10 @@ export type EmbeddedPiSubscribeContext = {
emitBlockChunk: (text: string) => void; emitBlockChunk: (text: string) => void;
flushBlockReplyBuffer: () => void; flushBlockReplyBuffer: () => void;
emitReasoningStream: (text: string) => void; emitReasoningStream: (text: string) => void;
consumeReplyDirectives: (
text: string,
options?: { final?: boolean },
) => ReplyDirectiveParseResult | null;
resetAssistantMessageState: (nextAssistantTextBaseline: number) => void; resetAssistantMessageState: (nextAssistantTextBaseline: number) => void;
resetForCompactionRetry: () => void; resetForCompactionRetry: () => void;
finalizeAssistantTexts: (args: { finalizeAssistantTexts: (args: {

View File

@ -0,0 +1,106 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
describe("subscribeEmbeddedPiSession reply tags", () => {
it("carries reply_to_current across tag-only block chunks", () => {
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",
blockReplyChunking: {
minChars: 1,
maxChars: 50,
breakPreference: "newline",
},
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: "[[reply_to_current]]\nHello",
},
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "[[reply_to_current]]\nHello" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
const payload = onBlockReply.mock.calls[0]?.[0];
expect(payload?.text).toBe("Hello");
expect(payload?.replyToCurrent).toBe(true);
expect(payload?.replyToTag).toBe(true);
});
it("flushes trailing directive tails on stream end", () => {
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",
blockReplyChunking: {
minChars: 1,
maxChars: 50,
breakPreference: "newline",
},
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "Hello [[" },
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Hello [[" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(2);
expect(onBlockReply.mock.calls[0]?.[0]?.text).toBe("Hello");
expect(onBlockReply.mock.calls[1]?.[0]?.text).toBe("[[");
});
});

View File

@ -1,4 +1,5 @@
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js"; import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import type { InlineCodeState } from "../markdown/code-spans.js"; import type { InlineCodeState } from "../markdown/code-spans.js";
@ -75,11 +76,13 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
const messagingToolSentTargets = state.messagingToolSentTargets; const messagingToolSentTargets = state.messagingToolSentTargets;
const pendingMessagingTexts = state.pendingMessagingTexts; const pendingMessagingTexts = state.pendingMessagingTexts;
const pendingMessagingTargets = state.pendingMessagingTargets; const pendingMessagingTargets = state.pendingMessagingTargets;
const replyDirectiveAccumulator = createStreamingDirectiveAccumulator();
const resetAssistantMessageState = (nextAssistantTextBaseline: number) => { const resetAssistantMessageState = (nextAssistantTextBaseline: number) => {
state.deltaBuffer = ""; state.deltaBuffer = "";
state.blockBuffer = ""; state.blockBuffer = "";
blockChunker?.reset(); blockChunker?.reset();
replyDirectiveAccumulator.reset();
state.blockState.thinking = false; state.blockState.thinking = false;
state.blockState.final = false; state.blockState.final = false;
state.blockState.inlineCode = createInlineCodeState(); state.blockState.inlineCode = createInlineCodeState();
@ -374,7 +377,8 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
assistantTexts.push(chunk); assistantTexts.push(chunk);
rememberAssistantText(chunk); rememberAssistantText(chunk);
if (!params.onBlockReply) return; if (!params.onBlockReply) return;
const splitResult = parseReplyDirectives(chunk); const splitResult = replyDirectiveAccumulator.consume(chunk);
if (!splitResult) return;
const { const {
text: cleanedText, text: cleanedText,
mediaUrls, mediaUrls,
@ -395,6 +399,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
}); });
}; };
const consumeReplyDirectives = (text: string, options?: { final?: boolean }) =>
replyDirectiveAccumulator.consume(text, options);
const flushBlockReplyBuffer = () => { const flushBlockReplyBuffer = () => {
if (!params.onBlockReply) return; if (!params.onBlockReply) return;
if (blockChunker?.hasBuffered()) { if (blockChunker?.hasBuffered()) {
@ -447,6 +454,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
emitBlockChunk, emitBlockChunk,
flushBlockReplyBuffer, flushBlockReplyBuffer,
emitReasoningStream, emitReasoningStream,
consumeReplyDirectives,
resetAssistantMessageState, resetAssistantMessageState,
resetForCompactionRetry, resetForCompactionRetry,
finalizeAssistantTexts, finalizeAssistantTexts,

View File

@ -115,6 +115,15 @@ describe("buildAgentSystemPrompt", () => {
); );
}); });
it("includes workspace notes when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
workspaceNotes: ["Reminder: commit your changes in this workspace after edits."],
});
expect(prompt).toContain("Reminder: commit your changes in this workspace after edits.");
});
it("includes user time when provided (12-hour)", () => { it("includes user time when provided (12-hour)", () => {
const prompt = buildAgentSystemPrompt({ const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd", workspaceDir: "/tmp/clawd",

View File

@ -17,8 +17,9 @@ function buildSkillsSection(params: {
isMinimal: boolean; isMinimal: boolean;
readToolName: string; readToolName: string;
}) { }) {
if (params.isMinimal) return [];
const trimmed = params.skillsPrompt?.trim(); const trimmed = params.skillsPrompt?.trim();
if (!trimmed || params.isMinimal) return []; if (!trimmed) return [];
return [ return [
"## Skills (mandatory)", "## Skills (mandatory)",
"Before replying: scan <available_skills> <description> entries.", "Before replying: scan <available_skills> <description> entries.",
@ -148,6 +149,7 @@ export function buildAgentSystemPrompt(params: {
skillsPrompt?: string; skillsPrompt?: string;
heartbeatPrompt?: string; heartbeatPrompt?: string;
docsPath?: string; docsPath?: string;
workspaceNotes?: string[];
/** Controls which hardcoded sections to include. Defaults to "full". */ /** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode; promptMode?: PromptMode;
runtimeInfo?: { runtimeInfo?: {
@ -327,6 +329,7 @@ export function buildAgentSystemPrompt(params: {
isMinimal, isMinimal,
readToolName, readToolName,
}); });
const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean);
// For "none" mode, return just the basic identity line // For "none" mode, return just the basic identity line
if (promptMode === "none") { if (promptMode === "none") {
@ -403,6 +406,7 @@ export function buildAgentSystemPrompt(params: {
"## Workspace", "## Workspace",
`Your working directory is: ${params.workspaceDir}`, `Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
...workspaceNotes,
"", "",
...docsSection, ...docsSection,
params.sandboxInfo?.enabled ? "## Sandbox" : "", params.sandboxInfo?.enabled ? "## Sandbox" : "",

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { runCommandWithTimeout } from "../process/exec.js";
import type { WorkspaceBootstrapFile } from "./workspace.js"; import type { WorkspaceBootstrapFile } from "./workspace.js";
import { import {
DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_FILENAME,
@ -40,6 +41,34 @@ describe("ensureAgentWorkspace", () => {
await expect(fs.stat(bootstrap)).resolves.toBeDefined(); await expect(fs.stat(bootstrap)).resolves.toBeDefined();
}); });
it("initializes a git repo for brand-new workspaces when git is available", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
const nested = path.join(dir, "nested");
const gitAvailable = await runCommandWithTimeout(["git", "--version"], { timeoutMs: 2_000 })
.then((res) => res.code === 0)
.catch(() => false);
if (!gitAvailable) return;
await ensureAgentWorkspace({
dir: nested,
ensureBootstrapFiles: true,
});
await expect(fs.stat(path.join(nested, ".git"))).resolves.toBeDefined();
});
it("does not initialize git when workspace already exists", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
await fs.writeFile(path.join(dir, "AGENTS.md"), "custom", "utf-8");
await ensureAgentWorkspace({
dir,
ensureBootstrapFiles: true,
});
await expect(fs.stat(path.join(dir, ".git"))).rejects.toBeDefined();
});
it("does not overwrite existing AGENTS.md", async () => { it("does not overwrite existing AGENTS.md", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
const agentsPath = path.join(dir, "AGENTS.md"); const agentsPath = path.join(dir, "AGENTS.md");

View File

@ -4,7 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { isSubagentSessionKey } from "../routing/session-key.js"; import { isSubagentSessionKey } from "../routing/session-key.js";
import { formatCliCommand } from "../cli/command-format.js"; import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
export function resolveDefaultAgentWorkspaceDir( export function resolveDefaultAgentWorkspaceDir(
@ -27,142 +27,9 @@ export const DEFAULT_USER_FILENAME = "USER.md";
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md"; export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md"; export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
const DEFAULT_AGENTS_TEMPLATE = `# AGENTS.md - Clawdbot Workspace
This folder is the assistant's working directory.
## First run (one-time)
- If BOOTSTRAP.md exists, follow its ritual and delete it once complete.
- Your agent identity lives in IDENTITY.md.
- Your profile lives in USER.md.
## Backup tip (recommended)
If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity
and notes are backed up.
\`\`\`bash
git init
git add AGENTS.md
git commit -m "Add agent workspace"
\`\`\`
## Safety defaults
- Don't exfiltrate secrets or private data.
- Don't run destructive commands unless explicitly asked.
- Be concise in chat; write longer output to files in this workspace.
## Daily memory (recommended)
- Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed).
- On session start, read today + yesterday if present.
- Capture durable facts, preferences, and decisions; avoid secrets.
## Heartbeats (optional)
- HEARTBEAT.md can hold a tiny checklist for heartbeat runs; keep it small.
## Customize
- Add your preferred style, rules, and "memory" here.
`;
const DEFAULT_SOUL_TEMPLATE = `# SOUL.md - Persona & Boundaries
Describe who the assistant is, tone, and boundaries.
- Keep replies concise and direct.
- Ask clarifying questions when needed.
- Never send streaming/partial replies to external messaging surfaces.
`;
const DEFAULT_TOOLS_TEMPLATE = `# TOOLS.md - User Tool Notes (editable)
This file is for *your* notes about external tools and conventions.
It does not define which tools exist; Clawdbot provides built-in tools internally.
## Examples
### imsg
- Send an iMessage/SMS: describe who/what, confirm before sending.
- Prefer short messages; avoid sending secrets.
### sag
- Text-to-speech: specify voice, target speaker/room, and whether to stream.
Add whatever else you want the assistant to know about your local toolchain.
`;
const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md
Optional: keep a tiny checklist for heartbeat runs.
Guidance (to avoid nagging):
- Only report items that are truly new or changed.
- Do not invent tasks from old chat context.
- If nothing needs attention, reply HEARTBEAT_OK.
`;
const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after)
Hello. I was just born.
## Your mission
Start a short, playful conversation and learn:
- Who am I?
- What am I?
- Who are you?
- How should I call you?
## How to ask (cute + helpful)
Say:
"Hello! I was just born. Who am I? What am I? Who are you? How should I call you?"
Then offer suggestions:
- 3-5 name ideas.
- 3-5 creature/vibe combos.
- 5 emoji ideas.
## Write these files
After the user chooses, update:
1) IDENTITY.md
- Name
- Creature
- Vibe
- Emoji
2) USER.md
- Name
- Preferred address
- Pronouns (optional)
- Timezone (optional)
- Notes
3) ~/.clawdbot/clawdbot.json
Run: ${formatCliCommand('clawdbot agents set-identity --workspace "<this workspace>" --from-identity')}
If multiple agents share a host, add --agent <id>.
## Cleanup
Delete BOOTSTRAP.md once this is complete.
`;
const DEFAULT_IDENTITY_TEMPLATE = `# IDENTITY.md - Agent Identity
- Name:
- Creature:
- Vibe:
- Emoji:
`;
const DEFAULT_USER_TEMPLATE = `# USER.md - User Profile
- Name:
- Preferred address:
- Pronouns (optional):
- Timezone (optional):
- Notes:
`;
const TEMPLATE_DIR = path.resolve( const TEMPLATE_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
"../../docs/templates", "../../docs/reference/templates",
); );
function stripFrontMatter(content: string): string { function stripFrontMatter(content: string): string {
@ -175,13 +42,15 @@ function stripFrontMatter(content: string): string {
return trimmed; return trimmed;
} }
async function loadTemplate(name: string, fallback: string): Promise<string> { async function loadTemplate(name: string): Promise<string> {
const templatePath = path.join(TEMPLATE_DIR, name); const templatePath = path.join(TEMPLATE_DIR, name);
try { try {
const content = await fs.readFile(templatePath, "utf-8"); const content = await fs.readFile(templatePath, "utf-8");
return stripFrontMatter(content); return stripFrontMatter(content);
} catch { } catch {
return fallback; throw new Error(
`Missing workspace template: ${name} (${templatePath}). Ensure docs/reference/templates are packaged.`,
);
} }
} }
@ -213,6 +82,35 @@ async function writeFileIfMissing(filePath: string, content: string) {
} }
} }
async function hasGitRepo(dir: string): Promise<boolean> {
try {
await fs.stat(path.join(dir, ".git"));
return true;
} catch {
return false;
}
}
async function isGitAvailable(): Promise<boolean> {
try {
const result = await runCommandWithTimeout(["git", "--version"], { timeoutMs: 2_000 });
return result.code === 0;
} catch {
return false;
}
}
async function ensureGitRepo(dir: string, isBrandNewWorkspace: boolean) {
if (!isBrandNewWorkspace) return;
if (await hasGitRepo(dir)) return;
if (!(await isGitAvailable())) return;
try {
await runCommandWithTimeout(["git", "init"], { cwd: dir, timeoutMs: 10_000 });
} catch {
// Ignore git init failures; workspace creation should still succeed.
}
}
export async function ensureAgentWorkspace(params?: { export async function ensureAgentWorkspace(params?: {
dir?: string; dir?: string;
ensureBootstrapFiles?: boolean; ensureBootstrapFiles?: boolean;
@ -255,19 +153,13 @@ export async function ensureAgentWorkspace(params?: {
return existing.every((v) => !v); return existing.every((v) => !v);
})(); })();
const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_TEMPLATE); const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME);
const soulTemplate = await loadTemplate(DEFAULT_SOUL_FILENAME, DEFAULT_SOUL_TEMPLATE); const soulTemplate = await loadTemplate(DEFAULT_SOUL_FILENAME);
const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME, DEFAULT_TOOLS_TEMPLATE); const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME);
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME, DEFAULT_IDENTITY_TEMPLATE); const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME);
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME, DEFAULT_USER_TEMPLATE); const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
const heartbeatTemplate = await loadTemplate( const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
DEFAULT_HEARTBEAT_FILENAME, const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);
DEFAULT_HEARTBEAT_TEMPLATE,
);
const bootstrapTemplate = await loadTemplate(
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_BOOTSTRAP_TEMPLATE,
);
await writeFileIfMissing(agentsPath, agentsTemplate); await writeFileIfMissing(agentsPath, agentsTemplate);
await writeFileIfMissing(soulPath, soulTemplate); await writeFileIfMissing(soulPath, soulTemplate);
@ -278,6 +170,7 @@ export async function ensureAgentWorkspace(params?: {
if (isBrandNewWorkspace) { if (isBrandNewWorkspace) {
await writeFileIfMissing(bootstrapPath, bootstrapTemplate); await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
} }
await ensureGitRepo(dir, isBrandNewWorkspace);
return { return {
dir, dir,

View File

@ -379,6 +379,7 @@ export async function resolveReplyDirectives(params: {
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
parentSessionKey: ctx.ParentSessionKey,
storePath, storePath,
defaultProvider, defaultProvider,
defaultModel, defaultModel,

View File

@ -247,7 +247,7 @@ export async function runPreparedReply(
const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n"); const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
const mediaNote = buildInboundMediaNote(ctx); const mediaNote = buildInboundMediaNote(ctx);
const mediaReplyHint = mediaNote const mediaReplyHint = mediaNote
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." ? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body."
: undefined; : undefined;
let prefixedCommandBody = mediaNote let prefixedCommandBody = mediaNote
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim() ? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()

View File

@ -0,0 +1,157 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { createModelSelectionState } from "./model-selection.js";
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
]),
}));
const defaultProvider = "openai";
const defaultModel = "gpt-4o-mini";
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
sessionId: "session-id",
updatedAt: Date.now(),
...overrides,
});
async function resolveState(params: {
cfg: ClawdbotConfig;
sessionEntry: ReturnType<typeof makeEntry>;
sessionStore: Record<string, ReturnType<typeof makeEntry>>;
sessionKey: string;
parentSessionKey?: string;
}) {
return createModelSelectionState({
cfg: params.cfg,
agentCfg: params.cfg.agents?.defaults,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
parentSessionKey: params.parentSessionKey,
defaultProvider,
defaultModel,
provider: defaultProvider,
model: defaultModel,
hasModelDirective: false,
});
}
describe("createModelSelectionState parent inheritance", () => {
it("inherits parent override from explicit parentSessionKey", async () => {
const cfg = {} as ClawdbotConfig;
const parentKey = "agent:main:discord:channel:C1";
const sessionKey = "agent:main:discord:channel:C1:thread:123";
const parentEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
const sessionEntry = makeEntry();
const sessionStore = {
[parentKey]: parentEntry,
[sessionKey]: sessionEntry,
};
const state = await resolveState({
cfg,
sessionEntry,
sessionStore,
sessionKey,
parentSessionKey: parentKey,
});
expect(state.provider).toBe("openai");
expect(state.model).toBe("gpt-4o");
});
it("derives parent key from topic session suffix", async () => {
const cfg = {} as ClawdbotConfig;
const parentKey = "agent:main:telegram:group:123";
const sessionKey = "agent:main:telegram:group:123:topic:99";
const parentEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
const sessionEntry = makeEntry();
const sessionStore = {
[parentKey]: parentEntry,
[sessionKey]: sessionEntry,
};
const state = await resolveState({
cfg,
sessionEntry,
sessionStore,
sessionKey,
});
expect(state.provider).toBe("openai");
expect(state.model).toBe("gpt-4o");
});
it("prefers child override over parent", async () => {
const cfg = {} as ClawdbotConfig;
const parentKey = "agent:main:telegram:group:123";
const sessionKey = "agent:main:telegram:group:123:topic:99";
const parentEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
const sessionEntry = makeEntry({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-5",
});
const sessionStore = {
[parentKey]: parentEntry,
[sessionKey]: sessionEntry,
};
const state = await resolveState({
cfg,
sessionEntry,
sessionStore,
sessionKey,
});
expect(state.provider).toBe("anthropic");
expect(state.model).toBe("claude-opus-4-5");
});
it("ignores parent override when disallowed", async () => {
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-4o-mini": {},
},
},
},
} as ClawdbotConfig;
const parentKey = "agent:main:slack:channel:C1";
const sessionKey = "agent:main:slack:channel:C1:thread:123";
const parentEntry = makeEntry({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-5",
});
const sessionEntry = makeEntry();
const sessionStore = {
[parentKey]: parentEntry,
[sessionKey]: sessionEntry,
};
const state = await resolveState({
cfg,
sessionEntry,
sessionStore,
sessionKey,
});
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe(defaultModel);
});
});

View File

@ -13,6 +13,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { resolveThreadParentSessionKey } from "../../sessions/session-key-utils.js";
import type { ThinkLevel } from "./directives.js"; import type { ThinkLevel } from "./directives.js";
export type ModelDirectiveSelection = { export type ModelDirectiveSelection = {
@ -78,6 +79,52 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
return dist; return dist;
} }
type StoredModelOverride = {
provider?: string;
model: string;
source: "session" | "parent";
};
function resolveModelOverrideFromEntry(entry?: SessionEntry): {
provider?: string;
model: string;
} | null {
const model = entry?.modelOverride?.trim();
if (!model) return null;
const provider = entry?.providerOverride?.trim() || undefined;
return { provider, model };
}
function resolveParentSessionKeyCandidate(params: {
sessionKey?: string;
parentSessionKey?: string;
}): string | null {
const explicit = params.parentSessionKey?.trim();
if (explicit && explicit !== params.sessionKey) return explicit;
const derived = resolveThreadParentSessionKey(params.sessionKey);
if (derived && derived !== params.sessionKey) return derived;
return null;
}
function resolveStoredModelOverride(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
parentSessionKey?: string;
}): StoredModelOverride | null {
const direct = resolveModelOverrideFromEntry(params.sessionEntry);
if (direct) return { ...direct, source: "session" };
const parentKey = resolveParentSessionKeyCandidate({
sessionKey: params.sessionKey,
parentSessionKey: params.parentSessionKey,
});
if (!parentKey || !params.sessionStore) return null;
const parentEntry = params.sessionStore[parentKey];
const parentOverride = resolveModelOverrideFromEntry(parentEntry);
if (!parentOverride) return null;
return { ...parentOverride, source: "parent" };
}
function scoreFuzzyMatch(params: { function scoreFuzzyMatch(params: {
provider: string; provider: string;
model: string; model: string;
@ -177,6 +224,7 @@ export async function createModelSelectionState(params: {
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey?: string; sessionKey?: string;
parentSessionKey?: string;
storePath?: string; storePath?: string;
defaultProvider: string; defaultProvider: string;
defaultModel: string; defaultModel: string;
@ -190,6 +238,7 @@ export async function createModelSelectionState(params: {
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
parentSessionKey,
storePath, storePath,
defaultProvider, defaultProvider,
defaultModel, defaultModel,
@ -199,7 +248,13 @@ export async function createModelSelectionState(params: {
let model = params.model; let model = params.model;
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0; const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride); const initialStoredOverride = resolveStoredModelOverride({
sessionEntry,
sessionStore,
sessionKey,
parentSessionKey,
});
const hasStoredOverride = Boolean(initialStoredOverride);
const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride; const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride;
let allowedModelKeys = new Set<string>(); let allowedModelKeys = new Set<string>();
@ -242,14 +297,18 @@ export async function createModelSelectionState(params: {
} }
} }
const storedProviderOverride = sessionEntry?.providerOverride?.trim(); const storedOverride = resolveStoredModelOverride({
const storedModelOverride = sessionEntry?.modelOverride?.trim(); sessionEntry,
if (storedModelOverride) { sessionStore,
const candidateProvider = storedProviderOverride || defaultProvider; sessionKey,
const key = modelKey(candidateProvider, storedModelOverride); parentSessionKey,
});
if (storedOverride?.model) {
const candidateProvider = storedOverride.provider || defaultProvider;
const key = modelKey(candidateProvider, storedOverride.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
provider = candidateProvider; provider = candidateProvider;
model = storedModelOverride; model = storedOverride.model;
} }
} }

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
describe("createStreamingDirectiveAccumulator", () => {
it("stashes reply_to_current until a renderable chunk arrives", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
const result = accumulator.consume("Hello");
expect(result?.text).toBe("Hello");
expect(result?.replyToCurrent).toBe(true);
expect(result?.replyToTag).toBe(true);
});
it("handles reply tags split across chunks", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to_")).toBeNull();
const result = accumulator.consume("current]] Yo");
expect(result?.text).toBe("Yo");
expect(result?.replyToCurrent).toBe(true);
expect(result?.replyToTag).toBe(true);
});
it("propagates explicit reply ids across chunks", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
const result = accumulator.consume("Hi");
expect(result?.text).toBe("Hi");
expect(result?.replyToId).toBe("abc-123");
expect(result?.replyToTag).toBe(true);
});
});

View File

@ -0,0 +1,124 @@
import { splitMediaFromOutput } from "../../media/parse.js";
import { parseInlineDirectives } from "../../utils/directive-tags.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyDirectiveParseResult } from "./reply-directives.js";
type PendingReplyState = {
explicitId?: string;
sawCurrent: boolean;
hasTag: boolean;
};
type ParsedChunk = ReplyDirectiveParseResult & {
replyToExplicitId?: string;
};
type ConsumeOptions = {
final?: boolean;
silentToken?: string;
};
const splitTrailingDirective = (text: string): { text: string; tail: string } => {
const openIndex = text.lastIndexOf("[[");
if (openIndex < 0) return { text, tail: "" };
const closeIndex = text.indexOf("]]", openIndex + 2);
if (closeIndex >= 0) return { text, tail: "" };
return {
text: text.slice(0, openIndex),
tail: text.slice(openIndex),
};
};
const parseChunk = (raw: string, options?: { silentToken?: string }): ParsedChunk => {
const split = splitMediaFromOutput(raw);
let text = split.text ?? "";
const replyParsed = parseInlineDirectives(text, {
stripAudioTag: false,
stripReplyTags: true,
});
if (replyParsed.hasReplyTag) {
text = replyParsed.text;
}
const silentToken = options?.silentToken ?? SILENT_REPLY_TOKEN;
const isSilent = isSilentReplyText(text, silentToken);
if (isSilent) {
text = "";
}
return {
text,
mediaUrls: split.mediaUrls,
mediaUrl: split.mediaUrl,
replyToId: replyParsed.replyToId,
replyToExplicitId: replyParsed.replyToExplicitId,
replyToCurrent: replyParsed.replyToCurrent,
replyToTag: replyParsed.hasReplyTag,
audioAsVoice: split.audioAsVoice,
isSilent,
};
};
const hasRenderableContent = (parsed: ReplyDirectiveParseResult): boolean =>
Boolean(parsed.text) ||
Boolean(parsed.mediaUrl) ||
(parsed.mediaUrls?.length ?? 0) > 0 ||
Boolean(parsed.audioAsVoice);
export function createStreamingDirectiveAccumulator() {
let pendingTail = "";
let pendingReply: PendingReplyState = { sawCurrent: false, hasTag: false };
const reset = () => {
pendingTail = "";
pendingReply = { sawCurrent: false, hasTag: false };
};
const consume = (raw: string, options: ConsumeOptions = {}): ReplyDirectiveParseResult | null => {
let combined = `${pendingTail}${raw ?? ""}`;
pendingTail = "";
if (!options.final) {
const split = splitTrailingDirective(combined);
combined = split.text;
pendingTail = split.tail;
}
if (!combined) {
return null;
}
const parsed = parseChunk(combined, { silentToken: options.silentToken });
const hasTag = pendingReply.hasTag || parsed.replyToTag;
const sawCurrent = pendingReply.sawCurrent || parsed.replyToCurrent;
const explicitId = parsed.replyToExplicitId ?? pendingReply.explicitId;
const combinedResult: ReplyDirectiveParseResult = {
...parsed,
replyToId: explicitId,
replyToCurrent: sawCurrent,
replyToTag: hasTag,
};
if (!hasRenderableContent(combinedResult)) {
if (hasTag) {
pendingReply = {
explicitId,
sawCurrent,
hasTag,
};
}
return null;
}
pendingReply = { sawCurrent: false, hasTag: false };
return combinedResult;
};
return {
consume,
reset,
};
}

View File

@ -1 +1 @@
0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4 a99455ba0c4d0aad0a110bf25440c208b798198d5524b269f0f2d3f984262ae4

View File

@ -1,10 +1,12 @@
import { resolveCommitHash } from "../infra/git-commit.js"; import { resolveCommitHash } from "../infra/git-commit.js";
import { visibleWidth } from "../terminal/ansi.js";
import { isRich, theme } from "../terminal/theme.js"; import { isRich, theme } from "../terminal/theme.js";
import { pickTagline, type TaglineOptions } from "./tagline.js"; import { pickTagline, type TaglineOptions } from "./tagline.js";
type BannerOptions = TaglineOptions & { type BannerOptions = TaglineOptions & {
argv?: string[]; argv?: string[];
commit?: string | null; commit?: string | null;
columns?: number;
richTty?: boolean; richTty?: boolean;
}; };
@ -36,12 +38,28 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
const tagline = pickTagline(options); const tagline = pickTagline(options);
const rich = options.richTty ?? isRich(); const rich = options.richTty ?? isRich();
const title = "🦞 Clawdbot"; const title = "🦞 Clawdbot";
const prefix = "🦞 ";
const columns = options.columns ?? process.stdout.columns ?? 120;
const plainFullLine = `${title} ${version} (${commitLabel}) — ${tagline}`;
const fitsOnOneLine = visibleWidth(plainFullLine) <= columns;
if (rich) { if (rich) {
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted( if (fitsOnOneLine) {
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
}
const line1 = `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`, `(${commitLabel})`,
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`; )}`;
const line2 = `${" ".repeat(prefix.length)}${theme.muted("—")} ${theme.accentDim(tagline)}`;
return `${line1}\n${line2}`;
} }
return `${title} ${version} (${commitLabel}) — ${tagline}`; if (fitsOnOneLine) {
return plainFullLine;
}
const line1 = `${title} ${version} (${commitLabel})`;
const line2 = `${" ".repeat(prefix.length)}${tagline}`;
return `${line1}\n${line2}`;
} }
const LOBSTER_ASCII = [ const LOBSTER_ASCII = [

View File

@ -2,10 +2,12 @@ import type { Command } from "commander";
import { gatewayStatusCommand } from "../../commands/gateway-status.js"; import { gatewayStatusCommand } from "../../commands/gateway-status.js";
import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js"; import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js";
import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js"; import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js";
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js"; import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { colorize, isRich, theme } from "../../terminal/theme.js"; import { colorize, isRich, theme } from "../../terminal/theme.js";
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import { withProgress } from "../progress.js"; import { withProgress } from "../progress.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { import {
@ -58,6 +60,41 @@ function runGatewayCommand(action: () => Promise<void>, label?: string) {
}); });
} }
function parseDaysOption(raw: unknown, fallback = 30): number {
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(1, Math.floor(raw));
if (typeof raw === "string" && raw.trim() !== "") {
const parsed = Number(raw);
if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed));
}
return fallback;
}
function renderCostUsageSummary(summary: CostUsageSummary, days: number, rich: boolean): string[] {
const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00";
const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0";
const lines = [
colorize(rich, theme.heading, `Usage cost (${days} days)`),
`${colorize(rich, theme.muted, "Total:")} ${totalCost} · ${totalTokens} tokens`,
];
if (summary.totals.missingCostEntries > 0) {
lines.push(
`${colorize(rich, theme.muted, "Missing entries:")} ${summary.totals.missingCostEntries}`,
);
}
const latest = summary.daily.at(-1);
if (latest) {
const latestCost = formatUsd(latest.totalCost) ?? "$0.00";
const latestTokens = formatTokenCount(latest.totalTokens) ?? "0";
lines.push(
`${colorize(rich, theme.muted, "Latest day:")} ${latest.date} · ${latestCost} · ${latestTokens} tokens`,
);
}
return lines;
}
export function registerGatewayCli(program: Command) { export function registerGatewayCli(program: Command) {
const gateway = addGatewayRunCommand( const gateway = addGatewayRunCommand(
program program
@ -160,6 +197,28 @@ export function registerGatewayCli(program: Command) {
}), }),
); );
gatewayCallOpts(
gateway
.command("usage-cost")
.description("Fetch usage cost summary from session logs")
.option("--days <days>", "Number of days to include", "30")
.action(async (opts) => {
await runGatewayCommand(async () => {
const days = parseDaysOption(opts.days);
const result = await callGatewayCli("usage.cost", opts, { days });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const rich = isRich();
const summary = result as CostUsageSummary;
for (const line of renderCostUsageSummary(summary, days, rich)) {
defaultRuntime.log(line);
}
}, "Gateway usage cost failed");
}),
);
gatewayCallOpts( gatewayCallOpts(
gateway gateway
.command("health") .command("health")

View File

@ -139,7 +139,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
agents agents
.command("set-identity") .command("set-identity")
.description("Update an agent identity (name/theme/emoji)") .description("Update an agent identity (name/theme/emoji/avatar)")
.option("--agent <id>", "Agent id to update") .option("--agent <id>", "Agent id to update")
.option("--workspace <dir>", "Workspace directory used to locate the agent + IDENTITY.md") .option("--workspace <dir>", "Workspace directory used to locate the agent + IDENTITY.md")
.option("--identity-file <path>", "Explicit IDENTITY.md path to read") .option("--identity-file <path>", "Explicit IDENTITY.md path to read")
@ -147,6 +147,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
.option("--name <name>", "Identity name") .option("--name <name>", "Identity name")
.option("--theme <theme>", "Identity theme") .option("--theme <theme>", "Identity theme")
.option("--emoji <emoji>", "Identity emoji") .option("--emoji <emoji>", "Identity emoji")
.option("--avatar <value>", "Identity avatar (workspace path, http(s) URL, or data URI)")
.option("--json", "Output JSON summary", false) .option("--json", "Output JSON summary", false)
.addHelpText( .addHelpText(
"after", "after",
@ -155,6 +156,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
${theme.heading("Examples:")} ${theme.heading("Examples:")}
${formatHelpExamples([ ${formatHelpExamples([
['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."], ['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."],
["clawdbot agents set-identity --agent main --avatar avatars/clawd.png", "Set avatar path."],
["clawdbot agents set-identity --workspace ~/clawd --from-identity", "Load from IDENTITY.md."], ["clawdbot agents set-identity --workspace ~/clawd --from-identity", "Load from IDENTITY.md."],
[ [
"clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main", "clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main",
@ -174,6 +176,7 @@ ${formatHelpExamples([
name: opts.name as string | undefined, name: opts.name as string | undefined,
theme: opts.theme as string | undefined, theme: opts.theme as string | undefined,
emoji: opts.emoji as string | undefined, emoji: opts.emoji as string | undefined,
avatar: opts.avatar as string | undefined,
json: Boolean(opts.json), json: Boolean(opts.json),
}, },
defaultRuntime, defaultRuntime,

View File

@ -5,6 +5,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateRunResult } from "../infra/update-runner.js"; import type { UpdateRunResult } from "../infra/update-runner.js";
const confirm = vi.fn();
const select = vi.fn();
const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() }));
const isCancel = (value: unknown) => value === "cancel";
vi.mock("@clack/prompts", () => ({
confirm,
select,
isCancel,
spinner,
}));
// Mock the update-runner module // Mock the update-runner module
vi.mock("../infra/update-runner.js", () => ({ vi.mock("../infra/update-runner.js", () => ({
runGatewayUpdate: vi.fn(), runGatewayUpdate: vi.fn(),
@ -128,9 +140,11 @@ describe("update-cli", () => {
}); });
it("exports updateCommand and registerUpdateCli", async () => { it("exports updateCommand and registerUpdateCli", async () => {
const { updateCommand, registerUpdateCli } = await import("./update-cli.js"); const { updateCommand, registerUpdateCli, updateWizardCommand } =
await import("./update-cli.js");
expect(typeof updateCommand).toBe("function"); expect(typeof updateCommand).toBe("function");
expect(typeof registerUpdateCli).toBe("function"); expect(typeof registerUpdateCli).toBe("function");
expect(typeof updateWizardCommand).toBe("function");
}, 20_000); }, 20_000);
it("updateCommand runs update and outputs result", async () => { it("updateCommand runs update and outputs result", async () => {
@ -585,4 +599,61 @@ describe("update-cli", () => {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
it("updateWizardCommand requires a TTY", async () => {
const { defaultRuntime } = await import("../runtime.js");
const { updateWizardCommand } = await import("./update-cli.js");
setTty(false);
vi.mocked(defaultRuntime.error).mockClear();
vi.mocked(defaultRuntime.exit).mockClear();
await updateWizardCommand({});
expect(defaultRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("Update wizard requires a TTY"),
);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("updateWizardCommand offers dev checkout and forwards selections", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-wizard-"));
const previousGitDir = process.env.CLAWDBOT_GIT_DIR;
try {
setTty(true);
process.env.CLAWDBOT_GIT_DIR = tempDir;
const { checkUpdateStatus } = await import("../infra/update-check.js");
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { updateWizardCommand } = await import("./update-cli.js");
vi.mocked(checkUpdateStatus).mockResolvedValue({
root: "/test/path",
installKind: "package",
packageManager: "npm",
deps: {
manager: "npm",
status: "ok",
lockfilePath: null,
markerPath: null,
},
});
select.mockResolvedValue("dev");
confirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
});
await updateWizardCommand({});
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.channel).toBe("dev");
} finally {
process.env.CLAWDBOT_GIT_DIR = previousGitDir;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
}); });

View File

@ -1,4 +1,4 @@
import { confirm, isCancel, spinner } from "@clack/prompts"; import { confirm, isCancel, select, spinner } from "@clack/prompts";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@ -39,7 +39,7 @@ import { trimLogTail } from "../infra/restart-sentinel.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { stylePromptMessage } from "../terminal/prompt-style.js"; import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { renderTable } from "../terminal/table.js"; import { renderTable } from "../terminal/table.js";
import { formatHelpExamples } from "./help-format.js"; import { formatHelpExamples } from "./help-format.js";
@ -63,6 +63,9 @@ export type UpdateStatusOptions = {
json?: boolean; json?: boolean;
timeout?: string; timeout?: string;
}; };
export type UpdateWizardOptions = {
timeout?: string;
};
const STEP_LABELS: Record<string, string> = { const STEP_LABELS: Record<string, string> = {
"clean check": "Working directory is clean", "clean check": "Working directory is clean",
@ -481,6 +484,15 @@ function formatStepStatus(exitCode: number | null): string {
return theme.error("\u2717"); return theme.error("\u2717");
} }
const selectStyled = <T>(params: Parameters<typeof select<T>>[0]) =>
select({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
),
});
type PrintResultOptions = UpdateCommandOptions & { type PrintResultOptions = UpdateCommandOptions & {
hideSteps?: boolean; hideSteps?: boolean;
}; };
@ -940,6 +952,142 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
} }
} }
export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promise<void> {
if (!process.stdin.isTTY) {
defaultRuntime.error(
"Update wizard requires a TTY. Use `clawdbot update --channel <stable|beta|dev>` instead.",
);
defaultRuntime.exit(1);
return;
}
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
defaultRuntime.error("--timeout must be a positive integer (seconds)");
defaultRuntime.exit(1);
return;
}
const root =
(await resolveClawdbotPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
})) ?? process.cwd();
const [updateStatus, configSnapshot] = await Promise.all([
checkUpdateStatus({
root,
timeoutMs: timeoutMs ?? 3500,
fetchGit: false,
includeRegistry: false,
}),
readConfigFileSnapshot(),
]);
const configChannel = configSnapshot.valid
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
: null;
const channelInfo = resolveEffectiveUpdateChannel({
configChannel,
installKind: updateStatus.installKind,
git: updateStatus.git
? { tag: updateStatus.git.tag, branch: updateStatus.git.branch }
: undefined,
});
const channelLabel = formatUpdateChannelLabel({
channel: channelInfo.channel,
source: channelInfo.source,
gitTag: updateStatus.git?.tag ?? null,
gitBranch: updateStatus.git?.branch ?? null,
});
const pickedChannel = await selectStyled({
message: "Update channel",
options: [
{
value: "keep",
label: `Keep current (${channelInfo.channel})`,
hint: channelLabel,
},
{
value: "stable",
label: "Stable",
hint: "Tagged releases (npm latest)",
},
{
value: "beta",
label: "Beta",
hint: "Prereleases (npm beta)",
},
{
value: "dev",
label: "Dev",
hint: "Git main",
},
],
initialValue: "keep",
});
if (isCancel(pickedChannel)) {
defaultRuntime.log(theme.muted("Update cancelled."));
defaultRuntime.exit(0);
return;
}
const requestedChannel = pickedChannel === "keep" ? null : pickedChannel;
if (requestedChannel === "dev" && updateStatus.installKind !== "git") {
const gitDir = resolveGitInstallDir();
const hasGit = await isGitCheckout(gitDir);
if (!hasGit) {
const dirExists = await pathExists(gitDir);
if (dirExists) {
const empty = await isEmptyDir(gitDir);
if (!empty) {
defaultRuntime.error(
`CLAWDBOT_GIT_DIR points at a non-git directory: ${gitDir}. Set CLAWDBOT_GIT_DIR to an empty folder or a clawdbot checkout.`,
);
defaultRuntime.exit(1);
return;
}
}
const ok = await confirm({
message: stylePromptMessage(
`Create a git checkout at ${gitDir}? (override via CLAWDBOT_GIT_DIR)`,
),
initialValue: true,
});
if (isCancel(ok) || ok === false) {
defaultRuntime.log(theme.muted("Update cancelled."));
defaultRuntime.exit(0);
return;
}
}
}
const restart = await confirm({
message: stylePromptMessage("Restart the gateway service after update?"),
initialValue: false,
});
if (isCancel(restart)) {
defaultRuntime.log(theme.muted("Update cancelled."));
defaultRuntime.exit(0);
return;
}
try {
await updateCommand({
channel: requestedChannel ?? undefined,
restart: Boolean(restart),
timeout: opts.timeout,
});
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}
export function registerUpdateCli(program: Command) { export function registerUpdateCli(program: Command) {
const update = program const update = program
.command("update") .command("update")
@ -959,6 +1107,7 @@ export function registerUpdateCli(program: Command) {
["clawdbot update --restart", "Update and restart the service"], ["clawdbot update --restart", "Update and restart the service"],
["clawdbot update --json", "Output result as JSON"], ["clawdbot update --json", "Output result as JSON"],
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"], ["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
["clawdbot update wizard", "Interactive update wizard"],
["clawdbot --update", "Shorthand for clawdbot update"], ["clawdbot --update", "Shorthand for clawdbot update"],
] as const; ] as const;
const fmtExamples = examples const fmtExamples = examples
@ -1005,6 +1154,23 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
} }
}); });
update
.command("wizard")
.description("Interactive update wizard")
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
.addHelpText(
"after",
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}\n`,
)
.action(async (opts) => {
try {
await updateWizardCommand({ timeout: opts.timeout as string | undefined });
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
update update
.command("status") .command("status")
.description("Show update channel and version status") .description("Show update channel and version status")

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js";
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js"; import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import type { IdentityConfig } from "../config/types.js"; import type { IdentityConfig } from "../config/types.js";
@ -15,7 +16,6 @@ import {
findAgentEntryIndex, findAgentEntryIndex,
listAgentEntries, listAgentEntries,
loadAgentIdentity, loadAgentIdentity,
parseIdentityMarkdown,
} from "./agents.config.js"; } from "./agents.config.js";
type AgentsSetIdentityOptions = { type AgentsSetIdentityOptions = {
@ -25,6 +25,7 @@ type AgentsSetIdentityOptions = {
name?: string; name?: string;
emoji?: string; emoji?: string;
theme?: string; theme?: string;
avatar?: string;
fromIdentity?: boolean; fromIdentity?: boolean;
json?: boolean; json?: boolean;
}; };
@ -40,7 +41,7 @@ async function loadIdentityFromFile(filePath: string): Promise<AgentIdentity | n
try { try {
const content = await fs.readFile(filePath, "utf-8"); const content = await fs.readFile(filePath, "utf-8");
const parsed = parseIdentityMarkdown(content); const parsed = parseIdentityMarkdown(content);
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) { if (!identityHasValues(parsed)) {
return null; return null;
} }
return parsed; return parsed;
@ -75,7 +76,8 @@ export async function agentsSetIdentityCommand(
const nameRaw = coerceTrimmed(opts.name); const nameRaw = coerceTrimmed(opts.name);
const emojiRaw = coerceTrimmed(opts.emoji); const emojiRaw = coerceTrimmed(opts.emoji);
const themeRaw = coerceTrimmed(opts.theme); const themeRaw = coerceTrimmed(opts.theme);
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw); const avatarRaw = coerceTrimmed(opts.avatar);
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw || avatarRaw);
const identityFileRaw = coerceTrimmed(opts.identityFile); const identityFileRaw = coerceTrimmed(opts.identityFile);
const workspaceRaw = coerceTrimmed(opts.workspace); const workspaceRaw = coerceTrimmed(opts.workspace);
@ -141,10 +143,20 @@ export async function agentsSetIdentityCommand(
...(nameRaw || identityFromFile?.name ? { name: nameRaw ?? identityFromFile?.name } : {}), ...(nameRaw || identityFromFile?.name ? { name: nameRaw ?? identityFromFile?.name } : {}),
...(emojiRaw || identityFromFile?.emoji ? { emoji: emojiRaw ?? identityFromFile?.emoji } : {}), ...(emojiRaw || identityFromFile?.emoji ? { emoji: emojiRaw ?? identityFromFile?.emoji } : {}),
...(themeRaw || fileTheme ? { theme: themeRaw ?? fileTheme } : {}), ...(themeRaw || fileTheme ? { theme: themeRaw ?? fileTheme } : {}),
...(avatarRaw || identityFromFile?.avatar
? { avatar: avatarRaw ?? identityFromFile?.avatar }
: {}),
}; };
if (!incomingIdentity.name && !incomingIdentity.emoji && !incomingIdentity.theme) { if (
runtime.error("No identity fields provided. Use --name/--emoji/--theme or --from-identity."); !incomingIdentity.name &&
!incomingIdentity.emoji &&
!incomingIdentity.theme &&
!incomingIdentity.avatar
) {
runtime.error(
"No identity fields provided. Use --name/--emoji/--theme/--avatar or --from-identity.",
);
runtime.exit(1); runtime.exit(1);
return; return;
} }
@ -204,5 +216,6 @@ export async function agentsSetIdentityCommand(
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`); if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`); if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`); if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`); if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
} }

View File

@ -1,12 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import { import {
resolveAgentDir, resolveAgentDir,
resolveAgentWorkspaceDir, resolveAgentWorkspaceDir,
resolveDefaultAgentId, resolveDefaultAgentId,
} from "../agents/agent-scope.js"; } from "../agents/agent-scope.js";
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js"; import type { AgentIdentityFile } from "../agents/identity-file.js";
import {
identityHasValues,
loadAgentIdentityFromWorkspace,
parseIdentityMarkdown as parseIdentityMarkdownFile,
} from "../agents/identity-file.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
@ -28,14 +30,7 @@ export type AgentSummary = {
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number]; type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
export type AgentIdentity = { export type AgentIdentity = AgentIdentityFile;
name?: string;
emoji?: string;
creature?: string;
vibe?: string;
theme?: string;
avatar?: string;
};
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] { export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list; const list = cfg.agents?.list;
@ -74,47 +69,13 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
} }
export function parseIdentityMarkdown(content: string): AgentIdentity { export function parseIdentityMarkdown(content: string): AgentIdentity {
const identity: AgentIdentity = {}; return parseIdentityMarkdownFile(content);
const lines = content.split(/\r?\n/);
for (const line of lines) {
const cleaned = line.trim().replace(/^\s*-\s*/, "");
const colonIndex = cleaned.indexOf(":");
if (colonIndex === -1) continue;
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
const value = cleaned
.slice(colonIndex + 1)
.replace(/^[*_]+|[*_]+$/g, "")
.trim();
if (!value) continue;
if (label === "name") identity.name = value;
if (label === "emoji") identity.emoji = value;
if (label === "creature") identity.creature = value;
if (label === "vibe") identity.vibe = value;
if (label === "theme") identity.theme = value;
if (label === "avatar") identity.avatar = value;
}
return identity;
} }
export function loadAgentIdentity(workspace: string): AgentIdentity | null { export function loadAgentIdentity(workspace: string): AgentIdentity | null {
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME); const parsed = loadAgentIdentityFromWorkspace(workspace);
try { if (!parsed) return null;
const content = fs.readFileSync(identityPath, "utf-8"); return identityHasValues(parsed) ? parsed : null;
const parsed = parseIdentityMarkdown(content);
if (
!parsed.name &&
!parsed.emoji &&
!parsed.theme &&
!parsed.creature &&
!parsed.vibe &&
!parsed.avatar
) {
return null;
}
return parsed;
} catch {
return null;
}
} }
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] { export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {

View File

@ -54,7 +54,13 @@ describe("agents set-identity command", () => {
await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(workspace, { recursive: true });
await fs.writeFile( await fs.writeFile(
path.join(workspace, "IDENTITY.md"), path.join(workspace, "IDENTITY.md"),
["- Name: Clawd", "- Creature: helpful sloth", "- Emoji: :)", ""].join("\n"), [
"- Name: Clawd",
"- Creature: helpful sloth",
"- Emoji: :)",
"- Avatar: avatars/clawd.png",
"",
].join("\n"),
"utf-8", "utf-8",
); );
@ -81,6 +87,7 @@ describe("agents set-identity command", () => {
name: "Clawd", name: "Clawd",
theme: "helpful sloth", theme: "helpful sloth",
emoji: ":)", emoji: ":)",
avatar: "avatars/clawd.png",
}); });
}); });
@ -115,7 +122,13 @@ describe("agents set-identity command", () => {
await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(workspace, { recursive: true });
await fs.writeFile( await fs.writeFile(
path.join(workspace, "IDENTITY.md"), path.join(workspace, "IDENTITY.md"),
["- Name: Clawd", "- Theme: space lobster", "- Emoji: :)", ""].join("\n"), [
"- Name: Clawd",
"- Theme: space lobster",
"- Emoji: :)",
"- Avatar: avatars/clawd.png",
"",
].join("\n"),
"utf-8", "utf-8",
); );
@ -125,7 +138,13 @@ describe("agents set-identity command", () => {
}); });
await agentsSetIdentityCommand( await agentsSetIdentityCommand(
{ workspace, fromIdentity: true, name: "Nova", emoji: "🦞" }, {
workspace,
fromIdentity: true,
name: "Nova",
emoji: "🦞",
avatar: "https://example.com/override.png",
},
runtime, runtime,
); );
@ -137,6 +156,7 @@ describe("agents set-identity command", () => {
name: "Nova", name: "Nova",
theme: "space lobster", theme: "space lobster",
emoji: "🦞", emoji: "🦞",
avatar: "https://example.com/override.png",
}); });
}); });
@ -147,9 +167,13 @@ describe("agents set-identity command", () => {
await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(workspace, { recursive: true });
await fs.writeFile( await fs.writeFile(
identityPath, identityPath,
["- **Name:** C-3PO", "- **Creature:** Flustered Protocol Droid", "- **Emoji:** 🤖", ""].join( [
"\n", "- **Name:** C-3PO",
), "- **Creature:** Flustered Protocol Droid",
"- **Emoji:** 🤖",
"- **Avatar:** avatars/c3po.png",
"",
].join("\n"),
"utf-8", "utf-8",
); );
@ -168,6 +192,53 @@ describe("agents set-identity command", () => {
name: "C-3PO", name: "C-3PO",
theme: "Flustered Protocol Droid", theme: "Flustered Protocol Droid",
emoji: "🤖", emoji: "🤖",
avatar: "avatars/c3po.png",
});
});
it("accepts avatar-only identity from IDENTITY.md", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-"));
const workspace = path.join(root, "work");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
path.join(workspace, "IDENTITY.md"),
"- Avatar: avatars/only.png\n",
"utf-8",
);
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: { agents: { list: [{ id: "main", workspace }] } },
});
await agentsSetIdentityCommand({ workspace, fromIdentity: true }, runtime);
const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
agents?: { list?: Array<{ id: string; identity?: Record<string, string> }> };
};
const main = written.agents?.list?.find((entry) => entry.id === "main");
expect(main?.identity).toEqual({
avatar: "avatars/only.png",
});
});
it("accepts avatar-only updates via flags", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: { agents: { list: [{ id: "main" }] } },
});
await agentsSetIdentityCommand(
{ agent: "main", avatar: "https://example.com/avatar.png" },
runtime,
);
const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
agents?: { list?: Array<{ id: string; identity?: Record<string, string> }> };
};
const main = written.agents?.list?.find((entry) => entry.id === "main");
expect(main?.identity).toEqual({
avatar: "https://example.com/avatar.png",
}); });
}); });

View File

@ -181,6 +181,10 @@ describe("gateway-status command", () => {
expect(startSshPortForward).toHaveBeenCalledTimes(1); expect(startSshPortForward).toHaveBeenCalledTimes(1);
expect(probeGateway).toHaveBeenCalled(); expect(probeGateway).toHaveBeenCalled();
const tunnelCall = probeGateway.mock.calls.find(
(call) => typeof call?.[0]?.url === "string" && call[0].url.startsWith("ws://127.0.0.1:"),
)?.[0] as { auth?: { token?: string } } | undefined;
expect(tunnelCall?.auth?.token).toBe("rtok");
expect(sshStop).toHaveBeenCalledTimes(1); expect(sshStop).toHaveBeenCalledTimes(1);
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<string, unknown>; const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<string, unknown>;

View File

@ -134,7 +134,7 @@ export function resolveAuthForTarget(
return { token: tokenOverride, password: passwordOverride }; return { token: tokenOverride, password: passwordOverride };
} }
if (target.kind === "configRemote") { if (target.kind === "configRemote" || target.kind === "sshTunnel") {
const token = const token =
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : ""; typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password; const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;

View File

@ -0,0 +1,54 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
import { withTempHome } from "./test-helpers.js";
describe("identity avatar validation", () => {
it("accepts workspace-relative avatar paths", async () => {
await withTempHome(async (home) => {
const workspace = path.join(home, "clawd");
const res = validateConfigObject({
agents: {
list: [{ id: "main", workspace, identity: { avatar: "avatars/clawd.png" } }],
},
});
expect(res.ok).toBe(true);
});
});
it("accepts http(s) and data avatars", async () => {
await withTempHome(async (home) => {
const workspace = path.join(home, "clawd");
const httpRes = validateConfigObject({
agents: {
list: [{ id: "main", workspace, identity: { avatar: "https://example.com/avatar.png" } }],
},
});
expect(httpRes.ok).toBe(true);
const dataRes = validateConfigObject({
agents: {
list: [{ id: "main", workspace, identity: { avatar: "data:image/png;base64,AAA" } }],
},
});
expect(dataRes.ok).toBe(true);
});
});
it("rejects avatar paths outside workspace", async () => {
await withTempHome(async (home) => {
const workspace = path.join(home, "clawd");
const res = validateConfigObject({
agents: {
list: [{ id: "main", workspace, identity: { avatar: "../oops.png" } }],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("agents.list.0.identity.avatar");
}
});
});
});

View File

@ -119,6 +119,7 @@ const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"agents.list.*.identity.avatar": "Identity Avatar",
"gateway.remote.url": "Remote Gateway URL", "gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshTarget": "Remote Gateway SSH Target",
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
@ -260,6 +261,8 @@ const FIELD_LABELS: Record<string, string> = {
"commands.restart": "Allow Restart", "commands.restart": "Allow Restart",
"commands.useAccessGroups": "Use Access Groups", "commands.useAccessGroups": "Use Access Groups",
"ui.seamColor": "Accent Color", "ui.seamColor": "Accent Color",
"ui.assistant.name": "Assistant Name",
"ui.assistant.avatar": "Assistant Avatar",
"browser.controlUrl": "Browser Control URL", "browser.controlUrl": "Browser Control URL",
"browser.snapshotDefaults": "Browser Snapshot Defaults", "browser.snapshotDefaults": "Browser Snapshot Defaults",
"browser.snapshotDefaults.mode": "Browser Snapshot Mode", "browser.snapshotDefaults.mode": "Browser Snapshot Mode",
@ -315,6 +318,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
"channels.signal.account": "Signal Account", "channels.signal.account": "Signal Account",
"channels.imessage.cliPath": "iMessage CLI Path", "channels.imessage.cliPath": "iMessage CLI Path",
"agents.list[].identity.avatar": "Agent Avatar",
"plugins.enabled": "Enable Plugins", "plugins.enabled": "Enable Plugins",
"plugins.allow": "Plugin Allowlist", "plugins.allow": "Plugin Allowlist",
"plugins.deny": "Plugin Denylist", "plugins.deny": "Plugin Denylist",
@ -344,6 +348,8 @@ const FIELD_HELP: Record<string, string> = {
"gateway.remote.sshTarget": "gateway.remote.sshTarget":
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
"agents.list[].identity.avatar":
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
"gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.", "gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.",
"gateway.auth.password": "Required for Tailscale funnel.", "gateway.auth.password": "Required for Tailscale funnel.",
"gateway.controlUi.basePath": "gateway.controlUi.basePath":
@ -511,6 +517,8 @@ const FIELD_HELP: Record<string, string> = {
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).", "Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
"plugins.installs.*.version": "Version recorded at install time (if available).", "plugins.installs.*.version": "Version recorded at install time (if available).",
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.", "plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
"agents.list.*.identity.avatar":
"Agent avatar (workspace-relative path, http(s) URL, or data URI).",
"agents.defaults.model.primary": "Primary model (provider/model).", "agents.defaults.model.primary": "Primary model (provider/model).",
"agents.defaults.model.fallbacks": "agents.defaults.model.fallbacks":
"Ordered fallback models (provider/model). Used when the primary model fails.", "Ordered fallback models (provider/model). Used when the primary model fails.",
@ -616,6 +624,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…", "gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
"gateway.remote.sshTarget": "user@host", "gateway.remote.sshTarget": "user@host",
"gateway.controlUi.basePath": "/clawdbot", "gateway.controlUi.basePath": "/clawdbot",
"agents.list[].identity.avatar": "avatars/clawd.png",
}; };
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];

View File

@ -154,6 +154,6 @@ export type IdentityConfig = {
name?: string; name?: string;
theme?: string; theme?: string;
emoji?: string; emoji?: string;
/** Path to a custom avatar image (relative to workspace or absolute). */ /** Avatar image: workspace-relative path, http(s) URL, or data URI. */
avatar?: string; avatar?: string;
}; };

View File

@ -65,6 +65,12 @@ export type ClawdbotConfig = {
ui?: { ui?: {
/** Accent color for Clawdbot UI chrome (hex). */ /** Accent color for Clawdbot UI chrome (hex). */
seamColor?: string; seamColor?: string;
assistant?: {
/** Assistant display name for UI surfaces. */
name?: string;
/** Assistant avatar (emoji, short text, or image URL/data URI). */
avatar?: string;
};
}; };
skills?: SkillsConfig; skills?: SkillsConfig;
plugins?: PluginsConfig; plugins?: PluginsConfig;

View File

@ -1,3 +1,5 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CHANNEL_IDS } from "../channels/registry.js"; import { CHANNEL_IDS } from "../channels/registry.js";
import { import {
@ -13,6 +15,60 @@ import { findLegacyConfigIssues } from "./legacy.js";
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js"; import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
import { ClawdbotSchema } from "./zod-schema.js"; import { ClawdbotSchema } from "./zod-schema.js";
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean {
const workspaceRoot = path.resolve(workspaceDir);
const resolved = path.resolve(workspaceRoot, value);
const relative = path.relative(workspaceRoot, resolved);
if (relative === "") return true;
if (relative.startsWith("..")) return false;
return !path.isAbsolute(relative);
}
function validateIdentityAvatar(config: ClawdbotConfig): ConfigValidationIssue[] {
const agents = config.agents?.list;
if (!Array.isArray(agents) || agents.length === 0) return [];
const issues: ConfigValidationIssue[] = [];
for (const [index, entry] of agents.entries()) {
if (!entry || typeof entry !== "object") continue;
const avatarRaw = entry.identity?.avatar;
if (typeof avatarRaw !== "string") continue;
const avatar = avatarRaw.trim();
if (!avatar) continue;
if (AVATAR_DATA_RE.test(avatar) || AVATAR_HTTP_RE.test(avatar)) continue;
if (avatar.startsWith("~")) {
issues.push({
path: `agents.list.${index}.identity.avatar`,
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
});
continue;
}
const hasScheme = AVATAR_SCHEME_RE.test(avatar);
if (hasScheme && !WINDOWS_ABS_RE.test(avatar)) {
issues.push({
path: `agents.list.${index}.identity.avatar`,
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
});
continue;
}
const workspaceDir = resolveAgentWorkspaceDir(
config,
entry.id ?? resolveDefaultAgentId(config),
);
if (!isWorkspaceAvatarPath(avatar, workspaceDir)) {
issues.push({
path: `agents.list.${index}.identity.avatar`,
message: "identity.avatar must stay within the agent workspace.",
});
}
}
return issues;
}
export function validateConfigObject( export function validateConfigObject(
raw: unknown, raw: unknown,
): { ok: true; config: ClawdbotConfig } | { ok: false; issues: ConfigValidationIssue[] } { ): { ok: true; config: ClawdbotConfig } | { ok: false; issues: ConfigValidationIssue[] } {
@ -48,6 +104,10 @@ export function validateConfigObject(
], ],
}; };
} }
const avatarIssues = validateIdentityAvatar(validated.data as ClawdbotConfig);
if (avatarIssues.length > 0) {
return { ok: false, issues: avatarIssues };
}
return { return {
ok: true, ok: true,
config: applyModelDefaults( config: applyModelDefaults(

View File

@ -86,6 +86,7 @@ export const IdentitySchema = z
name: z.string().optional(), name: z.string().optional(),
theme: z.string().optional(), theme: z.string().optional(),
emoji: z.string().optional(), emoji: z.string().optional(),
avatar: z.string().optional(),
}) })
.strict() .strict()
.optional(); .optional();

View File

@ -155,6 +155,13 @@ export const ClawdbotSchema = z
ui: z ui: z
.object({ .object({
seamColor: HexColorSchema.optional(), seamColor: HexColorSchema.optional(),
assistant: z
.object({
name: z.string().max(50).optional(),
avatar: z.string().max(200).optional(),
})
.strict()
.optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
describe("resolveAssistantIdentity avatar normalization", () => {
it("drops sentence-like avatar placeholders", () => {
const cfg: ClawdbotConfig = {
ui: {
assistant: {
avatar: "workspace-relative path, http(s) URL, or data URI",
},
},
};
expect(resolveAssistantIdentity({ cfg }).avatar).toBe(DEFAULT_ASSISTANT_IDENTITY.avatar);
});
it("keeps short text avatars", () => {
const cfg: ClawdbotConfig = {
ui: {
assistant: {
avatar: "PS",
},
},
};
expect(resolveAssistantIdentity({ cfg }).avatar).toBe("PS");
});
it("keeps path avatars", () => {
const cfg: ClawdbotConfig = {
ui: {
assistant: {
avatar: "avatars/clawd.png",
},
},
};
expect(resolveAssistantIdentity({ cfg }).avatar).toBe("avatars/clawd.png");
});
});

View File

@ -0,0 +1,77 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveAgentIdentity } from "../agents/identity.js";
import { loadAgentIdentity } from "../commands/agents.config.js";
import type { ClawdbotConfig } from "../config/config.js";
import { normalizeAgentId } from "../routing/session-key.js";
const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200;
export const DEFAULT_ASSISTANT_IDENTITY = {
name: "Assistant",
avatar: "A",
};
export type AssistantIdentity = {
agentId: string;
name: string;
avatar: string;
};
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.length <= maxLength) return trimmed;
return trimmed.slice(0, maxLength);
}
function isAvatarUrl(value: string): boolean {
return /^https?:\/\//i.test(value) || /^data:image\//i.test(value);
}
function looksLikeAvatarPath(value: string): boolean {
if (/[\\/]/.test(value)) return true;
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
}
function normalizeAvatarValue(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (isAvatarUrl(trimmed)) return trimmed;
if (looksLikeAvatarPath(trimmed)) return trimmed;
if (!/\s/.test(trimmed) && trimmed.length <= 4) return trimmed;
return undefined;
}
export function resolveAssistantIdentity(params: {
cfg: ClawdbotConfig;
agentId?: string | null;
workspaceDir?: string | null;
}): AssistantIdentity {
const agentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.cfg));
const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId);
const configAssistant = params.cfg.ui?.assistant;
const agentIdentity = resolveAgentIdentity(params.cfg, agentId);
const fileIdentity = workspaceDir ? loadAgentIdentity(workspaceDir) : null;
const name =
coerceIdentityValue(configAssistant?.name, MAX_ASSISTANT_NAME) ??
coerceIdentityValue(agentIdentity?.name, MAX_ASSISTANT_NAME) ??
coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME) ??
DEFAULT_ASSISTANT_IDENTITY.name;
const avatarCandidates = [
coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR),
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR),
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR),
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR),
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR),
];
const avatar =
avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ??
DEFAULT_ASSISTANT_IDENTITY.avatar;
return { agentId, name, avatar };
}

View File

@ -3,10 +3,16 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
const ROOT_PREFIX = "/"; const ROOT_PREFIX = "/";
const AVATAR_PREFIX = "/avatar";
export type ControlUiRequestOptions = { export type ControlUiRequestOptions = {
basePath?: string; basePath?: string;
config?: ClawdbotConfig;
agentId?: string;
}; };
export function normalizeControlUiBasePath(basePath?: string): string { export function normalizeControlUiBasePath(basePath?: string): string {
@ -62,6 +68,10 @@ function contentTypeForExt(ext: string): string {
case ".jpg": case ".jpg":
case ".jpeg": case ".jpeg":
return "image/jpeg"; return "image/jpeg";
case ".gif":
return "image/gif";
case ".webp":
return "image/webp";
case ".ico": case ".ico":
return "image/x-icon"; return "image/x-icon";
case ".txt": case ".txt":
@ -71,6 +81,83 @@ function contentTypeForExt(ext: string): string {
} }
} }
export type ControlUiAvatarResolution =
| { kind: "none"; reason: string }
| { kind: "local"; filePath: string }
| { kind: "remote"; url: string }
| { kind: "data"; url: string };
type ControlUiAvatarMeta = {
avatarUrl: string | null;
};
function sendJson(res: ServerResponse, status: number, body: unknown) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.end(JSON.stringify(body));
}
function buildAvatarUrl(basePath: string, agentId: string): string {
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
}
function isValidAgentId(agentId: string): boolean {
return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId);
}
export function handleControlUiAvatarRequest(
req: IncomingMessage,
res: ServerResponse,
opts: { basePath?: string; resolveAvatar: (agentId: string) => ControlUiAvatarResolution },
): boolean {
const urlRaw = req.url;
if (!urlRaw) return false;
if (req.method !== "GET" && req.method !== "HEAD") return false;
const url = new URL(urlRaw, "http://localhost");
const basePath = normalizeControlUiBasePath(opts.basePath);
const pathname = url.pathname;
const pathWithBase = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
if (!pathname.startsWith(pathWithBase)) return false;
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
const agentId = agentIdParts[0] ?? "";
if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) {
respondNotFound(res);
return true;
}
if (url.searchParams.get("meta") === "1") {
const resolved = opts.resolveAvatar(agentId);
const avatarUrl =
resolved.kind === "local"
? buildAvatarUrl(basePath, agentId)
: resolved.kind === "remote" || resolved.kind === "data"
? resolved.url
: null;
sendJson(res, 200, { avatarUrl } satisfies ControlUiAvatarMeta);
return true;
}
const resolved = opts.resolveAvatar(agentId);
if (resolved.kind !== "local") {
respondNotFound(res);
return true;
}
if (req.method === "HEAD") {
res.statusCode = 200;
res.setHeader("Content-Type", contentTypeForExt(path.extname(resolved.filePath).toLowerCase()));
res.setHeader("Cache-Control", "no-cache");
res.end();
return true;
}
serveFile(res, resolved.filePath);
return true;
}
function respondNotFound(res: ServerResponse) { function respondNotFound(res: ServerResponse) {
res.statusCode = 404; res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Type", "text/plain; charset=utf-8");
@ -86,11 +173,26 @@ function serveFile(res: ServerResponse, filePath: string) {
res.end(fs.readFileSync(filePath)); res.end(fs.readFileSync(filePath));
} }
function injectControlUiBasePath(html: string, basePath: string): string { interface ControlUiInjectionOpts {
const script = `<script>window.__CLAWDBOT_CONTROL_UI_BASE_PATH__=${JSON.stringify( basePath: string;
basePath, assistantName?: string;
)};</script>`; assistantAvatar?: string;
if (html.includes("__CLAWDBOT_CONTROL_UI_BASE_PATH__")) return html; }
function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): string {
const { basePath, assistantName, assistantAvatar } = opts;
const script =
`<script>` +
`window.__CLAWDBOT_CONTROL_UI_BASE_PATH__=${JSON.stringify(basePath)};` +
`window.__CLAWDBOT_ASSISTANT_NAME__=${JSON.stringify(
assistantName ?? DEFAULT_ASSISTANT_IDENTITY.name,
)};` +
`window.__CLAWDBOT_ASSISTANT_AVATAR__=${JSON.stringify(
assistantAvatar ?? DEFAULT_ASSISTANT_IDENTITY.avatar,
)};` +
`</script>`;
// Check if already injected
if (html.includes("__CLAWDBOT_ASSISTANT_NAME__")) return html;
const headClose = html.indexOf("</head>"); const headClose = html.indexOf("</head>");
if (headClose !== -1) { if (headClose !== -1) {
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`; return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
@ -98,11 +200,27 @@ function injectControlUiBasePath(html: string, basePath: string): string {
return `${script}${html}`; return `${script}${html}`;
} }
function serveIndexHtml(res: ServerResponse, indexPath: string, basePath: string) { interface ServeIndexHtmlOpts {
basePath: string;
config?: ClawdbotConfig;
agentId?: string;
}
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts;
const identity = config
? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY;
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8"); const raw = fs.readFileSync(indexPath, "utf8");
res.end(injectControlUiBasePath(raw, basePath)); res.end(
injectControlUiConfig(raw, {
basePath,
assistantName: identity.name,
assistantAvatar: identity.avatar,
}),
);
} }
function isSafeRelativePath(relPath: string) { function isSafeRelativePath(relPath: string) {
@ -181,7 +299,11 @@ export function handleControlUiHttpRequest(
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
if (path.basename(filePath) === "index.html") { if (path.basename(filePath) === "index.html") {
serveIndexHtml(res, filePath, basePath); serveIndexHtml(res, filePath, {
basePath,
config: opts?.config,
agentId: opts?.agentId,
});
return true; return true;
} }
serveFile(res, filePath); serveFile(res, filePath);
@ -191,7 +313,11 @@ export function handleControlUiHttpRequest(
// SPA fallback (client-side router): serve index.html for unknown paths. // SPA fallback (client-side router): serve index.html for unknown paths.
const indexPath = path.join(root, "index.html"); const indexPath = path.join(root, "index.html");
if (fs.existsSync(indexPath)) { if (fs.existsSync(indexPath)) {
serveIndexHtml(res, indexPath, basePath); serveIndexHtml(res, indexPath, {
basePath,
config: opts?.config,
agentId: opts?.agentId,
});
return true; return true;
} }

View File

@ -2,6 +2,10 @@ import AjvPkg, { type ErrorObject } from "ajv";
import { import {
type AgentEvent, type AgentEvent,
AgentEventSchema, AgentEventSchema,
type AgentIdentityParams,
AgentIdentityParamsSchema,
type AgentIdentityResult,
AgentIdentityResultSchema,
AgentParamsSchema, AgentParamsSchema,
type AgentSummary, type AgentSummary,
AgentSummarySchema, AgentSummarySchema,
@ -136,6 +140,8 @@ import {
SessionsListParamsSchema, SessionsListParamsSchema,
type SessionsPatchParams, type SessionsPatchParams,
SessionsPatchParamsSchema, SessionsPatchParamsSchema,
type SessionsPreviewParams,
SessionsPreviewParamsSchema,
type SessionsResetParams, type SessionsResetParams,
SessionsResetParamsSchema, SessionsResetParamsSchema,
type SessionsResolveParams, type SessionsResolveParams,
@ -198,6 +204,8 @@ export const validateEventFrame = ajv.compile<EventFrame>(EventFrameSchema);
export const validateSendParams = ajv.compile(SendParamsSchema); export const validateSendParams = ajv.compile(SendParamsSchema);
export const validatePollParams = ajv.compile<PollParams>(PollParamsSchema); export const validatePollParams = ajv.compile<PollParams>(PollParamsSchema);
export const validateAgentParams = ajv.compile(AgentParamsSchema); export const validateAgentParams = ajv.compile(AgentParamsSchema);
export const validateAgentIdentityParams =
ajv.compile<AgentIdentityParams>(AgentIdentityParamsSchema);
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema); export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema); export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema); export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema);
@ -223,6 +231,9 @@ export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams
); );
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema); export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema); export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
export const validateSessionsPreviewParams = ajv.compile<SessionsPreviewParams>(
SessionsPreviewParamsSchema,
);
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>( export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
SessionsResolveParamsSchema, SessionsResolveParamsSchema,
); );
@ -359,6 +370,8 @@ export {
SendParamsSchema, SendParamsSchema,
PollParamsSchema, PollParamsSchema,
AgentParamsSchema, AgentParamsSchema,
AgentIdentityParamsSchema,
AgentIdentityResultSchema,
WakeParamsSchema, WakeParamsSchema,
NodePairRequestParamsSchema, NodePairRequestParamsSchema,
NodePairListParamsSchema, NodePairListParamsSchema,
@ -368,6 +381,7 @@ export {
NodeListParamsSchema, NodeListParamsSchema,
NodeInvokeParamsSchema, NodeInvokeParamsSchema,
SessionsListParamsSchema, SessionsListParamsSchema,
SessionsPreviewParamsSchema,
SessionsPatchParamsSchema, SessionsPatchParamsSchema,
SessionsResetParamsSchema, SessionsResetParamsSchema,
SessionsDeleteParamsSchema, SessionsDeleteParamsSchema,
@ -432,6 +446,8 @@ export type {
ErrorShape, ErrorShape,
StateVersion, StateVersion,
AgentEvent, AgentEvent,
AgentIdentityParams,
AgentIdentityResult,
AgentWaitParams, AgentWaitParams,
ChatEvent, ChatEvent,
TickEvent, TickEvent,
@ -478,6 +494,7 @@ export type {
NodeInvokeResultParams, NodeInvokeResultParams,
NodeEventParams, NodeEventParams,
SessionsListParams, SessionsListParams,
SessionsPreviewParams,
SessionsResolveParams, SessionsResolveParams,
SessionsPatchParams, SessionsPatchParams,
SessionsResetParams, SessionsResetParams,

View File

@ -68,6 +68,23 @@ export const AgentParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const AgentIdentityParamsSchema = Type.Object(
{
agentId: Type.Optional(NonEmptyString),
sessionKey: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const AgentIdentityResultSchema = Type.Object(
{
agentId: NonEmptyString,
name: Type.Optional(NonEmptyString),
avatar: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const AgentWaitParamsSchema = Type.Object( export const AgentWaitParamsSchema = Type.Object(
{ {
runId: NonEmptyString, runId: NonEmptyString,

View File

@ -17,6 +17,18 @@ export const AgentSummarySchema = Type.Object(
{ {
id: NonEmptyString, id: NonEmptyString,
name: Type.Optional(NonEmptyString), name: Type.Optional(NonEmptyString),
identity: Type.Optional(
Type.Object(
{
name: Type.Optional(NonEmptyString),
theme: Type.Optional(NonEmptyString),
emoji: Type.Optional(NonEmptyString),
avatar: Type.Optional(NonEmptyString),
avatarUrl: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );

View File

@ -2,6 +2,8 @@ import type { TSchema } from "@sinclair/typebox";
import { import {
AgentEventSchema, AgentEventSchema,
AgentIdentityParamsSchema,
AgentIdentityResultSchema,
AgentParamsSchema, AgentParamsSchema,
AgentWaitParamsSchema, AgentWaitParamsSchema,
PollParamsSchema, PollParamsSchema,
@ -106,6 +108,7 @@ import {
SessionsDeleteParamsSchema, SessionsDeleteParamsSchema,
SessionsListParamsSchema, SessionsListParamsSchema,
SessionsPatchParamsSchema, SessionsPatchParamsSchema,
SessionsPreviewParamsSchema,
SessionsResetParamsSchema, SessionsResetParamsSchema,
SessionsResolveParamsSchema, SessionsResolveParamsSchema,
} from "./sessions.js"; } from "./sessions.js";
@ -136,6 +139,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SendParams: SendParamsSchema, SendParams: SendParamsSchema,
PollParams: PollParamsSchema, PollParams: PollParamsSchema,
AgentParams: AgentParamsSchema, AgentParams: AgentParamsSchema,
AgentIdentityParams: AgentIdentityParamsSchema,
AgentIdentityResult: AgentIdentityResultSchema,
AgentWaitParams: AgentWaitParamsSchema, AgentWaitParams: AgentWaitParamsSchema,
WakeParams: WakeParamsSchema, WakeParams: WakeParamsSchema,
NodePairRequestParams: NodePairRequestParamsSchema, NodePairRequestParams: NodePairRequestParamsSchema,
@ -151,6 +156,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
NodeEventParams: NodeEventParamsSchema, NodeEventParams: NodeEventParamsSchema,
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema, NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
SessionsListParams: SessionsListParamsSchema, SessionsListParams: SessionsListParamsSchema,
SessionsPreviewParams: SessionsPreviewParamsSchema,
SessionsResolveParams: SessionsResolveParamsSchema, SessionsResolveParams: SessionsResolveParamsSchema,
SessionsPatchParams: SessionsPatchParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema,
SessionsResetParams: SessionsResetParamsSchema, SessionsResetParams: SessionsResetParamsSchema,

View File

@ -26,6 +26,15 @@ export const SessionsListParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsPreviewParamsSchema = Type.Object(
{
keys: Type.Array(NonEmptyString, { minItems: 1 }),
limit: Type.Optional(Type.Integer({ minimum: 1 })),
maxChars: Type.Optional(Type.Integer({ minimum: 20 })),
},
{ additionalProperties: false },
);
export const SessionsResolveParamsSchema = Type.Object( export const SessionsResolveParamsSchema = Type.Object(
{ {
key: Type.Optional(NonEmptyString), key: Type.Optional(NonEmptyString),

View File

@ -2,6 +2,8 @@ import type { Static } from "@sinclair/typebox";
import type { import type {
AgentEventSchema, AgentEventSchema,
AgentIdentityParamsSchema,
AgentIdentityResultSchema,
AgentWaitParamsSchema, AgentWaitParamsSchema,
PollParamsSchema, PollParamsSchema,
WakeParamsSchema, WakeParamsSchema,
@ -99,6 +101,7 @@ import type {
SessionsDeleteParamsSchema, SessionsDeleteParamsSchema,
SessionsListParamsSchema, SessionsListParamsSchema,
SessionsPatchParamsSchema, SessionsPatchParamsSchema,
SessionsPreviewParamsSchema,
SessionsResetParamsSchema, SessionsResetParamsSchema,
SessionsResolveParamsSchema, SessionsResolveParamsSchema,
} from "./sessions.js"; } from "./sessions.js";
@ -125,6 +128,8 @@ export type PresenceEntry = Static<typeof PresenceEntrySchema>;
export type ErrorShape = Static<typeof ErrorShapeSchema>; export type ErrorShape = Static<typeof ErrorShapeSchema>;
export type StateVersion = Static<typeof StateVersionSchema>; export type StateVersion = Static<typeof StateVersionSchema>;
export type AgentEvent = Static<typeof AgentEventSchema>; export type AgentEvent = Static<typeof AgentEventSchema>;
export type AgentIdentityParams = Static<typeof AgentIdentityParamsSchema>;
export type AgentIdentityResult = Static<typeof AgentIdentityResultSchema>;
export type PollParams = Static<typeof PollParamsSchema>; export type PollParams = Static<typeof PollParamsSchema>;
export type AgentWaitParams = Static<typeof AgentWaitParamsSchema>; export type AgentWaitParams = Static<typeof AgentWaitParamsSchema>;
export type WakeParams = Static<typeof WakeParamsSchema>; export type WakeParams = Static<typeof WakeParamsSchema>;
@ -140,6 +145,7 @@ export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>; export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
export type NodeEventParams = Static<typeof NodeEventParamsSchema>; export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
export type SessionsListParams = Static<typeof SessionsListParamsSchema>; export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsPreviewParams = Static<typeof SessionsPreviewParamsSchema>;
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>; export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>; export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>; export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;

View File

@ -9,9 +9,11 @@ import type { TlsOptions } from "node:tls";
import type { WebSocketServer } from "ws"; import type { WebSocketServer } from "ws";
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { CanvasHostHandler } from "../canvas-host/server.js";
import { loadConfig } from "../config/config.js";
import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { createSubsystemLogger } from "../logging/subsystem.js";
import { handleSlackHttpRequest } from "../slack/http/index.js"; import { handleSlackHttpRequest } from "../slack/http/index.js";
import { handleControlUiHttpRequest } from "./control-ui.js"; import { resolveAgentAvatar } from "../agents/identity-avatar.js";
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
import { import {
extractHookToken, extractHookToken,
getHookChannelError, getHookChannelError,
@ -244,9 +246,17 @@ export function createGatewayHttpServer(opts: {
if (await canvasHost.handleHttpRequest(req, res)) return; if (await canvasHost.handleHttpRequest(req, res)) return;
} }
if (controlUiEnabled) { if (controlUiEnabled) {
if (
handleControlUiAvatarRequest(req, res, {
basePath: controlUiBasePath,
resolveAvatar: (agentId) => resolveAgentAvatar(loadConfig(), agentId),
})
)
return;
if ( if (
handleControlUiHttpRequest(req, res, { handleControlUiHttpRequest(req, res, {
basePath: controlUiBasePath, basePath: controlUiBasePath,
config: loadConfig(),
}) })
) )
return; return;

View File

@ -34,6 +34,7 @@ const BASE_METHODS = [
"voicewake.get", "voicewake.get",
"voicewake.set", "voicewake.set",
"sessions.list", "sessions.list",
"sessions.preview",
"sessions.patch", "sessions.patch",
"sessions.reset", "sessions.reset",
"sessions.delete", "sessions.delete",
@ -68,6 +69,7 @@ const BASE_METHODS = [
"system-event", "system-event",
"send", "send",
"agent", "agent",
"agent.identity.get",
"agent.wait", "agent.wait",
// WebChat WebSocket-native chat methods // WebChat WebSocket-native chat methods
"chat.history", "chat.history",

Some files were not shown because too many files have changed in this diff Show More