Compare commits

..
Author SHA1 Message Date
Peter Steinberger 3c163c71b5 fix: export SECTION_META for config form (#1418) (thanks @MaudeBot) 2026-01-22 04:33:37 +00:00
Maude Bot 277881b52f fix(ui): export SECTION_META from config-form module
Export the SECTION_META constant from config-form.render.ts and
re-export it through config-form.ts so it can be imported by config.ts.

This fixes a runtime error where SECTION_META was being referenced
but not properly exported from its source module.
2026-01-22 04:16:51 +00:00
199 changed files with 1113 additions and 9052 deletions

No files matched your search

+48 -57
View File
@@ -2,71 +2,64 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.21-2 ## 2026.1.22
### Fixes
- 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
- Hooks: suppress session-memory confirmation output. (#1464) Thanks @alfranli123.
## 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
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07. - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- 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 - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui - Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
- 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
- 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
- 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
- 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). - 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). - Signal: add typing indicators and DM read receipts via signal-cli.
- Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets. - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
- 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:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. - **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t 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. - Config: avoid stack traces for invalid configs and log the config path.
- 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. - Doctor: warn when gateway.mode is unset with configure/config guidance.
- 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. - OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
- Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names. - macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
- 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)
- 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. - 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. - Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
- Zalo Personal: tolerate ANSI/log-prefixed JSON output from `zca`. (#1379) Thanks @ptn1411. - UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat. - UI: export config form section metadata for shared usage. (#1418) Thanks @MaudeBot.
- 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) ## 2026.1.21
- 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) ### Changes
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
### 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
### Fixes
- 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)
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. - Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
- Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session. - Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
- 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
@@ -101,7 +94,6 @@ 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.
@@ -111,7 +103,6 @@ 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
+18 -18
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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=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/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=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/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=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> <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>
+82 -75
View File
@@ -2,80 +2,6 @@
<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 don’t 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 aren’t 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>
@@ -282,5 +208,86 @@ 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>
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2", "originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
"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" : "c8679fbd37416a8780fe43be88a497ff16209e2d", "revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0" "version" : "0.1.0"
} }
}, },
@@ -74,7 +74,6 @@ 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")
@@ -129,7 +128,6 @@ 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 {
@@ -190,11 +188,8 @@ 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 \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " + "Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
"or clear it on the gateway. " + "or clear it on the gateway. " +
"Reason: \(reason)" "Reason: \(reason)"
} }
@@ -305,27 +300,6 @@ 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 {
@@ -475,8 +475,8 @@ enum ExecApprovalsStore {
private static func mergeAgents( private static func mergeAgents(
current: ExecApprovalsAgent, current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent legacy: 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,12 +486,8 @@ enum ExecApprovalsStore {
seen.insert(key) seen.insert(key)
allowlist.append(entry) allowlist.append(entry)
} }
for entry in current.allowlist ?? [] { for entry in current.allowlist ?? [] { append(entry) }
append(entry) for entry in legacy.allowlist ?? [] { append(entry) }
}
for entry in legacy.allowlist ?? [] {
append(entry)
}
return ExecApprovalsAgent( return ExecApprovalsAgent(
security: current.security ?? legacy.security, security: current.security ?? legacy.security,
@@ -1,6 +1,5 @@
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import CoreGraphics
import Foundation import Foundation
import OSLog import OSLog
@@ -45,7 +44,6 @@ 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,
@@ -58,66 +56,4 @@ 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
@@ -13,7 +13,6 @@ 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 {
@@ -216,15 +215,36 @@ 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."
alert.accessoryView = self.buildAccessoryView(request) var details = "Clawdbot wants to run:\n\n\(request.command)"
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:
@@ -235,110 +255,6 @@ 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
@@ -413,8 +329,7 @@ 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:
@@ -69,7 +69,6 @@ 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"
@@ -250,11 +249,6 @@ 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()
@@ -541,30 +535,6 @@ 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(
@@ -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 == "tailnet" else { return nil } guard bind == "auto" 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,13 +560,16 @@ actor GatewayEndpointStore {
{ {
switch bindMode { switch bindMode {
case "tailnet": case "tailnet":
tailscaleIP ?? "127.0.0.1" return tailscaleIP ?? "127.0.0.1"
case "auto": case "auto":
"127.0.0.1" if let tailscaleIP, !tailscaleIP.isEmpty {
return tailscaleIP
}
return "127.0.0.1"
case "custom": case "custom":
customBindHost ?? "127.0.0.1" return customBindHost ?? "127.0.0.1"
default: default:
"127.0.0.1" return "127.0.0.1"
} }
} }
} }
@@ -115,7 +115,7 @@ extension GatewayLaunchAgentManager {
quiet: Bool) async -> CommandResult quiet: Bool) async -> CommandResult
{ {
let command = CommandResolver.clawdbotCommand( let command = CommandResolver.clawdbotCommand(
subcommand: "gateway", subcommand: "daemon",
extraArgs: self.withJsonFlag(args), extraArgs: self.withJsonFlag(args),
// Launchd management must always run locally, even if remote mode is configured. // Launchd management must always run locally, even if remote mode is configured.
configRoot: ["gateway": ["mode": "local"]]) configRoot: ["gateway": ["mode": "local"]])
@@ -2,12 +2,15 @@ import AppKit
import ClawdbotDiscovery import ClawdbotDiscovery
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit import ClawdbotKit
import CoreLocation
import Observation import Observation
import SwiftUI import SwiftUI
struct GeneralSettings: View { struct GeneralSettings: View {
@Bindable var state: AppState @Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel( @State private var gatewayDiscovery = GatewayDiscoveryModel(
@@ -17,6 +20,7 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false @State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
@@ -56,6 +60,27 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled) binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
}
.labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
SettingsToggleRow( SettingsToggleRow(
title: "Enable Peekaboo Bridge", title: "Enable Peekaboo Bridge",
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
@@ -81,12 +106,27 @@ struct GeneralSettings: View {
.onAppear { .onAppear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.refreshGatewayStatus() self.refreshGatewayStatus()
self.lastLocationModeRaw = self.locationModeRaw
} }
.onChange(of: self.state.canvasEnabled) { _, enabled in .onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled { if !enabled {
CanvasManager.shared.hideAll() CanvasManager.shared.hideAll()
} }
} }
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
} }
private var activeBinding: Binding<Bool> { private var activeBinding: Binding<Bool> {
@@ -95,6 +135,26 @@ struct GeneralSettings: View {
set: { self.state.isPaused = !$0 }) set: { self.state.isPaused = !$0 })
} }
private var locationMode: ClawdbotLocationMode {
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus
let requireAlways = mode == .always
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
return true
}
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
}
private var connectionSection: some View { private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Clawdbot runs") Text("Clawdbot runs")
@@ -212,11 +272,6 @@ 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.")
@@ -1,5 +1,4 @@
import AppKit import AppKit
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
@@ -19,7 +18,6 @@ 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?
@@ -52,7 +50,6 @@ 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()
} }
@@ -99,50 +96,6 @@ 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)
} }
@@ -188,23 +141,14 @@ 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: statusText)), statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@@ -654,11 +598,8 @@ extension MenuSessionsInjector {
} }
guard self.isControlChannelConnected else { guard self.isControlChannelConnected else {
if self.cachedSnapshot != nil { self.cachedSnapshot = nil
self.cachedErrorText = "Gateway disconnected (showing cached)" self.cachedErrorText = nil
} else {
self.cachedErrorText = nil
}
self.cacheUpdatedAt = Date() self.cacheUpdatedAt = Date()
return return
} }
@@ -683,6 +624,8 @@ extension MenuSessionsInjector {
} }
guard self.isControlChannelConnected else { guard self.isControlChannelConnected else {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date() self.usageCacheUpdatedAt = Date()
return return
} }
@@ -705,6 +648,8 @@ extension MenuSessionsInjector {
} }
guard self.isControlChannelConnected else { guard self.isControlChannelConnected else {
self.cachedCostSummary = nil
self.cachedCostErrorText = nil
self.costCacheUpdatedAt = Date() self.costCacheUpdatedAt = Date()
return return
} }
@@ -679,8 +679,7 @@ 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:
@@ -1,6 +1,4 @@
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit
import CoreLocation
import SwiftUI import SwiftUI
struct PermissionsSettings: View { struct PermissionsSettings: View {
@@ -19,8 +17,6 @@ struct PermissionsSettings: View {
.padding(.horizontal, 2) .padding(.horizontal, 2)
.padding(.vertical, 6) .padding(.vertical, 6)
LocationAccessSettings()
Button("Restart onboarding") { self.showOnboarding() } Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() Spacer()
@@ -30,72 +26,6 @@ struct PermissionsSettings: View {
} }
} }
private struct LocationAccessSettings: View {
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
}
.labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
.onAppear {
self.lastLocationModeRaw = self.locationModeRaw
}
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
}
private var locationMode: ClawdbotLocationMode {
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus
let requireAlways = mode == .always
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
return true
}
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
}
}
struct PermissionStatusList: View { struct PermissionStatusList: View {
let status: [Capability: Bool] let status: [Capability: Bool]
let refresh: () async -> Void let refresh: () async -> Void
@@ -184,14 +184,6 @@ 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"],
@@ -20,13 +20,11 @@ actor RemoteTunnelManager {
tunnel.process.isRunning, tunnel.process.isRunning,
let local = tunnel.localPort let local = tunnel.localPort
{ {
let pid = tunnel.process.processIdentifier if await self.isTunnelHealthy(port: local) {
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( self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
"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
@@ -37,11 +35,19 @@ 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)
{ {
self.logger.info( if await self.isTunnelHealthy(port: desiredPort) {
"reusing existing SSH tunnel listener " + self.logger.info(
"localPort=\(desiredPort, privacy: .public) " + "reusing existing SSH tunnel listener " +
"pid=\(desc.pid, privacy: .public)") "localPort=\(desiredPort, privacy: .public) " +
return desiredPort "pid=\(desc.pid, privacy: .public)")
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
} }
@@ -82,6 +88,10 @@ 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 }
@@ -118,5 +128,21 @@ actor RemoteTunnelManager {
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
} }
// Keep tunnel reuse lightweight; restart only when the listener disappears. private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
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
}
} }
@@ -1,6 +1,5 @@
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol
import OSLog import OSLog
import SwiftUI import SwiftUI
@@ -32,80 +31,31 @@ actor SessionPreviewCache {
static let shared = SessionPreviewCache() static let shared = SessionPreviewCache()
private struct CacheEntry { private struct CacheEntry {
let snapshot: SessionMenuPreviewSnapshot let items: [SessionPreviewItem]
let updatedAt: Date let updatedAt: Date
} }
private var entries: [String: CacheEntry] = [:] private var entries: [String: CacheEntry] = [:]
func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? { func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? {
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.snapshot return entry.items
} }
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) { func store(items: [SessionPreviewItem], for sessionKey: String) {
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date()) self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
} }
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? { func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
self.entries[sessionKey]?.snapshot self.entries[sessionKey]?.items
}
}
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( func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
snapshot: SessionMenuPreviewSnapshot, self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
for sessionKey: String,
updatedAt: Date = Date())
{
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
} }
func _testReset() { func _testReset() {
@@ -224,44 +174,36 @@ 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.cachedSnapshot( if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
for: sessionKey, return self.snapshot(from: cached)
maxAge: cacheMaxAgeSeconds)
{
return cached
} }
do { do {
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems) let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey) let payload = try await AsyncTimeout.withTimeout(
return snapshot 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)
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 {
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) { let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
return fallback if let fallback {
return Self.snapshot(from: fallback)
} }
let errorDescription = String(describing: error) let errorDescription = String(describing: error)
Self.logger.warning( Self.logger.warning(
@@ -271,120 +213,18 @@ 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 {
let boundedItems = self.normalizeMaxItems(maxItems) min(max(maxItems * 3, 20), 120)
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
@@ -395,7 +235,7 @@ enum SessionMenuPreviewLoader {
return SessionPreviewItem(id: id, role: role, text: text) return SessionPreviewItem(id: id, role: role, text: text)
} }
let trimmed = built.suffix(boundedItems) let trimmed = built.suffix(maxItems)
return Array(trimmed.reversed()) return Array(trimmed.reversed())
} }
@@ -408,16 +248,12 @@ 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": .user case "user": return .user
case "assistant": .assistant case "assistant": return .assistant
case "system": .system case "system": return .system
case "tool": .tool case "tool": return .tool
default: .other default: return .other
} }
} }
@@ -480,16 +316,4 @@ 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")
}
} }
@@ -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": case "tailnet", "auto":
return tailnetIP ?? "127.0.0.1" return tailnetIP ?? "127.0.0.1"
default: default:
return "127.0.0.1" return "127.0.0.1"
@@ -552,44 +552,6 @@ 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?
@@ -925,27 +887,6 @@ 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?
@@ -1506,21 +1447,17 @@ 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
} }
} }
@@ -1,56 +0,0 @@
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)
}
}
@@ -140,14 +140,14 @@ import Testing
#expect(resolved.mode == .remote) #expect(resolved.mode == .remote)
} }
@Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() { @Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost( let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto", bindMode: "auto",
tailscaleIP: "100.64.1.2") tailscaleIP: "100.64.1.2")
#expect(host == "127.0.0.1") #expect(host == "100.64.1.2")
} }
@Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() { @Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost( let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto", bindMode: "auto",
tailscaleIP: nil) tailscaleIP: nil)
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -7,22 +7,20 @@ 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")]
let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready) await SessionPreviewCache.shared._testSet(items: items, for: "main")
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(loaded.status == .ready) #expect(snapshot.status == .ready)
#expect(loaded.items.count == 1) #expect(snapshot.items.count == 1)
#expect(loaded.items.first?.text == "Hi") #expect(snapshot.items.first?.text == "Hi")
} }
@Test func loaderReturnsEmptyWhenCachedEmpty() async { @Test func loaderReturnsEmptyWhenCachedEmpty() async {
await SessionPreviewCache.shared._testReset() await SessionPreviewCache.shared._testReset()
let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty) await SessionPreviewCache.shared._testSet(items: [], for: "main")
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
#expect(loaded.status == .empty) #expect(snapshot.status == .empty)
#expect(loaded.items.isEmpty) #expect(snapshot.items.isEmpty)
} }
} }
@@ -235,27 +235,6 @@ 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
@@ -12,7 +12,6 @@ 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
@@ -88,28 +87,36 @@ public struct ClawdbotChatView: View {
private var messageList: some View { private var messageList: some View {
ZStack { ZStack {
ScrollView { ScrollView {
LazyVStack(spacing: Layout.messageSpacing) { #if os(macOS)
self.messageListRows VStack(spacing: 0) {
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
}
Color.clear Color.clear
#if os(macOS)
.frame(height: Layout.messageListPaddingBottom) .frame(height: Layout.messageListPaddingBottom)
#else
.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)
#else
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
Color.clear
.frame(height: Layout.messageListPaddingBottom + 1)
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.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()
@@ -126,26 +133,18 @@ 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, self.isPinnedToBottom else { return } guard self.hasPerformedInitialScroll 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, self.isPinnedToBottom else { return } guard self.hasPerformedInitialScroll 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
} }
@@ -94,13 +94,6 @@ 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
@@ -124,7 +117,6 @@ 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
@@ -157,8 +149,6 @@ 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
@@ -310,18 +300,6 @@ 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)])
@@ -552,44 +552,6 @@ 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?
@@ -925,27 +887,6 @@ 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?
@@ -1506,21 +1447,17 @@ 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
} }
} }
-1
View File
@@ -1 +0,0 @@
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);min-height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease)}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-height:1}.topbar-status{display:flex;align-items:center;gap:8px}.toLine truncated
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index-bYQnHP3a.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
-15
View File
@@ -1,15 +0,0 @@
<!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>
-49
View File
@@ -18,54 +18,5 @@ 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"
}
}
]
}
}
```
+5 -13
View File
@@ -16,7 +16,6 @@ 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
@@ -49,11 +48,6 @@ 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
@@ -75,13 +69,11 @@ High-level:
1. Requires a clean worktree (no uncommitted changes). 1. Requires a clean worktree (no uncommitted changes).
2. Switches to the selected channel (tag or branch). 2. Switches to the selected channel (tag or branch).
3. Fetches upstream (dev only). 3. Fetches and rebases against `@{upstream}` (dev only).
4. Dev only: preflight lint + TypeScript build in a temp worktree; if the tip fails, walks back up to 10 commits to find the newest clean build. 4. Installs deps (pnpm preferred; npm fallback).
5. Rebases onto the selected commit (dev only). 5. Builds + builds the Control UI.
6. Installs deps (pnpm preferred; npm fallback). 6. Runs `clawdbot doctor` as the final “safe update” check.
7. Builds + builds the Control UI. 7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
8. Runs `clawdbot doctor` as the final “safe update” check.
9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
## `--update` shorthand ## `--update` shorthand
-3
View File
@@ -140,9 +140,6 @@ 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
+1 -1
View File
@@ -24,7 +24,7 @@ The prompt is intentionally compact and uses fixed sections:
- **Current Date & Time**: user-local time, timezone, and time format. - **Current Date & Time**: user-local time, timezone, and time format.
- **Reply Tags**: optional reply tag syntax for supported providers. - **Reply Tags**: optional reply tag syntax for supported providers.
- **Heartbeats**: heartbeat prompt and ack behavior. - **Heartbeats**: heartbeat prompt and ack behavior.
- **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line). - **Runtime**: host, OS, node, model, thinking level (one line).
- **Reasoning**: current visibility level + /reasoning toggle hint. - **Reasoning**: current visibility level + /reasoning toggle hint.
## Prompt modes ## Prompt modes
+3 -35
View File
@@ -400,26 +400,12 @@ Optional per-agent identity used for defaults and UX. This is written by the mac
If set, Clawdbot derives defaults (only when you haven’t set them explicitly): If set, Clawdbot derives defaults (only when you haven’t 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 agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) - `agents.list[].groupChat.mentionPatterns` from the agent’s `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"
}
}
] ]
} }
} }
@@ -1280,18 +1266,6 @@ Default: `~/clawd`.
If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their
own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`. own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`.
### `agents.defaults.repoRoot`
Optional repository root to show in the system prompt’s Runtime line. If unset, Clawdbot
tries to detect a `.git` directory by walking upward from the workspace (and current
working directory). The path must exist to be used.
```json5
{
agents: { defaults: { repoRoot: "~/Projects/clawdbot" } }
}
```
### `agents.defaults.skipBootstrap` ### `agents.defaults.skipBootstrap`
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`). Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
@@ -1995,7 +1969,7 @@ Per-agent override (further restrict):
Notes: Notes:
- `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow). - `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow).
- `/elevated on|off|ask|full` stores state per session key; inline directives apply to a single message. - `/elevated on|off` stores state per session key; inline directives apply to a single message.
- Elevated `exec` runs on the host and bypasses sandboxing. - Elevated `exec` runs on the host and bypasses sandboxing.
- Tool policy still applies; if `exec` is denied, elevated cannot be used. - Tool policy still applies; if `exec` is denied, elevated cannot be used.
@@ -2654,13 +2628,7 @@ 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
}
} }
} }
``` ```
-1
View File
@@ -17,7 +17,6 @@ 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`
@@ -91,8 +91,7 @@ Available groups:
## Elevated: exec-only “run on host” ## Elevated: exec-only “run on host”
Elevated does **not** grant extra tools; it only affects `exec`. Elevated does **not** grant extra tools; it only affects `exec`.
- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host (approvals may still apply). - If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host.
- Use `/elevated full` to skip exec approvals for the session.
- If you’re already running direct, elevated is effectively a no-op (still gated). - If you’re already running direct, elevated is effectively a no-op (still gated).
- Elevated is **not** skill-scoped and does **not** override tool allow/deny. - Elevated is **not** skill-scoped and does **not** override tool allow/deny.
-15
View File
@@ -178,20 +178,6 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem. - Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)). - **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
### Prompt injection does not require public DMs
Even if **only you** can message the bot, prompt injection can still happen via
any **untrusted content** the bot reads (web search/fetch results, browser pages,
emails, docs, attachments, pasted logs/code). In other words: the sender is not
the only threat surface; the **content itself** can carry adversarial instructions.
When tools are enabled, the typical risk is exfiltrating context or triggering
tool calls. Reduce the blast radius by:
- Using a read-only or tool-disabled **reader agent** to summarize untrusted content,
then pass the summary to your main agent.
- Keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents unless needed.
- Enabling sandboxing and strict tool allowlists for any agent that touches untrusted input.
### Model strength (security note) ### Model strength (security note)
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts. Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
@@ -201,7 +187,6 @@ Recommendations:
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes. - **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists). - If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled. - When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
## Reasoning & verbose output in groups ## Reasoning & verbose output in groups
-9
View File
@@ -53,15 +53,6 @@ Almost always a Node/npm PATH issue. Start here:
- [Models](/cli/models) - [Models](/cli/models)
- [OAuth / auth concepts](/concepts/oauth) - [OAuth / auth concepts](/concepts/oauth)
### `/model` says `model not allowed`
This usually means `agents.defaults.models` is configured as an allowlist. When it’s non-empty,
only those provider/model keys can be selected.
- Check the allowlist: `clawdbot config get agents.defaults.models`
- Add the model you want (or clear the allowlist) and retry `/model`
- Use `/models` to browse the allowed providers/models
### When filing an issue ### When filing an issue
Paste a safe report: Paste a safe report:
+3 -8
View File
@@ -71,8 +71,6 @@ 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
@@ -157,21 +155,18 @@ Quick diagnosis:
```bash ```bash
node -v node -v
npm -v npm -v
npm prefix -g npm bin -g
echo "$PATH" echo "$PATH"
``` ```
If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`). If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`): Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
```bash ```bash
# macOS / Linux export PATH="/path/from/npm/bin/-g:$PATH"
export PATH="$(npm prefix -g)/bin:$PATH"
``` ```
On Windows, add the output of `npm prefix -g` to your PATH.
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash). Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Update / uninstall ## Update / uninstall
+8 -11
View File
@@ -19,36 +19,33 @@ Run:
```bash ```bash
node -v node -v
npm -v npm -v
npm prefix -g npm bin -g
echo "$PATH" echo "$PATH"
``` ```
If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`). If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
## Fix: put npm’s global bin dir on PATH ## Fix: put npm’s global bin dir on PATH
1) Find your global npm prefix: 1) Find your global bin directory:
```bash ```bash
npm prefix -g npm bin -g
``` ```
2) Add the global npm bin directory to your shell startup file: 2) Add it to your shell startup file:
- zsh: `~/.zshrc` - zsh: `~/.zshrc`
- bash: `~/.bashrc` - bash: `~/.bashrc`
Example (replace the path with your `npm prefix -g` output): Example (replace the path with your `npm bin -g` output):
```bash ```bash
# macOS / Linux export PATH="/path/from/npm/bin/-g:$PATH"
export PATH="/path/from/npm/prefix/bin:$PATH"
``` ```
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash). Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
On Windows, add the output of `npm prefix -g` to your PATH.
## Fix: avoid `sudo npm install -g` / permission errors (Linux) ## Fix: avoid `sudo npm install -g` / permission errors (Linux)
If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory: If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory:
@@ -66,7 +63,7 @@ Persist the `export PATH=...` line in your shell startup file.
You’ll have the fewest surprises if Node/npm are installed in a way that: You’ll have the fewest surprises if Node/npm are installed in a way that:
- keeps Node updated (22+) - keeps Node updated (22+)
- makes the global npm bin dir stable and on PATH in new shells - makes `npm bin -g` stable and on PATH in new shells
Common choices: Common choices:
-2
View File
@@ -22,8 +22,6 @@ 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
+1 -1
View File
@@ -216,7 +216,7 @@ Option B:
## Slash commands ## Slash commands
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` - `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>`
- Per-agent, per-session overrides; non-persistent unless saved via config. - Per-agent, per-session overrides; non-persistent unless saved via config.
- `/elevated on|off|ask|full` remains a shortcut for `host=gateway security=full` (with `full` skipping approvals). - `/elevated on|off` remains a shortcut for `host=gateway security=full`.
## Cross-platform story ## Cross-platform story
- The runner service is the portable execution target. - The runner service is the portable execution target.
-2
View File
@@ -7,8 +7,6 @@ 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.
-1
View File
@@ -10,7 +10,6 @@ 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.
-5
View File
@@ -11,12 +11,7 @@ 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`.
+1 -1
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 Clawd’s “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. Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up.
```bash ```bash
clawdbot setup clawdbot setup
+1 -36
View File
@@ -117,8 +117,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent) - [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
- [Security and access control](#security-and-access-control) - [Security and access control](#security-and-access-control)
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms) - [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
- [Is prompt injection only a concern for public bots?](#is-prompt-injection-only-a-concern-for-public-bots)
- [Can I use cheaper models for personal assistant tasks?](#can-i-use-cheaper-models-for-personal-assistant-tasks)
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code) - [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work) - [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop) - [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
@@ -1067,17 +1065,6 @@ 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
@@ -1287,7 +1274,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: 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. Fix: 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
@@ -1552,28 +1539,6 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
Run `clawdbot doctor` to surface risky DM policies. Run `clawdbot doctor` to surface risky DM policies.
### Is prompt injection only a concern for public bots?
No. Prompt injection is about **untrusted content**, not just who can DM the bot.
If your assistant reads external content (web search/fetch, browser pages, emails,
docs, attachments, pasted logs), that content can include instructions that try
to hijack the model. This can happen even if **you are the only sender**.
The biggest risk is when tools are enabled: the model can be tricked into
exfiltrating context or calling tools on your behalf. Reduce the blast radius by:
- using a read-only or tool-disabled "reader" agent to summarize untrusted content
- keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents
- sandboxing and strict tool allowlists
Details: [Security](/gateway/security).
### Can I use cheaper models for personal assistant tasks?
Yes, **if** the agent is chat-only and the input is trusted. Smaller tiers are
more susceptible to instruction hijacking, so avoid them for tool-enabled agents
or when reading untrusted content. If you must use a smaller model, lock down
tools and run inside a sandbox. See [Security](/gateway/security).
### I ran `/start` in Telegram but didn’t get a pairing code ### I ran `/start` in Telegram but didn’t get a pairing code
Pairing codes are sent **only** when an unknown sender messages the bot and Pairing codes are sent **only** when an unknown sender messages the bot and
+8 -11
View File
@@ -6,20 +6,17 @@ read_when:
# Elevated Mode (/elevated directives) # Elevated Mode (/elevated directives)
## What it does ## What it does
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply). - `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`.
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host). - Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`. - Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state. - Only `on|off` are accepted; anything else returns a hint and does not change state.
## What it controls (and what it doesn’t) ## What it controls (and what it doesn’t)
- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). - **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key. - **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only. - **Inline directive**: `/elevated on` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. - **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
- **Host execution**: elevated forces `exec` onto the gateway host with full security. - **Host execution**: elevated forces `exec` onto the gateway host with full security.
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status. - **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used. - **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
@@ -29,8 +26,8 @@ read_when:
3. Global default (`agents.defaults.elevatedDefault` in config). 3. Global default (`agents.defaults.elevatedDefault` in config).
## Setting a session default ## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`. - Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
- Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`). - Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state. - If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. - Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
@@ -44,4 +41,4 @@ read_when:
## Logging + status ## Logging + status
- Elevated exec calls are logged at info level. - Elevated exec calls are logged at info level.
- Session status includes elevated mode (e.g. `elevated=ask`, `elevated=full`). - Session status includes elevated mode (e.g. `elevated=on`).
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
commands on a real host (`gateway` or `node`). Think of it like a safety interlock: commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
commands are allowed only when policy + allowlist + (optional) user approval all agree. commands are allowed only when policy + allowlist + (optional) user approval all agree.
Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals). Exec approvals are **in addition** to tool policy and elevated gating.
If the companion app UI is **not available**, any request that requires a prompt is If the companion app UI is **not available**, any request that requires a prompt is
resolved by the **ask fallback** (default: deny). resolved by the **ask fallback** (default: deny).
+1 -1
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/clawdbot/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/vignesh07/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
+1 -1
View File
@@ -78,7 +78,7 @@ Text + native (when enabled):
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`) - `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
- `/verbose on|full|off` (alias: `/v`) - `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals) - `/elevated on|off` (alias: `/elev`)
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current) - `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`) - `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) - `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
+1 -1
View File
@@ -78,7 +78,7 @@ Session controls:
- `/verbose <on|full|off>` - `/verbose <on|full|off>`
- `/reasoning <on|off|stream>` - `/reasoning <on|off|stream>`
- `/usage <off|tokens|full>` - `/usage <off|tokens|full>`
- `/elevated <on|off|ask|full>` (alias: `/elev`) - `/elevated <on|off>` (alias: `/elev`)
- `/activation <mention|always>` - `/activation <mention|always>`
- `/deliver <on|off>` - `/deliver <on|off>`
-26
View File
@@ -134,29 +134,3 @@ pnpm ui:dev # auto-installs UI deps on first run
``` ```
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`). Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
## Debugging/testing: dev server + remote Gateway
The Control UI is static files; the WebSocket target is configurable and can be
different from the HTTP origin. This is handy when you want the Vite dev server
locally but the Gateway runs elsewhere.
1) Start the UI dev server: `pnpm ui:dev`
2) Open a URL like:
```text
http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
```
Optional one-time auth (if needed):
```text
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789&token=<gateway-token>
```
Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` is stored in localStorage; `password` is kept in memory only.
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
Remote access setup details: [Remote access](/gateway/remote).
+2 -4
View File
@@ -1,11 +1,9 @@
{ {
"name": "@clawdbot/lobster", "name": "@clawdbot/lobster",
"version": "2026.1.21", "version": "2026.1.17-1",
"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": [ "extensions": ["./index.ts"]
"./index.ts"
]
} }
} }
+7 -35
View File
@@ -29,22 +29,13 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) {
return lobsterPath; return lobsterPath;
} }
function isWindowsSpawnEINVAL(err: unknown) { async function runLobsterSubprocess(params: {
if (!err || typeof err !== "object") return false; execPath: string;
const code = (err as { code?: unknown }).code; argv: string[];
return code === "EINVAL"; cwd: string;
} timeoutMs: number;
maxStdoutBytes: number;
async function runLobsterSubprocessOnce( }) {
params: {
execPath: string;
argv: string[];
cwd: string;
timeoutMs: number;
maxStdoutBytes: number;
},
useShell: boolean,
) {
const { execPath, argv, cwd } = params; const { execPath, argv, cwd } = params;
const timeoutMs = Math.max(200, params.timeoutMs); const timeoutMs = Math.max(200, params.timeoutMs);
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
@@ -60,8 +51,6 @@ async function runLobsterSubprocessOnce(
cwd, cwd,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env, env,
shell: useShell,
windowsHide: useShell ? true : undefined,
}); });
let stdout = ""; let stdout = "";
@@ -113,23 +102,6 @@ async function runLobsterSubprocessOnce(
}); });
} }
async function runLobsterSubprocess(params: {
execPath: string;
argv: string[];
cwd: string;
timeoutMs: number;
maxStdoutBytes: number;
}) {
try {
return await runLobsterSubprocessOnce(params, false);
} catch (err) {
if (process.platform === "win32" && isWindowsSpawnEINVAL(err)) {
return await runLobsterSubprocessOnce(params, true);
}
throw err;
}
}
function parseEnvelope(stdout: string): LobsterEnvelope { function parseEnvelope(stdout: string): LobsterEnvelope {
let parsed: unknown; let parsed: unknown;
try { try {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "clawdbot", "name": "clawdbot",
"version": "2026.1.21-2", "version": "2026.1.21",
"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 && pnpm ui:build", "prepack": "pnpm 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",
-2
View File
@@ -301,8 +301,6 @@ importers:
extensions/imessage: {} extensions/imessage: {}
extensions/lobster: {}
extensions/matrix: extensions/matrix:
dependencies: dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs': '@matrix-org/matrix-sdk-crypto-nodejs':
+33 -1
View File
@@ -7,6 +7,7 @@ TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}" DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}" SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX) ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX) ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX) ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
@@ -20,6 +21,7 @@ Env:
CODESIGN_TIMESTAMP=auto|on|off CODESIGN_TIMESTAMP=auto|on|off
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1
HELP HELP
exit 0 exit 0
fi fi
@@ -180,13 +182,43 @@ cat > "$ENT_TMP_RUNTIME" <<'PLIST'
</plist> </plist>
PLIST PLIST
cat > "$ENT_TMP_APP" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.time-sensitive</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
PLIST
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \ /usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE" /usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP"
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)." echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
fi fi
# The time-sensitive entitlement is restricted and requires explicit enablement
# (and typically a matching provisioning profile). It is *not* safe to enable
# unconditionally for local debug packaging since AMFI will refuse to launch.
APP_ENTITLEMENTS="$ENT_TMP_APP_BASE" APP_ENTITLEMENTS="$ENT_TMP_APP_BASE"
if [[ "${ENABLE_TIME_SENSITIVE_NOTIFICATIONS:-}" == "1" ]]; then
APP_ENTITLEMENTS="$ENT_TMP_APP"
else
echo "Note: Time Sensitive Notifications entitlement disabled."
echo " To force it: ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1 scripts/codesign-mac-app.sh <app>"
fi
# clear extended attributes to avoid stale signatures # clear extended attributes to avoid stale signatures
xattr -cr "$APP_BUNDLE" 2>/dev/null || true xattr -cr "$APP_BUNDLE" 2>/dev/null || true
@@ -285,5 +317,5 @@ sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
verify_team_ids verify_team_ids
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_RUNTIME" rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME"
echo "Codesign complete for $APP_BUNDLE" echo "Codesign complete for $APP_BUNDLE"
-1
View File
@@ -11,7 +11,6 @@ COPY src ./src
COPY scripts ./scripts COPY scripts ./scripts
COPY docs ./docs COPY docs ./docs
COPY skills ./skills COPY skills ./skills
COPY extensions/memory-core ./extensions/memory-core
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm build RUN pnpm build
+37 -85
View File
@@ -51,27 +51,14 @@ TRASH
start_s="$(date +%s)" start_s="$(date +%s)"
while true; do while true; do
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then if NEEDLE="$needle_compact" node --input-type=module -e "
return 0
fi
if NEEDLE=\"$needle_compact\" node --input-type=module -e "
import fs from \"node:fs\"; import fs from \"node:fs\";
const file = process.env.WIZARD_LOG_PATH; const file = process.env.WIZARD_LOG_PATH;
const needle = process.env.NEEDLE ?? \"\"; const needle = process.env.NEEDLE ?? \"\";
let text = \"\"; let text = \"\";
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
if (text.length > 20000) text = text.slice(-20000); text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\");
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\"); process.exit(text.includes(needle) ? 0 : 1);
const haystack = sanitize(text);
const safeNeedle = sanitize(needle);
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
let escaped = \"\";
for (const ch of safeNeedle) {
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
}
const pattern = escaped.split(\"\").join(\".*\");
const re = new RegExp(pattern, \"i\");
process.exit(re.test(haystack) ? 0 : 1);
"; then "; then
return 0 return 0
fi fi
@@ -93,35 +80,13 @@ TRASH
} }
wait_for_gateway() { wait_for_gateway() {
for _ in $(seq 1 20); do for _ in $(seq 1 10); do
if node --input-type=module -e " if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway-e2e.log; then
import net from 'node:net';
const socket = net.createConnection({ host: '127.0.0.1', port: 18789 });
const timeout = setTimeout(() => {
socket.destroy();
process.exit(1);
}, 500);
socket.on('connect', () => {
clearTimeout(timeout);
socket.end();
process.exit(0);
});
socket.on('error', () => {
clearTimeout(timeout);
process.exit(1);
});
" >/dev/null 2>&1; then
return 0 return 0
fi fi
if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then
if [ -n "${GATEWAY_PID:-}" ] && kill -0 "$GATEWAY_PID" 2>/dev/null; then
return 0
fi
fi
sleep 1 sleep 1
done done
echo "Gateway failed to start" cat /tmp/gateway-e2e.log
cat /tmp/gateway-e2e.log || true
return 1 return 1
} }
@@ -151,7 +116,7 @@ TRASH
WIZARD_LOG_PATH="$log_path" WIZARD_LOG_PATH="$log_path"
export WIZARD_LOG_PATH export WIZARD_LOG_PATH
# Run under script to keep an interactive TTY for clack prompts. # Run under script to keep an interactive TTY for clack prompts.
script -q -f -c "$command" "$log_path" < "$input_fifo" & script -q -c "$command" "$log_path" < "$input_fifo" &
wizard_pid=$! wizard_pid=$!
exec 3> "$input_fifo" exec 3> "$input_fifo"
@@ -164,18 +129,8 @@ TRASH
"$send_fn" "$send_fn"
if ! wait "$wizard_pid"; then
wizard_status=$?
exec 3>&-
rm -f "$input_fifo"
stop_gateway "$gw_pid"
echo "Wizard exited with status $wizard_status"
if [ -f "$log_path" ]; then
tail -n 160 "$log_path" || true
fi
exit "$wizard_status"
fi
exec 3>&- exec 3>&-
wait "$wizard_pid"
rm -f "$input_fifo" rm -f "$input_fifo"
stop_gateway "$gw_pid" stop_gateway "$gw_pid"
if [ -n "$validate_fn" ]; then if [ -n "$validate_fn" ]; then
@@ -221,18 +176,14 @@ TRASH
send_local_basic() { send_local_basic() {
# Risk acknowledgement (default is "No"). # Risk acknowledgement (default is "No").
wait_for_log "Continue?" 60
send $'"'"'y\r'"'"' 0.6 send $'"'"'y\r'"'"' 0.6
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI. # Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
if wait_for_log "Where will the Gateway run?" 20; then send $'"'"'\r'"'"' 0.5
send $'"'"'\r'"'"' 0.5
fi
select_skip_hooks select_skip_hooks
} }
send_reset_config_only() { send_reset_config_only() {
# Risk acknowledgement (default is "No"). # Risk acknowledgement (default is "No").
wait_for_log "Continue?" 40 || true
send $'"'"'y\r'"'"' 0.8 send $'"'"'y\r'"'"' 0.8
# Select reset flow for existing config. # Select reset flow for existing config.
wait_for_log "Config handling" 40 || true wait_for_log "Config handling" 40 || true
@@ -260,27 +211,19 @@ TRASH
send_skills_flow() { send_skills_flow() {
# Select skills section and skip optional installs. # Select skills section and skip optional installs.
send $'"'"'\r'"'"' 1.2 wait_for_log "Where will the Gateway run?" 40 || true
send $'"'"'\r'"'"' 0.8
# Configure skills now? -> No # Configure skills now? -> No
send $'"'"'n\r'"'"' 1.5 wait_for_log "Configure skills now?" 40 || true
send "" 1.0 send $'"'"'n\r'"'"' 0.8
wait_for_log "Configure complete." 40 || true
send "" 0.8
} }
run_case_local_basic() { run_case_local_basic() {
local home_dir local home_dir
home_dir="$(make_home local-basic)" home_dir="$(make_home local-basic)"
export HOME="$home_dir" run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log
mkdir -p "$HOME"
node dist/index.js onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
# Assert config + workspace scaffolding. # Assert config + workspace scaffolding.
workspace_dir="$HOME/clawd" workspace_dir="$HOME/clawd"
@@ -340,6 +283,25 @@ if (errors.length > 0) {
} }
NODE NODE
node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway.log 2>&1 &
GW_PID=$!
# Gate on gateway readiness, then run health.
for _ in $(seq 1 10); do
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
break
fi
sleep 1
done
if ! grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
cat /tmp/gateway.log
exit 1
fi
node dist/index.js health --timeout 2000 || (cat /tmp/gateway.log && exit 1)
kill "$GW_PID"
wait "$GW_PID" || true
} }
run_case_remote_non_interactive() { run_case_remote_non_interactive() {
@@ -393,7 +355,7 @@ NODE
# Seed a remote config to exercise reset path. # Seed a remote config to exercise reset path.
cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"' cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"'
{ {
"agents": { "defaults": { "workspace": "/root/old" } }, "agent": { "workspace": "/root/old" },
"gateway": { "gateway": {
"mode": "remote", "mode": "remote",
"remote": { "url": "ws://old.example:18789", "token": "old-token" } "remote": { "url": "ws://old.example:18789", "token": "old-token" }
@@ -401,17 +363,7 @@ NODE
} }
JSON JSON
node dist/index.js onboard \ run_wizard reset-config "$home_dir" send_reset_config_only
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--reset \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
config_path="$HOME/.clawdbot/clawdbot.json" config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path" assert_file "$config_path"
@@ -68,56 +68,4 @@ 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");
});
}); });
+36 -87
View File
@@ -8,17 +8,16 @@ import {
type ExecAsk, type ExecAsk,
type ExecHost, type ExecHost,
type ExecSecurity, type ExecSecurity,
type ExecApprovalsFile, type ExecAllowlistEntry,
addAllowlistEntry, addAllowlistEntry,
analyzeShellCommand, analyzeShellCommand,
evaluateExecAllowlist, isSafeBinUsage,
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";
@@ -141,7 +140,7 @@ export type { BashSandboxConfig } from "./bash-tools.shared.js";
export type ExecElevatedDefaults = { export type ExecElevatedDefaults = {
enabled: boolean; enabled: boolean;
allowed: boolean; allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full"; defaultLevel: "on" | "off";
}; };
const execSchema = Type.Object({ const execSchema = Type.Object({
@@ -707,25 +706,12 @@ 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 elevatedDefaultOn =
const elevatedDefaultMode = elevatedDefaults?.defaultLevel === "on" &&
elevatedDefaults?.defaultLevel === "full" elevatedDefaults.enabled &&
? "full" elevatedDefaults.allowed;
: elevatedDefaults?.defaultLevel === "ask" const elevatedRequested =
? "ask" typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn;
: elevatedDefaults?.defaultLevel === "on"
? "ask"
: "off";
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off";
const elevatedMode =
typeof params.elevated === "boolean"
? params.elevated
? elevatedDefaultMode === "full"
? "full"
: "ask"
: "off"
: effectiveDefaultMode;
const elevatedRequested = elevatedMode !== "off";
if (elevatedRequested) { if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) { if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
const runtime = defaults?.sandbox ? "sandboxed" : "direct"; const runtime = defaults?.sandbox ? "sandboxed" : "direct";
@@ -757,8 +743,6 @@ 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";
@@ -783,10 +767,6 @@ export function createExecTool(
const configuredAsk = defaults?.ask ?? "on-miss"; const configuredAsk = defaults?.ask ?? "on-miss";
const requestedAsk = normalizeExecAsk(params.ask); const requestedAsk = normalizeExecAsk(params.ask);
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk); let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
const bypassApprovals = elevatedRequested && elevatedMode === "full";
if (bypassApprovals) {
ask = "off";
}
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined; const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd(); const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
@@ -871,43 +851,7 @@ export function createExecTool(
if (nodeEnv) { if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true }); applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
} }
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env }); const requiresAsk = hostAsk === "always" || hostAsk === "on-miss";
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,
@@ -958,8 +902,8 @@ export function createExecTool(
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId, agentId,
resolvedPath: undefined, resolvedPath: null,
sessionKey: defaults?.sessionKey, sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;
@@ -1087,7 +1031,7 @@ export function createExecTool(
}; };
} }
if (host === "gateway" && !bypassApprovals) { if (host === "gateway") {
const approvals = resolveExecApprovals(agentId, { security: "allowlist" }); const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security); const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask); const hostAsk = maxAsk(ask, approvals.agent.ask);
@@ -1096,28 +1040,33 @@ 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 allowlistEval = evaluateExecAllowlist({ const allowlistMatches: ExecAllowlistEntry[] = [];
analysis, let allowlistSatisfied = false;
allowlist: approvals.allowlist, if (hostSecurity === "allowlist" && analysis.ok && analysis.segments.length > 0) {
safeBins, allowlistSatisfied = analysis.segments.every((segment) => {
cwd: workdir, const match = matchAllowlist(approvals.allowlist, segment.resolution);
}); if (match) allowlistMatches.push(match);
const allowlistMatches = allowlistEval.allowlistMatches; const safe = isSafeBinUsage({
const allowlistSatisfied = argv: segment.argv,
hostSecurity === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false; resolution: segment.resolution,
const requiresAsk = requiresExecApproval({ safeBins,
ask: hostAsk, cwd: workdir,
security: hostSecurity, });
analysisOk: analysis.ok, return Boolean(match || safe);
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; const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
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 =
@@ -1139,7 +1088,7 @@ export function createExecTool(
ask: hostAsk, ask: hostAsk,
agentId, agentId,
resolvedPath, resolvedPath,
sessionKey: defaults?.sessionKey, sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;
-4
View File
@@ -55,7 +55,6 @@ export type CodexCliCredential = {
access: string; access: string;
refresh: string; refresh: string;
expires: number; expires: number;
accountId?: string;
}; };
export type QwenCliCredential = { export type QwenCliCredential = {
@@ -144,7 +143,6 @@ 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",
@@ -157,7 +155,6 @@ function readCodexKeychainCredentials(options?: {
access: accessToken, access: accessToken,
refresh: refreshToken, refresh: refreshToken,
expires, expires,
accountId,
}; };
} catch { } catch {
return null; return null;
@@ -446,7 +443,6 @@ export function readCodexCliCredentials(options?: {
access: accessToken, access: accessToken,
refresh: refreshToken, refresh: refreshToken,
expires, expires,
accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined,
}; };
} }
-2
View File
@@ -183,8 +183,6 @@ export function buildSystemPrompt(params: {
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config, config: params.config,
agentId: params.agentId, agentId: params.agentId,
workspaceDir: params.workspaceDir,
cwd: process.cwd(),
runtime: { runtime: {
host: "clawdbot", host: "clawdbot",
os: `${os.type()} ${os.release()}`, os: `${os.type()} ${os.release()}`,
-112
View File
@@ -1,112 +0,0 @@
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");
});
});
-99
View File
@@ -1,99 +0,0 @@
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 };
}
-63
View File
@@ -1,63 +0,0 @@
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);
}
-13
View File
@@ -68,10 +68,6 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
} }
function isInstructionsRequiredError(raw: string): boolean {
return /instructions are required/i.test(raw);
}
function toInt(value: string | undefined, fallback: number): number { function toInt(value: string | undefined, fallback: number): number {
const trimmed = value?.trim(); const trimmed = value?.trim();
if (!trimmed) return fallback; if (!trimmed) return fallback;
@@ -447,15 +443,6 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (chatgpt usage limit)`); logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
break; break;
} }
if (
allowNotFoundSkip &&
model.provider === "openai-codex" &&
isInstructionsRequiredError(message)
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (instructions required)`);
break;
}
logProgress(`${progressLabel}: failed`); logProgress(`${progressLabel}: failed`);
failures.push({ model: id, error: message }); failures.push({ model: id, error: message });
break; break;
+3 -9
View File
@@ -6,15 +6,9 @@ export function isGoogleModelApi(api?: string | null): boolean {
); );
} }
export function isAntigravityClaude(params: { export function isAntigravityClaude(api?: string | null, modelId?: string): boolean {
api?: string | null; if (api !== "google-antigravity") return false;
provider?: string | null; return modelId?.toLowerCase().includes("claude") ?? false;
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 };
@@ -86,7 +86,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
expect(assistant.content?.[0]?.thinking).toBe("reasoning"); expect(assistant.content?.[0]?.thinking).toBe("reasoning");
}); });
it("drops unsigned thinking blocks for Antigravity Claude", async () => { it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const input = [ const input = [
{ {
@@ -107,37 +107,11 @@ 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; thinkingSignature?: string }>; content?: Array<{ type?: string; thinking?: 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 () => {
+3 -79
View File
@@ -55,15 +55,6 @@ 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;
@@ -78,66 +69,6 @@ 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)) {
@@ -278,11 +209,7 @@ export async function sanitizeSessionHistory(params: {
sessionManager: SessionManager; sessionManager: SessionManager;
sessionId: string; sessionId: string;
}): Promise<AgentMessage[]> { }): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude({ const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
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 =
@@ -294,15 +221,12 @@ export async function sanitizeSessionHistory(params: {
sanitizeToolCallIds, sanitizeToolCallIds,
toolCallIdMode, toolCallIdMode,
enforceToolCallLast: params.modelApi === "anthropic-messages", enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: isAntigravityClaudeModel, preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
sanitizeThoughtSignatures: isOpenRouterGemini sanitizeThoughtSignatures: isOpenRouterGemini
? { allowBase64Only: true, includeCamelCase: true } ? { allowBase64Only: true, includeCamelCase: true }
: undefined, : undefined,
}); });
const sanitizedThinking = isAntigravityClaudeModel const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
: sanitizedImages;
const repairedTools = sanitizeToolUseResultPairing(sanitizedThinking);
return applyGoogleTurnOrderingFix({ return applyGoogleTurnOrderingFix({
messages: repairedTools, messages: repairedTools,
@@ -46,7 +46,6 @@ 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";
@@ -185,11 +184,6 @@ 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();
@@ -285,8 +279,6 @@ export async function runEmbeddedAttempt(
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config, config: params.config,
agentId: sessionAgentId, agentId: sessionAgentId,
workspaceDir: effectiveWorkspace,
cwd: process.cwd(),
runtime: { runtime: {
host: machineName, host: machineName,
os: `${os.type()} ${os.release()}`, os: `${os.type()} ${os.release()}`,
@@ -320,7 +312,6 @@ export async function runEmbeddedAttempt(
: undefined, : undefined,
skillsPrompt, skillsPrompt,
docsPath: docsPath ?? undefined, docsPath: docsPath ?? undefined,
workspaceNotes,
reactionGuidance, reactionGuidance,
promptMode, promptMode,
runtimeInfo, runtimeInfo,
@@ -20,7 +20,6 @@ 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: {
@@ -55,7 +54,6 @@ 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,
+1 -1
View File
@@ -76,6 +76,6 @@ export type EmbeddedSandboxInfo = {
allowedControlPorts?: number[]; allowedControlPorts?: number[];
elevated?: { elevated?: {
allowed: boolean; allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full"; defaultLevel: "on" | "off";
}; };
}; };
@@ -226,27 +226,24 @@ export function handleMessageEnd(
); );
} else { } else {
ctx.state.lastBlockReplyText = text; ctx.state.lastBlockReplyText = text;
const splitResult = ctx.consumeReplyDirectives(text, { final: true }); const {
if (splitResult) { text: cleanedText,
const { mediaUrls,
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: mediaUrls?.length ? mediaUrls : undefined,
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,
});
}
} }
} }
} }
@@ -257,30 +254,6 @@ 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();
@@ -1,7 +1,6 @@
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";
@@ -78,10 +77,6 @@ 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: {
@@ -1,106 +0,0 @@
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("[[");
});
});
+1 -9
View File
@@ -1,5 +1,4 @@
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";
@@ -76,13 +75,11 @@ 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();
@@ -377,8 +374,7 @@ 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 = replyDirectiveAccumulator.consume(chunk); const splitResult = parseReplyDirectives(chunk);
if (!splitResult) return;
const { const {
text: cleanedText, text: cleanedText,
mediaUrls, mediaUrls,
@@ -399,9 +395,6 @@ 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()) {
@@ -454,7 +447,6 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
emitBlockChunk, emitBlockChunk,
flushBlockReplyBuffer, flushBlockReplyBuffer,
emitReasoningStream, emitReasoningStream,
consumeReplyDirectives,
resetAssistantMessageState, resetAssistantMessageState,
resetForCompactionRetry, resetForCompactionRetry,
finalizeAssistantTexts, finalizeAssistantTexts,
-106
View File
@@ -1,106 +0,0 @@
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 { buildSystemPromptParams } from "./system-prompt-params.js";
async function makeTempDir(label: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-${label}-`));
}
async function makeRepoRoot(root: string): Promise<void> {
await fs.mkdir(path.join(root, ".git"), { recursive: true });
}
function buildParams(params: { config?: ClawdbotConfig; workspaceDir?: string; cwd?: string }) {
return buildSystemPromptParams({
config: params.config,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
runtime: {
host: "host",
os: "os",
arch: "arch",
node: "node",
model: "model",
},
});
}
describe("buildSystemPromptParams repo root", () => {
it("detects repo root from workspaceDir", async () => {
const temp = await makeTempDir("workspace");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(repoRoot, "nested", "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const { runtimeInfo } = buildParams({ workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("falls back to cwd when workspaceDir has no repo", async () => {
const temp = await makeTempDir("cwd");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(temp, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const { runtimeInfo } = buildParams({ workspaceDir, cwd: repoRoot });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("uses configured repoRoot when valid", async () => {
const temp = await makeTempDir("config");
const repoRoot = path.join(temp, "config-root");
const workspaceDir = path.join(temp, "workspace");
await fs.mkdir(repoRoot, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(workspaceDir);
const config: ClawdbotConfig = {
agents: {
defaults: {
repoRoot,
},
},
};
const { runtimeInfo } = buildParams({ config, workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("ignores invalid repoRoot config and auto-detects", async () => {
const temp = await makeTempDir("invalid");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(repoRoot, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const config: ClawdbotConfig = {
agents: {
defaults: {
repoRoot: path.join(temp, "missing"),
},
},
};
const { runtimeInfo } = buildParams({ config, workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("returns undefined when no repo is found", async () => {
const workspaceDir = await makeTempDir("norepo");
const { runtimeInfo } = buildParams({ workspaceDir });
expect(runtimeInfo.repoRoot).toBeUndefined();
});
});
-58
View File
@@ -1,6 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
formatUserTime, formatUserTime,
@@ -21,7 +18,6 @@ export type RuntimeInfoInput = {
capabilities?: string[]; capabilities?: string[];
/** Supported message actions for the current channel (e.g., react, edit, unsend) */ /** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[]; channelActions?: string[];
repoRoot?: string;
}; };
export type SystemPromptRuntimeParams = { export type SystemPromptRuntimeParams = {
@@ -35,14 +31,7 @@ export function buildSystemPromptParams(params: {
config?: ClawdbotConfig; config?: ClawdbotConfig;
agentId?: string; agentId?: string;
runtime: Omit<RuntimeInfoInput, "agentId">; runtime: Omit<RuntimeInfoInput, "agentId">;
workspaceDir?: string;
cwd?: string;
}): SystemPromptRuntimeParams { }): SystemPromptRuntimeParams {
const repoRoot = resolveRepoRoot({
config: params.config,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
});
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
@@ -50,56 +39,9 @@ export function buildSystemPromptParams(params: {
runtimeInfo: { runtimeInfo: {
agentId: params.agentId, agentId: params.agentId,
...params.runtime, ...params.runtime,
repoRoot,
}, },
userTimezone, userTimezone,
userTime, userTime,
userTimeFormat, userTimeFormat,
}; };
} }
function resolveRepoRoot(params: {
config?: ClawdbotConfig;
workspaceDir?: string;
cwd?: string;
}): string | undefined {
const configured = params.config?.agents?.defaults?.repoRoot?.trim();
if (configured) {
try {
const resolved = path.resolve(configured);
const stat = fs.statSync(resolved);
if (stat.isDirectory()) return resolved;
} catch {
// ignore invalid config path
}
}
const candidates = [params.workspaceDir, params.cwd]
.map((value) => value?.trim())
.filter(Boolean) as string[];
const seen = new Set<string>();
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
if (seen.has(resolved)) continue;
seen.add(resolved);
const root = findGitRoot(resolved);
if (root) return root;
}
return undefined;
}
function findGitRoot(startDir: string): string | null {
let current = path.resolve(startDir);
for (let i = 0; i < 12; i += 1) {
const gitPath = path.join(current, ".git");
try {
const stat = fs.statSync(gitPath);
if (stat.isDirectory() || stat.isFile()) return current;
} catch {
// ignore missing .git at this level
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return null;
}
+1 -12
View File
@@ -115,15 +115,6 @@ 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",
@@ -293,7 +284,6 @@ describe("buildAgentSystemPrompt", () => {
{ {
agentId: "work", agentId: "work",
host: "host", host: "host",
repoRoot: "/repo",
os: "macOS", os: "macOS",
arch: "arm64", arch: "arm64",
node: "v20", node: "v20",
@@ -307,7 +297,6 @@ describe("buildAgentSystemPrompt", () => {
expect(line).toContain("agent=work"); expect(line).toContain("agent=work");
expect(line).toContain("host=host"); expect(line).toContain("host=host");
expect(line).toContain("repo=/repo");
expect(line).toContain("os=macOS (arm64)"); expect(line).toContain("os=macOS (arm64)");
expect(line).toContain("node=v20"); expect(line).toContain("node=v20");
expect(line).toContain("model=anthropic/claude"); expect(line).toContain("model=anthropic/claude");
@@ -331,7 +320,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("You are running in a sandboxed runtime"); expect(prompt).toContain("You are running in a sandboxed runtime");
expect(prompt).toContain("Sub-agents stay sandboxed"); expect(prompt).toContain("Sub-agents stay sandboxed");
expect(prompt).toContain("User can toggle with /elevated on|off|ask|full."); expect(prompt).toContain("User can toggle with /elevated on|off.");
expect(prompt).toContain("Current elevated level: on"); expect(prompt).toContain("Current elevated level: on");
}); });
+5 -14
View File
@@ -17,9 +17,8 @@ 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) return []; if (!trimmed || params.isMinimal) return [];
return [ return [
"## Skills (mandatory)", "## Skills (mandatory)",
"Before replying: scan <available_skills> <description> entries.", "Before replying: scan <available_skills> <description> entries.",
@@ -149,7 +148,6 @@ 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?: {
@@ -162,7 +160,6 @@ export function buildAgentSystemPrompt(params: {
defaultModel?: string; defaultModel?: string;
channel?: string; channel?: string;
capabilities?: string[]; capabilities?: string[];
repoRoot?: string;
}; };
messageToolHints?: string[]; messageToolHints?: string[];
sandboxInfo?: { sandboxInfo?: {
@@ -178,7 +175,7 @@ export function buildAgentSystemPrompt(params: {
allowedControlPorts?: number[]; allowedControlPorts?: number[];
elevated?: { elevated?: {
allowed: boolean; allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full"; defaultLevel: "on" | "off";
}; };
}; };
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */ /** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
@@ -329,7 +326,6 @@ 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") {
@@ -406,7 +402,6 @@ 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" : "",
@@ -448,14 +443,12 @@ export function buildAgentSystemPrompt(params: {
params.sandboxInfo.elevated?.allowed params.sandboxInfo.elevated?.allowed
? "Elevated exec is available for this session." ? "Elevated exec is available for this session."
: "", : "",
params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "",
params.sandboxInfo.elevated?.allowed params.sandboxInfo.elevated?.allowed
? "User can toggle with /elevated on|off|ask|full." ? "You may also send /elevated on|off when needed."
: "", : "",
params.sandboxInfo.elevated?.allowed params.sandboxInfo.elevated?.allowed
? "You may also send /elevated on|off|ask|full when needed." ? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).`
: "",
params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).`
: "", : "",
] ]
.filter(Boolean) .filter(Boolean)
@@ -577,7 +570,6 @@ export function buildRuntimeLine(
node?: string; node?: string;
model?: string; model?: string;
defaultModel?: string; defaultModel?: string;
repoRoot?: string;
}, },
runtimeChannel?: string, runtimeChannel?: string,
runtimeCapabilities: string[] = [], runtimeCapabilities: string[] = [],
@@ -586,7 +578,6 @@ export function buildRuntimeLine(
return `Runtime: ${[ return `Runtime: ${[
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "", runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "", runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "",
runtimeInfo?.os runtimeInfo?.os
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}` ? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
: runtimeInfo?.arch : runtimeInfo?.arch
-29
View File
@@ -2,7 +2,6 @@ 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,
@@ -41,34 +40,6 @@ 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");
+150 -43
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 { runCommandWithTimeout } from "../process/exec.js"; import { formatCliCommand } from "../cli/command-format.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
export function resolveDefaultAgentWorkspaceDir( export function resolveDefaultAgentWorkspaceDir(
@@ -27,9 +27,142 @@ 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/reference/templates", "../../docs/templates",
); );
function stripFrontMatter(content: string): string { function stripFrontMatter(content: string): string {
@@ -42,15 +175,13 @@ function stripFrontMatter(content: string): string {
return trimmed; return trimmed;
} }
async function loadTemplate(name: string): Promise<string> { async function loadTemplate(name: string, fallback: 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 {
throw new Error( return fallback;
`Missing workspace template: ${name} (${templatePath}). Ensure docs/reference/templates are packaged.`,
);
} }
} }
@@ -82,35 +213,6 @@ 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;
@@ -153,13 +255,19 @@ export async function ensureAgentWorkspace(params?: {
return existing.every((v) => !v); return existing.every((v) => !v);
})(); })();
const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME); const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_TEMPLATE);
const soulTemplate = await loadTemplate(DEFAULT_SOUL_FILENAME); const soulTemplate = await loadTemplate(DEFAULT_SOUL_FILENAME, DEFAULT_SOUL_TEMPLATE);
const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME); const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME, DEFAULT_TOOLS_TEMPLATE);
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME); const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME, DEFAULT_IDENTITY_TEMPLATE);
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME); const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME, DEFAULT_USER_TEMPLATE);
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME); const heartbeatTemplate = await loadTemplate(
const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME); DEFAULT_HEARTBEAT_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);
@@ -170,7 +278,6 @@ 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,
+2 -2
View File
@@ -395,9 +395,9 @@ function buildChatCommands(): ChatCommandDefinition[] {
args: [ args: [
{ {
name: "mode", name: "mode",
description: "on, off, ask, or full", description: "on or off",
type: "string", type: "string",
choices: ["on", "off", "ask", "full"], choices: ["on", "off"],
}, },
], ],
argsMenu: "auto", argsMenu: "auto",
@@ -219,7 +219,7 @@ describe("directive behavior", () => {
); );
const events = drainSystemEvents(MAIN_SESSION_KEY); const events = drainSystemEvents(MAIN_SESSION_KEY);
expect(events.some((e) => e.includes("Elevated ASK"))).toBe(true); expect(events.some((e) => e.includes("Elevated ON"))).toBe(true);
}); });
}); });
it("queues a system event when toggling reasoning", async () => { it("queues a system event when toggling reasoning", async () => {
@@ -150,7 +150,7 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode set to ask"); expect(text).toContain("Elevated mode enabled");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -143,7 +143,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current elevated level: on"); expect(text).toContain("Current elevated level: on");
expect(text).toContain("Options: on, off, ask, full."); expect(text).toContain("Options: on, off.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -55,16 +55,6 @@ describe("directive parsing", () => {
expect(res.hasDirective).toBe(true); expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("on"); expect(res.elevatedLevel).toBe("on");
}); });
it("matches elevated ask", () => {
const res = extractElevatedDirective("/elevated ask please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("ask");
});
it("matches elevated full", () => {
const res = extractElevatedDirective("/elevated full please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("full");
});
it("matches think at start of line", () => { it("matches think at start of line", () => {
const res = extractThinkDirective("/think:high run slow"); const res = extractThinkDirective("/think:high run slow");
@@ -129,7 +129,7 @@ describe("trigger handling", () => {
cfg, cfg,
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode set to ask"); expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>; const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -223,7 +223,7 @@ describe("trigger handling", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok"); expect(text).toBe("ok");
expect(text).not.toContain("Elevated mode set to ask"); expect(text).not.toContain("Elevated mode enabled");
}); });
}); });
}); });
@@ -184,7 +184,7 @@ describe("trigger handling", () => {
cfg, cfg,
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode set to ask"); expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>; const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -226,7 +226,7 @@ describe("trigger handling", () => {
cfg, cfg,
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode set to ask"); expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>; const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -167,7 +167,7 @@ describe("trigger handling", () => {
cfg, cfg,
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode set to ask"); expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>; const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -102,8 +102,6 @@ async function resolveContextReport(
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.cfg, config: params.cfg,
agentId: sessionAgentId, agentId: sessionAgentId,
workspaceDir,
cwd: process.cwd(),
runtime: { runtime: {
host: "unknown", host: "unknown",
os: "unknown", os: "unknown",
@@ -120,7 +118,7 @@ async function resolveContextReport(
workspaceAccess: "rw" as const, workspaceAccess: "rw" as const,
elevated: { elevated: {
allowed: params.elevated.allowed, allowed: params.elevated.allowed,
defaultLevel: (params.resolvedElevatedLevel ?? "off") as "on" | "off" | "ask" | "full", defaultLevel: params.resolvedElevatedLevel === "off" ? ("off" as const) : ("on" as const),
}, },
} }
: { enabled: false }; : { enabled: false };
+1 -1
View File
@@ -45,7 +45,7 @@ function formatTimestampWithAge(valueMs?: number) {
} }
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined { function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
const raw = params.sessionKey?.trim() || params.ctx.CommandTargetSessionKey?.trim(); const raw = params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
if (!raw) return undefined; if (!raw) return undefined;
const { mainKey, alias } = resolveMainSessionAlias(params.cfg); const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
return resolveInternalSessionKey({ key: raw, alias, mainKey }); return resolveInternalSessionKey({ key: raw, alias, mainKey });
-27
View File
@@ -215,33 +215,6 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Subagents: none"); expect(result.reply?.text).toContain("Subagents: none");
}); });
it("lists subagents for the current command session over the target session", async () => {
resetSubagentRegistryForTests();
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:slack:slash:U1",
requesterDisplayKey: "agent:main:slack:slash:U1",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
});
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/subagents list", cfg, {
CommandSource: "native",
CommandTargetSessionKey: "agent:main:main",
});
params.sessionKey = "agent:main:slack:slash:U1";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Subagents (current session)");
expect(result.reply?.text).toContain("agent:main:subagent:abc");
});
it("omits subagent status line when none exist", async () => { it("omits subagent status line when none exist", async () => {
resetSubagentRegistryForTests(); resetSubagentRegistryForTests();
const cfg = { const cfg = {
@@ -205,7 +205,7 @@ export async function handleDirectiveOnly(params: {
const level = currentElevatedLevel ?? "off"; const level = currentElevatedLevel ?? "off";
return { return {
text: [ text: [
withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"), withOptions(`Current elevated level: ${level}.`, "on, off"),
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
] ]
.filter(Boolean) .filter(Boolean)
@@ -213,7 +213,7 @@ export async function handleDirectiveOnly(params: {
}; };
} }
return { return {
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`, text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
}; };
} }
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) { if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
@@ -426,9 +426,7 @@ export async function handleDirectiveOnly(params: {
parts.push( parts.push(
directives.elevatedLevel === "off" directives.elevatedLevel === "off"
? formatDirectiveAck("Elevated mode disabled.") ? formatDirectiveAck("Elevated mode disabled.")
: directives.elevatedLevel === "full" : formatDirectiveAck("Elevated mode enabled."),
? formatDirectiveAck("Elevated mode set to full (auto-approve).")
: formatDirectiveAck("Elevated mode set to ask (approvals may still apply)."),
); );
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
} }
@@ -16,15 +16,10 @@ export const withOptions = (line: string, options: string) =>
export const formatElevatedRuntimeHint = () => export const formatElevatedRuntimeHint = () =>
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`; `${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
export const formatElevatedEvent = (level: ElevatedLevel) => { export const formatElevatedEvent = (level: ElevatedLevel) =>
if (level === "full") { level === "on"
return "Elevated FULL — exec runs on host with auto-approval."; ? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed."
} : "Elevated OFF — exec stays in sandbox.";
if (level === "ask" || level === "on") {
return "Elevated ASK — exec runs on host; approvals may still apply.";
}
return "Elevated OFF — exec stays in sandbox.";
};
export const formatReasoningEvent = (level: ReasoningLevel) => { export const formatReasoningEvent = (level: ReasoningLevel) => {
if (level === "stream") return "Reasoning STREAM — emit live <think>."; if (level === "stream") return "Reasoning STREAM — emit live <think>.";
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more