Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c163c71b5 | ||
|
|
277881b52f |
No files matched your search
+1
-18
@@ -6,40 +6,25 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Changes
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
- Media: accept MEDIA paths with spaces/tilde and prefer the message tool hint for image replies.
|
||||
- Google Antigravity: drop unsigned thinking blocks for Claude models to avoid signature errors.
|
||||
- Config: avoid stack traces for invalid configs and log the config path.
|
||||
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
|
||||
- Agents: include skills section in minimal prompts for subagents. (#1431) Thanks @robbyczgw-cla.
|
||||
- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900)
|
||||
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
||||
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
|
||||
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
|
||||
- Logs: align rolling log filenames with local time and fall back to latest file when today's log is missing. (#1343)
|
||||
- Models: inherit session model overrides in thread/topic sessions (Telegram topics, Slack/Discord threads). (#1376)
|
||||
- macOS: keep local auto bind loopback-first; only use tailnet when bind=tailnet.
|
||||
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
||||
- macOS: keep chat pinned to bottom during streaming replies. (#1279)
|
||||
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
||||
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
|
||||
- Exec: avoid defaulting to elevated mode when elevated is not allowed.
|
||||
- Exec approvals: align node/gateway allowlist prechecks and approval gating; avoid null optional params in approval requests. (#1425) Thanks @czekaj.
|
||||
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
||||
- UI: export config form section metadata for shared usage. (#1418) Thanks @MaudeBot.
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
@@ -109,7 +94,6 @@ Docs: https://docs.clawd.bot
|
||||
- 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/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: 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.
|
||||
@@ -119,7 +103,6 @@ Docs: https://docs.clawd.bot
|
||||
- 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: 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: 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
|
||||
|
||||
@@ -479,26 +479,26 @@ Thanks to all clawtributors:
|
||||
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a>
|
||||
<a href="https://github.com/search?q=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=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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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>
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2",
|
||||
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -24,7 +24,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -74,7 +74,6 @@ final class ControlChannel {
|
||||
}
|
||||
|
||||
private(set) var lastPingMs: Double?
|
||||
private(set) var authSourceLabel: String?
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||
|
||||
@@ -129,7 +128,6 @@ final class ControlChannel {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
self.lastPingMs = nil
|
||||
self.authSourceLabel = nil
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||
let tokenKey = CommandResolver.connectionModeIsRemote()
|
||||
? "gateway.remote.token"
|
||||
: "gateway.auth.token"
|
||||
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. " +
|
||||
"Reason: \(reason)"
|
||||
}
|
||||
@@ -305,27 +300,6 @@ final class ControlChannel {
|
||||
code: 0,
|
||||
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 {
|
||||
|
||||
@@ -215,15 +215,36 @@ enum ExecApprovalsPromptPresenter {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
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: "Always 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() {
|
||||
case .alertFirstButtonReturn:
|
||||
@@ -234,110 +255,6 @@ enum ExecApprovalsPromptPresenter {
|
||||
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
|
||||
|
||||
@@ -249,11 +249,6 @@ actor GatewayConnection {
|
||||
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 {
|
||||
if let client {
|
||||
await client.shutdown()
|
||||
|
||||
@@ -482,7 +482,7 @@ actor GatewayEndpointStore {
|
||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
guard bind == "tailnet" else { return nil }
|
||||
guard bind == "auto" else { return nil }
|
||||
|
||||
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||
@@ -562,6 +562,9 @@ actor GatewayEndpointStore {
|
||||
case "tailnet":
|
||||
return tailscaleIP ?? "127.0.0.1"
|
||||
case "auto":
|
||||
if let tailscaleIP, !tailscaleIP.isEmpty {
|
||||
return tailscaleIP
|
||||
}
|
||||
return "127.0.0.1"
|
||||
case "custom":
|
||||
return customBindHost ?? "127.0.0.1"
|
||||
|
||||
@@ -115,7 +115,7 @@ extension GatewayLaunchAgentManager {
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "gateway",
|
||||
subcommand: "daemon",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Launchd management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
|
||||
@@ -2,12 +2,15 @@ import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettings: View {
|
||||
@Bindable var state: AppState
|
||||
@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 gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||
@@ -17,6 +20,7 @@ struct GeneralSettings: View {
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
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.",
|
||||
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(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||
@@ -81,12 +106,27 @@ struct GeneralSettings: View {
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
self.refreshGatewayStatus()
|
||||
self.lastLocationModeRaw = self.locationModeRaw
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
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> {
|
||||
@@ -95,6 +135,26 @@ struct GeneralSettings: View {
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Clawdbot runs")
|
||||
@@ -212,11 +272,6 @@ struct GeneralSettings: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||
Text(authLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@@ -19,7 +18,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private var isMenuOpen = false
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private var menuOpenWidth: CGFloat?
|
||||
private var isObservingControlChannel = false
|
||||
|
||||
private var cachedSnapshot: SessionStoreSnapshot?
|
||||
private var cachedErrorText: String?
|
||||
@@ -52,7 +50,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||
}
|
||||
|
||||
self.startControlChannelObservation()
|
||||
self.nodesStore.start()
|
||||
}
|
||||
|
||||
@@ -99,50 +96,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
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) {
|
||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
||||
}
|
||||
@@ -192,11 +145,10 @@ extension MenuSessionsInjector {
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let statusText = self.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: rows.count,
|
||||
statusText: statusText)),
|
||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
@@ -646,11 +598,8 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
if self.cachedSnapshot != nil {
|
||||
self.cachedErrorText = "Gateway disconnected (showing cached)"
|
||||
} else {
|
||||
self.cachedErrorText = nil
|
||||
}
|
||||
self.cachedSnapshot = nil
|
||||
self.cachedErrorText = nil
|
||||
self.cacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
@@ -675,6 +624,8 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedUsageSummary = nil
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
@@ -697,6 +648,8 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = nil
|
||||
self.costCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import SwiftUI
|
||||
|
||||
struct PermissionsSettings: View {
|
||||
@@ -19,8 +17,6 @@ struct PermissionsSettings: View {
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
LocationAccessSettings()
|
||||
|
||||
Button("Restart onboarding") { self.showOnboarding() }
|
||||
.buttonStyle(.bordered)
|
||||
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 {
|
||||
let status: [Capability: Bool]
|
||||
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] {
|
||||
let res = await ShellExecutor.run(
|
||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||
|
||||
@@ -20,13 +20,11 @@ actor RemoteTunnelManager {
|
||||
tunnel.process.isRunning,
|
||||
let local = tunnel.localPort
|
||||
{
|
||||
let pid = tunnel.process.processIdentifier
|
||||
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
||||
if await self.isTunnelHealthy(port: local) {
|
||||
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||
return local
|
||||
}
|
||||
self.logger.error(
|
||||
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
|
||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||
await self.beginRestart()
|
||||
tunnel.terminate()
|
||||
self.controlTunnel = nil
|
||||
@@ -37,11 +35,19 @@ actor RemoteTunnelManager {
|
||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||
self.isSshProcess(desc)
|
||||
{
|
||||
self.logger.info(
|
||||
"reusing existing SSH tunnel listener " +
|
||||
"localPort=\(desiredPort, privacy: .public) " +
|
||||
"pid=\(desc.pid, privacy: .public)")
|
||||
return desiredPort
|
||||
if await self.isTunnelHealthy(port: desiredPort) {
|
||||
self.logger.info(
|
||||
"reusing existing SSH tunnel listener " +
|
||||
"localPort=\(desiredPort, privacy: .public) " +
|
||||
"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
|
||||
}
|
||||
@@ -82,6 +88,10 @@ actor RemoteTunnelManager {
|
||||
self.controlTunnel = nil
|
||||
}
|
||||
|
||||
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
||||
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
||||
}
|
||||
|
||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||
let cmd = desc.command.lowercased()
|
||||
if cmd.contains("ssh") { return true }
|
||||
@@ -118,5 +128,21 @@ actor RemoteTunnelManager {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -52,51 +52,6 @@ actor SessionPreviewCache {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
extension SessionPreviewCache {
|
||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||
@@ -229,31 +184,17 @@ enum SessionMenuPreviewLoader {
|
||||
return self.snapshot(from: cached)
|
||||
}
|
||||
|
||||
let isConnected = await MainActor.run {
|
||||
if case .connected = ControlChannel.shared.state { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
guard isConnected else {
|
||||
if let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey) {
|
||||
return Self.snapshot(from: fallback)
|
||||
}
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Gateway disconnected"))
|
||||
}
|
||||
|
||||
do {
|
||||
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 payload = 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)
|
||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||
return Self.snapshot(from: built)
|
||||
|
||||
@@ -309,7 +309,7 @@ private func resolveLocalHost(bind: String?) -> String {
|
||||
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let tailnetIP = detectTailnetIPv4()
|
||||
switch normalized {
|
||||
case "tailnet":
|
||||
case "tailnet", "auto":
|
||||
return tailnetIP ?? "127.0.0.1"
|
||||
default:
|
||||
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 let runid: String
|
||||
public let timeoutms: Int?
|
||||
@@ -1485,21 +1447,17 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
||||
public struct AgentSummary: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let identity: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String?,
|
||||
identity: [String: AnyCodable]?
|
||||
name: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.identity = identity
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case identity
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,14 +140,14 @@ import Testing
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
|
||||
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "auto",
|
||||
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(
|
||||
bindMode: "auto",
|
||||
tailscaleIP: nil)
|
||||
|
||||
@@ -12,7 +12,6 @@ public struct ClawdbotChatView: View {
|
||||
@State private var scrollPosition: UUID?
|
||||
@State private var showSessions = false
|
||||
@State private var hasPerformedInitialScroll = false
|
||||
@State private var isPinnedToBottom = true
|
||||
private let showsSessionSwitcher: Bool
|
||||
private let style: Style
|
||||
private let markdownVariant: ChatMarkdownVariant
|
||||
@@ -88,28 +87,36 @@ public struct ClawdbotChatView: View {
|
||||
private var messageList: some View {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
self.messageListRows
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
self.messageListRows
|
||||
}
|
||||
|
||||
Color.clear
|
||||
#if os(macOS)
|
||||
.frame(height: Layout.messageListPaddingBottom)
|
||||
#else
|
||||
.frame(height: Layout.messageListPaddingBottom + 1)
|
||||
#endif
|
||||
.id(self.scrollerBottomID)
|
||||
}
|
||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||
.scrollTargetLayout()
|
||||
.padding(.top, Layout.messageListPaddingTop)
|
||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||
#else
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
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.
|
||||
.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 {
|
||||
ProgressView()
|
||||
@@ -126,26 +133,18 @@ public struct ClawdbotChatView: View {
|
||||
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
self.hasPerformedInitialScroll = true
|
||||
self.isPinnedToBottom = true
|
||||
}
|
||||
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
||||
self.hasPerformedInitialScroll = false
|
||||
self.isPinnedToBottom = true
|
||||
}
|
||||
.onChange(of: self.viewModel.messages.count) { _, _ 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.pendingRunCount) { _, _ in
|
||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
}
|
||||
}
|
||||
.onChange(of: self.viewModel.streamingAssistantText) { _, _ in
|
||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
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.
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
@@ -124,7 +117,6 @@ public actor GatewayChannelActor {
|
||||
private var lastSeq: Int?
|
||||
private var lastTick: Date?
|
||||
private var tickIntervalMs: Double = 30000
|
||||
private var lastAuthSource: GatewayAuthSource = .none
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let connectTimeoutSeconds: Double = 6
|
||||
@@ -157,8 +149,6 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
|
||||
|
||||
public func shutdown() async {
|
||||
self.shouldReconnect = false
|
||||
self.connected = false
|
||||
@@ -310,18 +300,6 @@ public actor GatewayChannelActor {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.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
|
||||
if let 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 let runid: String
|
||||
public let timeoutms: Int?
|
||||
@@ -1485,21 +1447,17 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
||||
public struct AgentSummary: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let identity: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String?,
|
||||
identity: [String: AnyCodable]?
|
||||
name: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.identity = identity
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case identity
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,54 +18,5 @@ Related:
|
||||
clawdbot agents list
|
||||
clawdbot agents add work --workspace ~/clawd-work
|
||||
clawdbot agents set-identity --workspace ~/clawd --from-identity
|
||||
clawdbot agents set-identity --agent main --avatar avatars/clawd.png
|
||||
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
@@ -16,7 +16,6 @@ If you installed via **npm/pnpm** (global install, no git metadata), updates hap
|
||||
```bash
|
||||
clawdbot update
|
||||
clawdbot update status
|
||||
clawdbot update wizard
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --channel dev
|
||||
clawdbot update --tag beta
|
||||
@@ -49,11 +48,6 @@ Options:
|
||||
- `--json`: print machine-readable status JSON.
|
||||
- `--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
|
||||
|
||||
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
||||
@@ -75,13 +69,11 @@ High-level:
|
||||
|
||||
1. Requires a clean worktree (no uncommitted changes).
|
||||
2. Switches to the selected channel (tag or branch).
|
||||
3. Fetches 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.
|
||||
5. Rebases onto the selected commit (dev only).
|
||||
6. Installs deps (pnpm preferred; npm fallback).
|
||||
7. Builds + builds the Control UI.
|
||||
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.
|
||||
3. Fetches and rebases against `@{upstream}` (dev only).
|
||||
4. Installs deps (pnpm preferred; npm fallback).
|
||||
5. Builds + builds the Control UI.
|
||||
6. Runs `clawdbot doctor` as the final “safe update” check.
|
||||
7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
|
||||
|
||||
## `--update` shorthand
|
||||
|
||||
|
||||
@@ -140,9 +140,6 @@ workspace lives).
|
||||
|
||||
### 1) Initialize the repo
|
||||
|
||||
If git is installed, brand-new workspaces are initialized automatically. If this
|
||||
workspace is not already a repo, run:
|
||||
|
||||
```bash
|
||||
cd ~/clawd
|
||||
git init
|
||||
|
||||
@@ -24,7 +24,7 @@ The prompt is intentionally compact and uses fixed sections:
|
||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
||||
- **Reply Tags**: optional reply tag syntax for supported providers.
|
||||
- **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.
|
||||
|
||||
## Prompt modes
|
||||
|
||||
@@ -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):
|
||||
- `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)
|
||||
- `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
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
identity: {
|
||||
name: "Samantha",
|
||||
theme: "helpful sloth",
|
||||
emoji: "🦥",
|
||||
avatar: "avatars/samantha.png"
|
||||
}
|
||||
}
|
||||
{ id: "main", identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1280,18 +1266,6 @@ Default: `~/clawd`.
|
||||
If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their
|
||||
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`
|
||||
|
||||
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:
|
||||
- `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.
|
||||
- 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
|
||||
{
|
||||
ui: {
|
||||
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
|
||||
}
|
||||
seamColor: "#FF4500" // hex (RRGGBB or #RRGGBB)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -17,7 +17,6 @@ Clawdbot has two log “surfaces”:
|
||||
## File-based logger
|
||||
|
||||
- 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`:
|
||||
- `logging.file`
|
||||
- `logging.level`
|
||||
|
||||
@@ -91,8 +91,7 @@ Available groups:
|
||||
## Elevated: exec-only “run on host”
|
||||
|
||||
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).
|
||||
- Use `/elevated full` to skip exec approvals for the session.
|
||||
- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
- **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)
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
|
||||
|
||||
## Reasoning & verbose output in groups
|
||||
|
||||
|
||||
@@ -53,15 +53,6 @@ Almost always a Node/npm PATH issue. Start here:
|
||||
- [Models](/cli/models)
|
||||
- [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
|
||||
|
||||
Paste a safe report:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -157,21 +155,18 @@ Quick diagnosis:
|
||||
```bash
|
||||
node -v
|
||||
npm -v
|
||||
npm prefix -g
|
||||
npm bin -g
|
||||
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`):
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
export PATH="$(npm prefix -g)/bin:$PATH"
|
||||
export PATH="/path/from/npm/bin/-g:$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).
|
||||
|
||||
## Update / uninstall
|
||||
|
||||
+8
-11
@@ -19,36 +19,33 @@ Run:
|
||||
```bash
|
||||
node -v
|
||||
npm -v
|
||||
npm prefix -g
|
||||
npm bin -g
|
||||
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
|
||||
|
||||
1) Find your global npm prefix:
|
||||
1) Find your global bin directory:
|
||||
|
||||
```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`
|
||||
- bash: `~/.bashrc`
|
||||
|
||||
Example (replace the path with your `npm prefix -g` output):
|
||||
Example (replace the path with your `npm bin -g` output):
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
export PATH="/path/from/npm/prefix/bin:$PATH"
|
||||
export PATH="/path/from/npm/bin/-g:$PATH"
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
|
||||
- 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:
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ By default, the Gateway writes a rolling log file under:
|
||||
|
||||
`/tmp/clawdbot/clawdbot-YYYY-MM-DD.log`
|
||||
|
||||
The date uses the gateway host's local timezone.
|
||||
|
||||
You can override this in `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json
|
||||
|
||||
@@ -216,7 +216,7 @@ Option B:
|
||||
## Slash commands
|
||||
- `/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.
|
||||
- `/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
|
||||
- The runner service is the portable execution target.
|
||||
|
||||
@@ -10,7 +10,6 @@ read_when:
|
||||
- **Creature:** Flustered Protocol Droid
|
||||
- **Vibe:** Anxious, detail-obsessed, slightly dramatic about errors, secretly loves finding bugs
|
||||
- **Emoji:** 🤖 (or ⚠️ when alarmed)
|
||||
- **Avatar:** avatars/c3po.png
|
||||
|
||||
## Role
|
||||
Debug agent for `--dev` mode. Fluent in over six million error messages.
|
||||
|
||||
@@ -11,12 +11,7 @@ read_when:
|
||||
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||
- **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.
|
||||
|
||||
Notes:
|
||||
- Save this file at the workspace root as `IDENTITY.md`.
|
||||
- For avatars, use a workspace-relative path like `avatars/clawd.png`.
|
||||
+1
-1
@@ -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).
|
||||
|
||||
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
|
||||
clawdbot setup
|
||||
|
||||
+1
-25
@@ -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)
|
||||
- [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 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)
|
||||
- [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)
|
||||
@@ -1276,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
|
||||
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
|
||||
|
||||
@@ -1541,28 +1539,6 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
|
||||
|
||||
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
|
||||
|
||||
Pairing codes are sent **only** when an unknown sender messages the bot and
|
||||
|
||||
+8
-11
@@ -6,20 +6,17 @@ read_when:
|
||||
# Elevated Mode (/elevated directives)
|
||||
|
||||
## What it does
|
||||
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
|
||||
- `/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`).
|
||||
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`.
|
||||
- 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`.
|
||||
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
|
||||
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
|
||||
- Only `on|off` are accepted; anything else returns a hint and does not change state.
|
||||
|
||||
## 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).
|
||||
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
|
||||
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
|
||||
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
## Setting a session default
|
||||
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`.
|
||||
- Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`).
|
||||
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
|
||||
- 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.
|
||||
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
|
||||
|
||||
@@ -44,4 +41,4 @@ read_when:
|
||||
|
||||
## Logging + status
|
||||
- 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`).
|
||||
@@ -11,7 +11,7 @@ read_when:
|
||||
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 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
|
||||
resolved by the **ask fallback** (default: deny).
|
||||
|
||||
@@ -26,7 +26,7 @@ If the pipeline pauses for approval, the tool returns a `resumeToken` so you can
|
||||
|
||||
## 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.
|
||||
|
||||
## Enable the tool
|
||||
|
||||
@@ -78,7 +78,7 @@ Text + native (when enabled):
|
||||
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
|
||||
- `/verbose on|full|off` (alias: `/v`)
|
||||
- `/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)
|
||||
- `/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)
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ Session controls:
|
||||
- `/verbose <on|full|off>`
|
||||
- `/reasoning <on|off|stream>`
|
||||
- `/usage <off|tokens|full>`
|
||||
- `/elevated <on|off|ask|full>` (alias: `/elev`)
|
||||
- `/elevated <on|off>` (alias: `/elev`)
|
||||
- `/activation <mention|always>`
|
||||
- `/deliver <on|off>`
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
## 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).
|
||||
@@ -29,22 +29,13 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) {
|
||||
return lobsterPath;
|
||||
}
|
||||
|
||||
function isWindowsSpawnEINVAL(err: unknown) {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const code = (err as { code?: unknown }).code;
|
||||
return code === "EINVAL";
|
||||
}
|
||||
|
||||
async function runLobsterSubprocessOnce(
|
||||
params: {
|
||||
execPath: string;
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
maxStdoutBytes: number;
|
||||
},
|
||||
useShell: boolean,
|
||||
) {
|
||||
async function runLobsterSubprocess(params: {
|
||||
execPath: string;
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
maxStdoutBytes: number;
|
||||
}) {
|
||||
const { execPath, argv, cwd } = params;
|
||||
const timeoutMs = Math.max(200, params.timeoutMs);
|
||||
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
|
||||
@@ -60,8 +51,6 @@ async function runLobsterSubprocessOnce(
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: useShell,
|
||||
windowsHide: useShell ? true : undefined,
|
||||
});
|
||||
|
||||
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 {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
|
||||
Generated
-2
@@ -301,8 +301,6 @@ importers:
|
||||
|
||||
extensions/imessage: {}
|
||||
|
||||
extensions/lobster: {}
|
||||
|
||||
extensions/matrix:
|
||||
dependencies:
|
||||
'@matrix-org/matrix-sdk-crypto-nodejs':
|
||||
|
||||
@@ -7,6 +7,7 @@ TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
|
||||
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
|
||||
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
|
||||
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_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
|
||||
|
||||
@@ -20,6 +21,7 @@ Env:
|
||||
CODESIGN_TIMESTAMP=auto|on|off
|
||||
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
|
||||
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
|
||||
ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1
|
||||
HELP
|
||||
exit 0
|
||||
fi
|
||||
@@ -180,13 +182,43 @@ cat > "$ENT_TMP_RUNTIME" <<'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
|
||||
/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 "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)."
|
||||
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"
|
||||
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
|
||||
xattr -cr "$APP_BUNDLE" 2>/dev/null || true
|
||||
@@ -285,5 +317,5 @@ sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
|
||||
|
||||
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"
|
||||
@@ -11,7 +11,6 @@ COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
COPY docs ./docs
|
||||
COPY skills ./skills
|
||||
COPY extensions/memory-core ./extensions/memory-core
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
|
||||
@@ -51,27 +51,14 @@ TRASH
|
||||
start_s="$(date +%s)"
|
||||
while true; do
|
||||
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
|
||||
if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then
|
||||
return 0
|
||||
fi
|
||||
if NEEDLE=\"$needle_compact\" node --input-type=module -e "
|
||||
if NEEDLE="$needle_compact" node --input-type=module -e "
|
||||
import fs from \"node:fs\";
|
||||
const file = process.env.WIZARD_LOG_PATH;
|
||||
const needle = process.env.NEEDLE ?? \"\";
|
||||
let text = \"\";
|
||||
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
||||
if (text.length > 20000) text = text.slice(-20000);
|
||||
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
|
||||
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);
|
||||
text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\");
|
||||
process.exit(text.includes(needle) ? 0 : 1);
|
||||
"; then
|
||||
return 0
|
||||
fi
|
||||
@@ -93,35 +80,13 @@ TRASH
|
||||
}
|
||||
|
||||
wait_for_gateway() {
|
||||
for _ in $(seq 1 20); do
|
||||
if node --input-type=module -e "
|
||||
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
|
||||
for _ in $(seq 1 10); do
|
||||
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway-e2e.log; then
|
||||
return 0
|
||||
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
|
||||
done
|
||||
echo "Gateway failed to start"
|
||||
cat /tmp/gateway-e2e.log || true
|
||||
cat /tmp/gateway-e2e.log
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -151,7 +116,7 @@ TRASH
|
||||
WIZARD_LOG_PATH="$log_path"
|
||||
export WIZARD_LOG_PATH
|
||||
# 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=$!
|
||||
exec 3> "$input_fifo"
|
||||
|
||||
@@ -164,18 +129,8 @@ TRASH
|
||||
|
||||
"$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>&-
|
||||
wait "$wizard_pid"
|
||||
rm -f "$input_fifo"
|
||||
stop_gateway "$gw_pid"
|
||||
if [ -n "$validate_fn" ]; then
|
||||
@@ -221,18 +176,14 @@ TRASH
|
||||
|
||||
send_local_basic() {
|
||||
# Risk acknowledgement (default is "No").
|
||||
wait_for_log "Continue?" 60
|
||||
send $'"'"'y\r'"'"' 0.6
|
||||
# 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
|
||||
fi
|
||||
send $'"'"'\r'"'"' 0.5
|
||||
select_skip_hooks
|
||||
}
|
||||
|
||||
send_reset_config_only() {
|
||||
# Risk acknowledgement (default is "No").
|
||||
wait_for_log "Continue?" 40 || true
|
||||
send $'"'"'y\r'"'"' 0.8
|
||||
# Select reset flow for existing config.
|
||||
wait_for_log "Config handling" 40 || true
|
||||
@@ -260,27 +211,19 @@ TRASH
|
||||
|
||||
send_skills_flow() {
|
||||
# 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
|
||||
send $'"'"'n\r'"'"' 1.5
|
||||
send "" 1.0
|
||||
wait_for_log "Configure skills now?" 40 || true
|
||||
send $'"'"'n\r'"'"' 0.8
|
||||
wait_for_log "Configure complete." 40 || true
|
||||
send "" 0.8
|
||||
}
|
||||
|
||||
run_case_local_basic() {
|
||||
local home_dir
|
||||
home_dir="$(make_home local-basic)"
|
||||
export HOME="$home_dir"
|
||||
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
|
||||
run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log
|
||||
|
||||
# Assert config + workspace scaffolding.
|
||||
workspace_dir="$HOME/clawd"
|
||||
@@ -340,6 +283,25 @@ if (errors.length > 0) {
|
||||
}
|
||||
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() {
|
||||
@@ -393,7 +355,7 @@ NODE
|
||||
# Seed a remote config to exercise reset path.
|
||||
cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"agents": { "defaults": { "workspace": "/root/old" } },
|
||||
"agent": { "workspace": "/root/old" },
|
||||
"gateway": {
|
||||
"mode": "remote",
|
||||
"remote": { "url": "ws://old.example:18789", "token": "old-token" }
|
||||
@@ -401,17 +363,7 @@ NODE
|
||||
}
|
||||
JSON
|
||||
|
||||
node dist/index.js onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--reset \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-health
|
||||
run_wizard reset-config "$home_dir" send_reset_config_only
|
||||
|
||||
config_path="$HOME/.clawdbot/clawdbot.json"
|
||||
assert_file "$config_path"
|
||||
|
||||
@@ -68,70 +68,4 @@ describe("exec approvals", () => {
|
||||
const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId;
|
||||
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 prevPath = process.env.PATH;
|
||||
const prevPathExt = process.env.PATHEXT;
|
||||
process.env.PATH = binDir;
|
||||
if (process.platform === "win32") {
|
||||
process.env.PATHEXT = ".CMD";
|
||||
}
|
||||
|
||||
try {
|
||||
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: `${exeName} --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");
|
||||
} finally {
|
||||
process.env.PATH = prevPath;
|
||||
if (prevPathExt === undefined) {
|
||||
delete process.env.PATHEXT;
|
||||
} else {
|
||||
process.env.PATHEXT = prevPathExt;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,17 +8,16 @@ import {
|
||||
type ExecAsk,
|
||||
type ExecHost,
|
||||
type ExecSecurity,
|
||||
type ExecApprovalsFile,
|
||||
type ExecAllowlistEntry,
|
||||
addAllowlistEntry,
|
||||
analyzeShellCommand,
|
||||
evaluateExecAllowlist,
|
||||
isSafeBinUsage,
|
||||
matchAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
requiresExecApproval,
|
||||
resolveSafeBins,
|
||||
recordAllowlistUse,
|
||||
resolveExecApprovals,
|
||||
resolveExecApprovalsFromFile,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { buildNodeShellCommand } from "../infra/node-shell.js";
|
||||
@@ -141,7 +140,7 @@ export type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
export type ExecElevatedDefaults = {
|
||||
enabled: boolean;
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
defaultLevel: "on" | "off";
|
||||
};
|
||||
|
||||
const execSchema = Type.Object({
|
||||
@@ -707,25 +706,12 @@ export function createExecTool(
|
||||
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
|
||||
: null;
|
||||
const elevatedDefaults = defaults?.elevated;
|
||||
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);
|
||||
const elevatedDefaultMode =
|
||||
elevatedDefaults?.defaultLevel === "full"
|
||||
? "full"
|
||||
: elevatedDefaults?.defaultLevel === "ask"
|
||||
? "ask"
|
||||
: 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";
|
||||
const elevatedDefaultOn =
|
||||
elevatedDefaults?.defaultLevel === "on" &&
|
||||
elevatedDefaults.enabled &&
|
||||
elevatedDefaults.allowed;
|
||||
const elevatedRequested =
|
||||
typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn;
|
||||
if (elevatedRequested) {
|
||||
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
||||
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
|
||||
@@ -757,8 +743,6 @@ export function createExecTool(
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (elevatedRequested) {
|
||||
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
|
||||
}
|
||||
const configuredHost = defaults?.host ?? "sandbox";
|
||||
@@ -783,10 +767,6 @@ export function createExecTool(
|
||||
const configuredAsk = defaults?.ask ?? "on-miss";
|
||||
const requestedAsk = normalizeExecAsk(params.ask);
|
||||
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
|
||||
const bypassApprovals = elevatedRequested && elevatedMode === "full";
|
||||
if (bypassApprovals) {
|
||||
ask = "off";
|
||||
}
|
||||
|
||||
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
|
||||
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
|
||||
@@ -871,43 +851,7 @@ export function createExecTool(
|
||||
if (nodeEnv) {
|
||||
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
|
||||
}
|
||||
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
|
||||
let allowlistSatisfied = false;
|
||||
if (hostAsk === "on-miss" && hostSecurity === "allowlist") {
|
||||
try {
|
||||
const approvalsSnapshot = (await callGatewayTool(
|
||||
"exec.approvals.node.get",
|
||||
{ timeoutMs: 10_000 },
|
||||
{ nodeId },
|
||||
)) as { file?: unknown } | null;
|
||||
const approvalsFile =
|
||||
approvalsSnapshot && typeof approvalsSnapshot === "object"
|
||||
? approvalsSnapshot.file
|
||||
: undefined;
|
||||
if (approvalsFile && typeof approvalsFile === "object") {
|
||||
const resolved = resolveExecApprovalsFromFile({
|
||||
file: approvalsFile as ExecApprovalsFile,
|
||||
agentId,
|
||||
overrides: { security: "allowlist" },
|
||||
});
|
||||
// Allowlist-only precheck; safe bins are node-local and may diverge.
|
||||
allowlistSatisfied = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: resolved.allowlist,
|
||||
safeBins: new Set(),
|
||||
cwd: workdir,
|
||||
}).allowlistSatisfied;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to requiring approval if node approvals cannot be fetched.
|
||||
}
|
||||
}
|
||||
const requiresAsk = requiresExecApproval({
|
||||
ask: hostAsk,
|
||||
security: hostSecurity,
|
||||
analysisOk: analysis.ok,
|
||||
allowlistSatisfied,
|
||||
});
|
||||
const requiresAsk = hostAsk === "always" || hostAsk === "on-miss";
|
||||
const commandText = params.command;
|
||||
const invokeTimeoutMs = Math.max(
|
||||
10_000,
|
||||
@@ -958,8 +902,8 @@ export function createExecTool(
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId,
|
||||
resolvedPath: undefined,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
resolvedPath: null,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
)) 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 hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
@@ -1096,28 +1040,33 @@ export function createExecTool(
|
||||
throw new Error("exec denied: host=gateway security=deny");
|
||||
}
|
||||
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
|
||||
const allowlistEval = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: approvals.allowlist,
|
||||
safeBins,
|
||||
cwd: workdir,
|
||||
});
|
||||
const allowlistMatches = allowlistEval.allowlistMatches;
|
||||
const allowlistSatisfied =
|
||||
hostSecurity === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false;
|
||||
const requiresAsk = requiresExecApproval({
|
||||
ask: hostAsk,
|
||||
security: hostSecurity,
|
||||
analysisOk: analysis.ok,
|
||||
allowlistSatisfied,
|
||||
});
|
||||
const allowlistMatches: ExecAllowlistEntry[] = [];
|
||||
let allowlistSatisfied = false;
|
||||
if (hostSecurity === "allowlist" && analysis.ok && analysis.segments.length > 0) {
|
||||
allowlistSatisfied = analysis.segments.every((segment) => {
|
||||
const match = matchAllowlist(approvals.allowlist, segment.resolution);
|
||||
if (match) allowlistMatches.push(match);
|
||||
const safe = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins,
|
||||
cwd: workdir,
|
||||
});
|
||||
return Boolean(match || safe);
|
||||
});
|
||||
}
|
||||
const requiresAsk =
|
||||
hostAsk === "always" ||
|
||||
(hostAsk === "on-miss" &&
|
||||
hostSecurity === "allowlist" &&
|
||||
(!analysis.ok || !allowlistSatisfied));
|
||||
|
||||
if (requiresAsk) {
|
||||
const approvalId = crypto.randomUUID();
|
||||
const approvalSlug = createApprovalSlug(approvalId);
|
||||
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
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 commandText = params.command;
|
||||
const effectiveTimeout =
|
||||
@@ -1139,7 +1088,7 @@ export function createExecTool(
|
||||
ask: hostAsk,
|
||||
agentId,
|
||||
resolvedPath,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
)) as { decision?: string } | null;
|
||||
|
||||
@@ -55,7 +55,6 @@ export type CodexCliCredential = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
export type QwenCliCredential = {
|
||||
@@ -144,7 +143,6 @@ function readCodexKeychainCredentials(options?: {
|
||||
const expires = Number.isFinite(lastRefresh)
|
||||
? lastRefresh + 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", {
|
||||
source: "keychain",
|
||||
@@ -157,7 +155,6 @@ function readCodexKeychainCredentials(options?: {
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires,
|
||||
accountId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -446,7 +443,6 @@ export function readCodexCliCredentials(options?: {
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires,
|
||||
accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -183,8 +183,6 @@ export function buildSystemPrompt(params: {
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cwd: process.cwd(),
|
||||
runtime: {
|
||||
host: "clawdbot",
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -68,10 +68,6 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
|
||||
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 {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return fallback;
|
||||
@@ -447,15 +443,6 @@ describeLive("live models (profile keys)", () => {
|
||||
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
allowNotFoundSkip &&
|
||||
model.provider === "openai-codex" &&
|
||||
isInstructionsRequiredError(message)
|
||||
) {
|
||||
skipped.push({ model: id, reason: message });
|
||||
logProgress(`${progressLabel}: skip (instructions required)`);
|
||||
break;
|
||||
}
|
||||
logProgress(`${progressLabel}: failed`);
|
||||
failures.push({ model: id, error: message });
|
||||
break;
|
||||
|
||||
@@ -6,15 +6,9 @@ export function isGoogleModelApi(api?: string | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isAntigravityClaude(params: {
|
||||
api?: string | null;
|
||||
provider?: string | null;
|
||||
modelId?: string;
|
||||
}): boolean {
|
||||
const provider = params.provider?.toLowerCase();
|
||||
const api = params.api?.toLowerCase();
|
||||
if (provider !== "google-antigravity" && api !== "google-antigravity") return false;
|
||||
return params.modelId?.toLowerCase().includes("claude") ?? false;
|
||||
export function isAntigravityClaude(api?: string | null, modelId?: string): boolean {
|
||||
if (api !== "google-antigravity") return false;
|
||||
return modelId?.toLowerCase().includes("claude") ?? false;
|
||||
}
|
||||
|
||||
export { sanitizeGoogleTurnOrdering };
|
||||
@@ -86,7 +86,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
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 input = [
|
||||
{
|
||||
@@ -107,37 +107,11 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
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 {
|
||||
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?.[0]?.thinking).toBe("reasoning");
|
||||
expect(assistant.content?.[0]?.thinkingSignature).toBe("c2ln");
|
||||
});
|
||||
|
||||
it("preserves order for mixed assistant content", async () => {
|
||||
|
||||
@@ -55,15 +55,6 @@ const MISTRAL_MODEL_HINTS = [
|
||||
"ministral",
|
||||
"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 {
|
||||
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));
|
||||
}
|
||||
|
||||
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[] {
|
||||
if (!schema || typeof schema !== "object") return [];
|
||||
if (Array.isArray(schema)) {
|
||||
@@ -278,11 +209,7 @@ export async function sanitizeSessionHistory(params: {
|
||||
sessionManager: SessionManager;
|
||||
sessionId: string;
|
||||
}): Promise<AgentMessage[]> {
|
||||
const isAntigravityClaudeModel = isAntigravityClaude({
|
||||
api: params.modelApi,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
||||
const provider = normalizeProviderId(params.provider ?? "");
|
||||
const modelId = (params.modelId ?? "").toLowerCase();
|
||||
const isOpenRouterGemini =
|
||||
@@ -294,15 +221,12 @@ export async function sanitizeSessionHistory(params: {
|
||||
sanitizeToolCallIds,
|
||||
toolCallIdMode,
|
||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||
preserveSignatures: isAntigravityClaudeModel,
|
||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
||||
sanitizeThoughtSignatures: isOpenRouterGemini
|
||||
? { allowBase64Only: true, includeCamelCase: true }
|
||||
: undefined,
|
||||
});
|
||||
const sanitizedThinking = isAntigravityClaudeModel
|
||||
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
|
||||
: sanitizedImages;
|
||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedThinking);
|
||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||
|
||||
return applyGoogleTurnOrderingFix({
|
||||
messages: repairedTools,
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
loadWorkspaceSkillEntries,
|
||||
resolveSkillsPromptForRun,
|
||||
} from "../../skills.js";
|
||||
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
|
||||
import { buildSystemPromptReport } from "../../system-prompt-report.js";
|
||||
import { resolveDefaultModelForAgent } from "../../model-selection.js";
|
||||
|
||||
@@ -185,11 +184,6 @@ export async function runEmbeddedAttempt(
|
||||
sessionId: params.sessionId,
|
||||
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();
|
||||
|
||||
@@ -285,8 +279,6 @@ export async function runEmbeddedAttempt(
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
cwd: process.cwd(),
|
||||
runtime: {
|
||||
host: machineName,
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
@@ -320,7 +312,6 @@ export async function runEmbeddedAttempt(
|
||||
: undefined,
|
||||
skillsPrompt,
|
||||
docsPath: docsPath ?? undefined,
|
||||
workspaceNotes,
|
||||
reactionGuidance,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
|
||||
@@ -20,7 +20,6 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||
level: "minimal" | "extensive";
|
||||
channel: string;
|
||||
};
|
||||
workspaceNotes?: string[];
|
||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||
promptMode?: PromptMode;
|
||||
runtimeInfo: {
|
||||
@@ -55,7 +54,6 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||
heartbeatPrompt: params.heartbeatPrompt,
|
||||
skillsPrompt: params.skillsPrompt,
|
||||
docsPath: params.docsPath,
|
||||
workspaceNotes: params.workspaceNotes,
|
||||
reactionGuidance: params.reactionGuidance,
|
||||
promptMode: params.promptMode,
|
||||
runtimeInfo: params.runtimeInfo,
|
||||
|
||||
@@ -76,6 +76,6 @@ export type EmbeddedSandboxInfo = {
|
||||
allowedControlPorts?: number[];
|
||||
elevated?: {
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
defaultLevel: "on" | "off";
|
||||
};
|
||||
};
|
||||
@@ -226,27 +226,24 @@ export function handleMessageEnd(
|
||||
);
|
||||
} else {
|
||||
ctx.state.lastBlockReplyText = text;
|
||||
const splitResult = ctx.consumeReplyDirectives(text, { final: true });
|
||||
if (splitResult) {
|
||||
const {
|
||||
const {
|
||||
text: cleanedText,
|
||||
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,
|
||||
mediaUrls,
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
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);
|
||||
}
|
||||
|
||||
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.blockBuffer = "";
|
||||
ctx.blockChunker?.reset();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
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 { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
||||
import type { MessagingToolSend } from "./pi-embedded-messaging.js";
|
||||
@@ -78,10 +77,6 @@ export type EmbeddedPiSubscribeContext = {
|
||||
emitBlockChunk: (text: string) => void;
|
||||
flushBlockReplyBuffer: () => void;
|
||||
emitReasoningStream: (text: string) => void;
|
||||
consumeReplyDirectives: (
|
||||
text: string,
|
||||
options?: { final?: boolean },
|
||||
) => ReplyDirectiveParseResult | null;
|
||||
resetAssistantMessageState: (nextAssistantTextBaseline: number) => void;
|
||||
resetForCompactionRetry: () => void;
|
||||
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,5 +1,4 @@
|
||||
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 { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type { InlineCodeState } from "../markdown/code-spans.js";
|
||||
@@ -76,13 +75,11 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
const messagingToolSentTargets = state.messagingToolSentTargets;
|
||||
const pendingMessagingTexts = state.pendingMessagingTexts;
|
||||
const pendingMessagingTargets = state.pendingMessagingTargets;
|
||||
const replyDirectiveAccumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
const resetAssistantMessageState = (nextAssistantTextBaseline: number) => {
|
||||
state.deltaBuffer = "";
|
||||
state.blockBuffer = "";
|
||||
blockChunker?.reset();
|
||||
replyDirectiveAccumulator.reset();
|
||||
state.blockState.thinking = false;
|
||||
state.blockState.final = false;
|
||||
state.blockState.inlineCode = createInlineCodeState();
|
||||
@@ -377,8 +374,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
assistantTexts.push(chunk);
|
||||
rememberAssistantText(chunk);
|
||||
if (!params.onBlockReply) return;
|
||||
const splitResult = replyDirectiveAccumulator.consume(chunk);
|
||||
if (!splitResult) return;
|
||||
const splitResult = parseReplyDirectives(chunk);
|
||||
const {
|
||||
text: cleanedText,
|
||||
mediaUrls,
|
||||
@@ -399,9 +395,6 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
});
|
||||
};
|
||||
|
||||
const consumeReplyDirectives = (text: string, options?: { final?: boolean }) =>
|
||||
replyDirectiveAccumulator.consume(text, options);
|
||||
|
||||
const flushBlockReplyBuffer = () => {
|
||||
if (!params.onBlockReply) return;
|
||||
if (blockChunker?.hasBuffered()) {
|
||||
@@ -454,7 +447,6 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
emitBlockChunk,
|
||||
flushBlockReplyBuffer,
|
||||
emitReasoningStream,
|
||||
consumeReplyDirectives,
|
||||
resetAssistantMessageState,
|
||||
resetForCompactionRetry,
|
||||
finalizeAssistantTexts,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
formatUserTime,
|
||||
@@ -21,7 +18,6 @@ export type RuntimeInfoInput = {
|
||||
capabilities?: string[];
|
||||
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
||||
channelActions?: string[];
|
||||
repoRoot?: string;
|
||||
};
|
||||
|
||||
export type SystemPromptRuntimeParams = {
|
||||
@@ -35,14 +31,7 @@ export function buildSystemPromptParams(params: {
|
||||
config?: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
runtime: Omit<RuntimeInfoInput, "agentId">;
|
||||
workspaceDir?: string;
|
||||
cwd?: string;
|
||||
}): SystemPromptRuntimeParams {
|
||||
const repoRoot = resolveRepoRoot({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cwd: params.cwd,
|
||||
});
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
@@ -50,56 +39,9 @@ export function buildSystemPromptParams(params: {
|
||||
runtimeInfo: {
|
||||
agentId: params.agentId,
|
||||
...params.runtime,
|
||||
repoRoot,
|
||||
},
|
||||
userTimezone,
|
||||
userTime,
|
||||
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;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).not.toContain("Owner numbers:");
|
||||
});
|
||||
|
||||
it("keeps skills but omits other extended sections in minimal prompt mode", () => {
|
||||
it("omits extended sections in minimal prompt mode", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
promptMode: "minimal",
|
||||
@@ -37,8 +37,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
});
|
||||
|
||||
expect(prompt).not.toContain("## User Identity");
|
||||
expect(prompt).toContain("## Skills (mandatory)");
|
||||
expect(prompt).toContain("<available_skills>");
|
||||
expect(prompt).not.toContain("## Skills");
|
||||
expect(prompt).not.toContain("## Memory Recall");
|
||||
expect(prompt).not.toContain("## Documentation");
|
||||
expect(prompt).not.toContain("## Reply Tags");
|
||||
@@ -116,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)", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
@@ -294,7 +284,6 @@ describe("buildAgentSystemPrompt", () => {
|
||||
{
|
||||
agentId: "work",
|
||||
host: "host",
|
||||
repoRoot: "/repo",
|
||||
os: "macOS",
|
||||
arch: "arm64",
|
||||
node: "v20",
|
||||
@@ -308,7 +297,6 @@ describe("buildAgentSystemPrompt", () => {
|
||||
|
||||
expect(line).toContain("agent=work");
|
||||
expect(line).toContain("host=host");
|
||||
expect(line).toContain("repo=/repo");
|
||||
expect(line).toContain("os=macOS (arm64)");
|
||||
expect(line).toContain("node=v20");
|
||||
expect(line).toContain("model=anthropic/claude");
|
||||
@@ -332,7 +320,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
|
||||
expect(prompt).toContain("You are running in a sandboxed runtime");
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||
/**
|
||||
* Controls which hardcoded sections are included in the system prompt.
|
||||
* - "full": All sections (default, for main agent)
|
||||
* - "minimal": Reduced sections (Tooling, Skills, Workspace, Runtime) - used for subagents
|
||||
* - "minimal": Reduced sections (Tooling, Workspace, Runtime) - used for subagents
|
||||
* - "none": Just basic identity line, no sections
|
||||
*/
|
||||
export type PromptMode = "full" | "minimal" | "none";
|
||||
@@ -18,7 +18,7 @@ function buildSkillsSection(params: {
|
||||
readToolName: string;
|
||||
}) {
|
||||
const trimmed = params.skillsPrompt?.trim();
|
||||
if (!trimmed) return [];
|
||||
if (!trimmed || params.isMinimal) return [];
|
||||
return [
|
||||
"## Skills (mandatory)",
|
||||
"Before replying: scan <available_skills> <description> entries.",
|
||||
@@ -148,7 +148,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
skillsPrompt?: string;
|
||||
heartbeatPrompt?: string;
|
||||
docsPath?: string;
|
||||
workspaceNotes?: string[];
|
||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||
promptMode?: PromptMode;
|
||||
runtimeInfo?: {
|
||||
@@ -161,7 +160,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
defaultModel?: string;
|
||||
channel?: string;
|
||||
capabilities?: string[];
|
||||
repoRoot?: string;
|
||||
};
|
||||
messageToolHints?: string[];
|
||||
sandboxInfo?: {
|
||||
@@ -177,7 +175,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
allowedControlPorts?: number[];
|
||||
elevated?: {
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
defaultLevel: "on" | "off";
|
||||
};
|
||||
};
|
||||
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
|
||||
@@ -328,7 +326,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
isMinimal,
|
||||
readToolName,
|
||||
});
|
||||
const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean);
|
||||
|
||||
// For "none" mode, return just the basic identity line
|
||||
if (promptMode === "none") {
|
||||
@@ -405,7 +402,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
"## Workspace",
|
||||
`Your working directory is: ${params.workspaceDir}`,
|
||||
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
||||
...workspaceNotes,
|
||||
"",
|
||||
...docsSection,
|
||||
params.sandboxInfo?.enabled ? "## Sandbox" : "",
|
||||
@@ -447,14 +443,12 @@ export function buildAgentSystemPrompt(params: {
|
||||
params.sandboxInfo.elevated?.allowed
|
||||
? "Elevated exec is available for this session."
|
||||
: "",
|
||||
params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "",
|
||||
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
|
||||
? "You may also send /elevated on|off|ask|full when needed."
|
||||
: "",
|
||||
params.sandboxInfo.elevated?.allowed
|
||||
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).`
|
||||
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -576,7 +570,6 @@ export function buildRuntimeLine(
|
||||
node?: string;
|
||||
model?: string;
|
||||
defaultModel?: string;
|
||||
repoRoot?: string;
|
||||
},
|
||||
runtimeChannel?: string,
|
||||
runtimeCapabilities: string[] = [],
|
||||
@@ -585,7 +578,6 @@ export function buildRuntimeLine(
|
||||
return `Runtime: ${[
|
||||
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
|
||||
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
||||
runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "",
|
||||
runtimeInfo?.os
|
||||
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
|
||||
: runtimeInfo?.arch
|
||||
|
||||
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
@@ -41,34 +40,6 @@ describe("ensureAgentWorkspace", () => {
|
||||
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 () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
||||
const agentsPath = path.join(dir, "AGENTS.md");
|
||||
|
||||
+150
-43
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
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";
|
||||
|
||||
export function resolveDefaultAgentWorkspaceDir(
|
||||
@@ -27,9 +27,142 @@ export const DEFAULT_USER_FILENAME = "USER.md";
|
||||
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.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(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../docs/reference/templates",
|
||||
"../../docs/templates",
|
||||
);
|
||||
|
||||
function stripFrontMatter(content: string): string {
|
||||
@@ -42,15 +175,13 @@ function stripFrontMatter(content: string): string {
|
||||
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);
|
||||
try {
|
||||
const content = await fs.readFile(templatePath, "utf-8");
|
||||
return stripFrontMatter(content);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Missing workspace template: ${name} (${templatePath}). Ensure docs/reference/templates are packaged.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?: {
|
||||
dir?: string;
|
||||
ensureBootstrapFiles?: boolean;
|
||||
@@ -153,13 +255,19 @@ export async function ensureAgentWorkspace(params?: {
|
||||
return existing.every((v) => !v);
|
||||
})();
|
||||
|
||||
const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME);
|
||||
const soulTemplate = await loadTemplate(DEFAULT_SOUL_FILENAME);
|
||||
const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME);
|
||||
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME);
|
||||
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
|
||||
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
|
||||
const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);
|
||||
const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_TEMPLATE);
|
||||
const soulTemplate = await loadTemplate(DEFAULT_SOUL_FILENAME, DEFAULT_SOUL_TEMPLATE);
|
||||
const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME, DEFAULT_TOOLS_TEMPLATE);
|
||||
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME, DEFAULT_IDENTITY_TEMPLATE);
|
||||
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME, DEFAULT_USER_TEMPLATE);
|
||||
const heartbeatTemplate = await loadTemplate(
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
DEFAULT_HEARTBEAT_TEMPLATE,
|
||||
);
|
||||
const bootstrapTemplate = await loadTemplate(
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_TEMPLATE,
|
||||
);
|
||||
|
||||
await writeFileIfMissing(agentsPath, agentsTemplate);
|
||||
await writeFileIfMissing(soulPath, soulTemplate);
|
||||
@@ -170,7 +278,6 @@ export async function ensureAgentWorkspace(params?: {
|
||||
if (isBrandNewWorkspace) {
|
||||
await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
|
||||
}
|
||||
await ensureGitRepo(dir, isBrandNewWorkspace);
|
||||
|
||||
return {
|
||||
dir,
|
||||
|
||||
@@ -395,9 +395,9 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on, off, ask, or full",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["on", "off", "ask", "full"],
|
||||
choices: ["on", "off"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
|
||||
+1
-1
@@ -219,7 +219,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
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 () => {
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,16 +55,6 @@ describe("directive parsing", () => {
|
||||
expect(res.hasDirective).toBe(true);
|
||||
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", () => {
|
||||
const res = extractThinkDirective("/think:high run slow");
|
||||
|
||||
+2
-2
@@ -129,7 +129,7 @@ describe("trigger handling", () => {
|
||||
cfg,
|
||||
);
|
||||
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 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;
|
||||
expect(text).toBe("ok");
|
||||
expect(text).not.toContain("Elevated mode set to ask");
|
||||
expect(text).not.toContain("Elevated mode enabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -184,7 +184,7 @@ describe("trigger handling", () => {
|
||||
cfg,
|
||||
);
|
||||
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 store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
|
||||
@@ -226,7 +226,7 @@ describe("trigger handling", () => {
|
||||
cfg,
|
||||
);
|
||||
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 store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
|
||||
|
||||
+1
-1
@@ -167,7 +167,7 @@ describe("trigger handling", () => {
|
||||
cfg,
|
||||
);
|
||||
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 store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
|
||||
|
||||
@@ -102,8 +102,6 @@ async function resolveContextReport(
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
workspaceDir,
|
||||
cwd: process.cwd(),
|
||||
runtime: {
|
||||
host: "unknown",
|
||||
os: "unknown",
|
||||
@@ -120,7 +118,7 @@ async function resolveContextReport(
|
||||
workspaceAccess: "rw" as const,
|
||||
elevated: {
|
||||
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 };
|
||||
|
||||
@@ -45,7 +45,7 @@ function formatTimestampWithAge(valueMs?: number) {
|
||||
}
|
||||
|
||||
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;
|
||||
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
|
||||
return resolveInternalSessionKey({ key: raw, alias, mainKey });
|
||||
|
||||
@@ -215,33 +215,6 @@ describe("handleCommands subagents", () => {
|
||||
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 () => {
|
||||
resetSubagentRegistryForTests();
|
||||
const cfg = {
|
||||
|
||||
@@ -205,7 +205,7 @@ export async function handleDirectiveOnly(params: {
|
||||
const level = currentElevatedLevel ?? "off";
|
||||
return {
|
||||
text: [
|
||||
withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"),
|
||||
withOptions(`Current elevated level: ${level}.`, "on, off"),
|
||||
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -213,7 +213,7 @@ export async function handleDirectiveOnly(params: {
|
||||
};
|
||||
}
|
||||
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)) {
|
||||
@@ -426,9 +426,7 @@ export async function handleDirectiveOnly(params: {
|
||||
parts.push(
|
||||
directives.elevatedLevel === "off"
|
||||
? formatDirectiveAck("Elevated mode disabled.")
|
||||
: directives.elevatedLevel === "full"
|
||||
? formatDirectiveAck("Elevated mode set to full (auto-approve).")
|
||||
: formatDirectiveAck("Elevated mode set to ask (approvals may still apply)."),
|
||||
: formatDirectiveAck("Elevated mode enabled."),
|
||||
);
|
||||
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
|
||||
}
|
||||
|
||||
@@ -16,15 +16,10 @@ export const withOptions = (line: string, options: string) =>
|
||||
export const formatElevatedRuntimeHint = () =>
|
||||
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
|
||||
|
||||
export const formatElevatedEvent = (level: ElevatedLevel) => {
|
||||
if (level === "full") {
|
||||
return "Elevated FULL — exec runs on host with auto-approval.";
|
||||
}
|
||||
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 formatElevatedEvent = (level: ElevatedLevel) =>
|
||||
level === "on"
|
||||
? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed."
|
||||
: "Elevated OFF — exec stays in sandbox.";
|
||||
|
||||
export const formatReasoningEvent = (level: ReasoningLevel) => {
|
||||
if (level === "stream") return "Reasoning STREAM — emit live <think>.";
|
||||
|
||||
@@ -379,7 +379,6 @@ export async function resolveReplyDirectives(params: {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
parentSessionKey: ctx.ParentSessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function runPreparedReply(
|
||||
const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
|
||||
const mediaNote = buildInboundMediaNote(ctx);
|
||||
const mediaReplyHint = mediaNote
|
||||
? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body."
|
||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||
: undefined;
|
||||
let prefixedCommandBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { createModelSelectionState } from "./model-selection.js";
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(async () => [
|
||||
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
|
||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||
]),
|
||||
}));
|
||||
|
||||
const defaultProvider = "openai";
|
||||
const defaultModel = "gpt-4o-mini";
|
||||
|
||||
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||
sessionId: "session-id",
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function resolveState(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
sessionEntry: ReturnType<typeof makeEntry>;
|
||||
sessionStore: Record<string, ReturnType<typeof makeEntry>>;
|
||||
sessionKey: string;
|
||||
parentSessionKey?: string;
|
||||
}) {
|
||||
return createModelSelectionState({
|
||||
cfg: params.cfg,
|
||||
agentCfg: params.cfg.agents?.defaults,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
parentSessionKey: params.parentSessionKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: defaultProvider,
|
||||
model: defaultModel,
|
||||
hasModelDirective: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("createModelSelectionState parent inheritance", () => {
|
||||
it("inherits parent override from explicit parentSessionKey", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const parentKey = "agent:main:discord:channel:C1";
|
||||
const sessionKey = "agent:main:discord:channel:C1:thread:123";
|
||||
const parentEntry = makeEntry({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4o",
|
||||
});
|
||||
const sessionEntry = makeEntry();
|
||||
const sessionStore = {
|
||||
[parentKey]: parentEntry,
|
||||
[sessionKey]: sessionEntry,
|
||||
};
|
||||
|
||||
const state = await resolveState({
|
||||
cfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
parentSessionKey: parentKey,
|
||||
});
|
||||
|
||||
expect(state.provider).toBe("openai");
|
||||
expect(state.model).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("derives parent key from topic session suffix", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const parentKey = "agent:main:telegram:group:123";
|
||||
const sessionKey = "agent:main:telegram:group:123:topic:99";
|
||||
const parentEntry = makeEntry({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4o",
|
||||
});
|
||||
const sessionEntry = makeEntry();
|
||||
const sessionStore = {
|
||||
[parentKey]: parentEntry,
|
||||
[sessionKey]: sessionEntry,
|
||||
};
|
||||
|
||||
const state = await resolveState({
|
||||
cfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
expect(state.provider).toBe("openai");
|
||||
expect(state.model).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("prefers child override over parent", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const parentKey = "agent:main:telegram:group:123";
|
||||
const sessionKey = "agent:main:telegram:group:123:topic:99";
|
||||
const parentEntry = makeEntry({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4o",
|
||||
});
|
||||
const sessionEntry = makeEntry({
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-opus-4-5",
|
||||
});
|
||||
const sessionStore = {
|
||||
[parentKey]: parentEntry,
|
||||
[sessionKey]: sessionEntry,
|
||||
};
|
||||
|
||||
const state = await resolveState({
|
||||
cfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
expect(state.provider).toBe("anthropic");
|
||||
expect(state.model).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("ignores parent override when disallowed", async () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4o-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const parentKey = "agent:main:slack:channel:C1";
|
||||
const sessionKey = "agent:main:slack:channel:C1:thread:123";
|
||||
const parentEntry = makeEntry({
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-opus-4-5",
|
||||
});
|
||||
const sessionEntry = makeEntry();
|
||||
const sessionStore = {
|
||||
[parentKey]: parentEntry,
|
||||
[sessionKey]: sessionEntry,
|
||||
};
|
||||
|
||||
const state = await resolveState({
|
||||
cfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
expect(state.provider).toBe(defaultProvider);
|
||||
expect(state.model).toBe(defaultModel);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||
import { resolveThreadParentSessionKey } from "../../sessions/session-key-utils.js";
|
||||
import type { ThinkLevel } from "./directives.js";
|
||||
|
||||
export type ModelDirectiveSelection = {
|
||||
@@ -79,52 +78,6 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
|
||||
return dist;
|
||||
}
|
||||
|
||||
type StoredModelOverride = {
|
||||
provider?: string;
|
||||
model: string;
|
||||
source: "session" | "parent";
|
||||
};
|
||||
|
||||
function resolveModelOverrideFromEntry(entry?: SessionEntry): {
|
||||
provider?: string;
|
||||
model: string;
|
||||
} | null {
|
||||
const model = entry?.modelOverride?.trim();
|
||||
if (!model) return null;
|
||||
const provider = entry?.providerOverride?.trim() || undefined;
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
function resolveParentSessionKeyCandidate(params: {
|
||||
sessionKey?: string;
|
||||
parentSessionKey?: string;
|
||||
}): string | null {
|
||||
const explicit = params.parentSessionKey?.trim();
|
||||
if (explicit && explicit !== params.sessionKey) return explicit;
|
||||
const derived = resolveThreadParentSessionKey(params.sessionKey);
|
||||
if (derived && derived !== params.sessionKey) return derived;
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStoredModelOverride(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
parentSessionKey?: string;
|
||||
}): StoredModelOverride | null {
|
||||
const direct = resolveModelOverrideFromEntry(params.sessionEntry);
|
||||
if (direct) return { ...direct, source: "session" };
|
||||
const parentKey = resolveParentSessionKeyCandidate({
|
||||
sessionKey: params.sessionKey,
|
||||
parentSessionKey: params.parentSessionKey,
|
||||
});
|
||||
if (!parentKey || !params.sessionStore) return null;
|
||||
const parentEntry = params.sessionStore[parentKey];
|
||||
const parentOverride = resolveModelOverrideFromEntry(parentEntry);
|
||||
if (!parentOverride) return null;
|
||||
return { ...parentOverride, source: "parent" };
|
||||
}
|
||||
|
||||
function scoreFuzzyMatch(params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -224,7 +177,6 @@ export async function createModelSelectionState(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
parentSessionKey?: string;
|
||||
storePath?: string;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
@@ -238,7 +190,6 @@ export async function createModelSelectionState(params: {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
parentSessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
@@ -248,13 +199,7 @@ export async function createModelSelectionState(params: {
|
||||
let model = params.model;
|
||||
|
||||
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
||||
const initialStoredOverride = resolveStoredModelOverride({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
parentSessionKey,
|
||||
});
|
||||
const hasStoredOverride = Boolean(initialStoredOverride);
|
||||
const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride);
|
||||
const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride;
|
||||
|
||||
let allowedModelKeys = new Set<string>();
|
||||
@@ -297,18 +242,14 @@ export async function createModelSelectionState(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const storedOverride = resolveStoredModelOverride({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
parentSessionKey,
|
||||
});
|
||||
if (storedOverride?.model) {
|
||||
const candidateProvider = storedOverride.provider || defaultProvider;
|
||||
const key = modelKey(candidateProvider, storedOverride.model);
|
||||
const storedProviderOverride = sessionEntry?.providerOverride?.trim();
|
||||
const storedModelOverride = sessionEntry?.modelOverride?.trim();
|
||||
if (storedModelOverride) {
|
||||
const candidateProvider = storedProviderOverride || defaultProvider;
|
||||
const key = modelKey(candidateProvider, storedModelOverride);
|
||||
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
||||
provider = candidateProvider;
|
||||
model = storedOverride.model;
|
||||
model = storedModelOverride;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
|
||||
|
||||
describe("createStreamingDirectiveAccumulator", () => {
|
||||
it("stashes reply_to_current until a renderable chunk arrives", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hello");
|
||||
expect(result?.text).toBe("Hello");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("handles reply tags split across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("current]] Yo");
|
||||
expect(result?.text).toBe("Yo");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates explicit reply ids across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hi");
|
||||
expect(result?.text).toBe("Hi");
|
||||
expect(result?.replyToId).toBe("abc-123");
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { splitMediaFromOutput } from "../../media/parse.js";
|
||||
import { parseInlineDirectives } from "../../utils/directive-tags.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyDirectiveParseResult } from "./reply-directives.js";
|
||||
|
||||
type PendingReplyState = {
|
||||
explicitId?: string;
|
||||
sawCurrent: boolean;
|
||||
hasTag: boolean;
|
||||
};
|
||||
|
||||
type ParsedChunk = ReplyDirectiveParseResult & {
|
||||
replyToExplicitId?: string;
|
||||
};
|
||||
|
||||
type ConsumeOptions = {
|
||||
final?: boolean;
|
||||
silentToken?: string;
|
||||
};
|
||||
|
||||
const splitTrailingDirective = (text: string): { text: string; tail: string } => {
|
||||
const openIndex = text.lastIndexOf("[[");
|
||||
if (openIndex < 0) return { text, tail: "" };
|
||||
const closeIndex = text.indexOf("]]", openIndex + 2);
|
||||
if (closeIndex >= 0) return { text, tail: "" };
|
||||
return {
|
||||
text: text.slice(0, openIndex),
|
||||
tail: text.slice(openIndex),
|
||||
};
|
||||
};
|
||||
|
||||
const parseChunk = (raw: string, options?: { silentToken?: string }): ParsedChunk => {
|
||||
const split = splitMediaFromOutput(raw);
|
||||
let text = split.text ?? "";
|
||||
|
||||
const replyParsed = parseInlineDirectives(text, {
|
||||
stripAudioTag: false,
|
||||
stripReplyTags: true,
|
||||
});
|
||||
|
||||
if (replyParsed.hasReplyTag) {
|
||||
text = replyParsed.text;
|
||||
}
|
||||
|
||||
const silentToken = options?.silentToken ?? SILENT_REPLY_TOKEN;
|
||||
const isSilent = isSilentReplyText(text, silentToken);
|
||||
if (isSilent) {
|
||||
text = "";
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
mediaUrls: split.mediaUrls,
|
||||
mediaUrl: split.mediaUrl,
|
||||
replyToId: replyParsed.replyToId,
|
||||
replyToExplicitId: replyParsed.replyToExplicitId,
|
||||
replyToCurrent: replyParsed.replyToCurrent,
|
||||
replyToTag: replyParsed.hasReplyTag,
|
||||
audioAsVoice: split.audioAsVoice,
|
||||
isSilent,
|
||||
};
|
||||
};
|
||||
|
||||
const hasRenderableContent = (parsed: ReplyDirectiveParseResult): boolean =>
|
||||
Boolean(parsed.text) ||
|
||||
Boolean(parsed.mediaUrl) ||
|
||||
(parsed.mediaUrls?.length ?? 0) > 0 ||
|
||||
Boolean(parsed.audioAsVoice);
|
||||
|
||||
export function createStreamingDirectiveAccumulator() {
|
||||
let pendingTail = "";
|
||||
let pendingReply: PendingReplyState = { sawCurrent: false, hasTag: false };
|
||||
|
||||
const reset = () => {
|
||||
pendingTail = "";
|
||||
pendingReply = { sawCurrent: false, hasTag: false };
|
||||
};
|
||||
|
||||
const consume = (raw: string, options: ConsumeOptions = {}): ReplyDirectiveParseResult | null => {
|
||||
let combined = `${pendingTail}${raw ?? ""}`;
|
||||
pendingTail = "";
|
||||
|
||||
if (!options.final) {
|
||||
const split = splitTrailingDirective(combined);
|
||||
combined = split.text;
|
||||
pendingTail = split.tail;
|
||||
}
|
||||
|
||||
if (!combined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseChunk(combined, { silentToken: options.silentToken });
|
||||
const hasTag = pendingReply.hasTag || parsed.replyToTag;
|
||||
const sawCurrent = pendingReply.sawCurrent || parsed.replyToCurrent;
|
||||
const explicitId = parsed.replyToExplicitId ?? pendingReply.explicitId;
|
||||
|
||||
const combinedResult: ReplyDirectiveParseResult = {
|
||||
...parsed,
|
||||
replyToId: explicitId,
|
||||
replyToCurrent: sawCurrent,
|
||||
replyToTag: hasTag,
|
||||
};
|
||||
|
||||
if (!hasRenderableContent(combinedResult)) {
|
||||
if (hasTag) {
|
||||
pendingReply = {
|
||||
explicitId,
|
||||
sawCurrent,
|
||||
hasTag,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pendingReply = { sawCurrent: false, hasTag: false };
|
||||
return combinedResult;
|
||||
};
|
||||
|
||||
return {
|
||||
consume,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -324,12 +324,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const queueDetails = formatQueueDetails(args.queue);
|
||||
const verboseLabel =
|
||||
verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null;
|
||||
const elevatedLabel =
|
||||
elevatedLevel && elevatedLevel !== "off"
|
||||
? elevatedLevel === "on"
|
||||
? "elevated"
|
||||
: `elevated:${elevatedLevel}`
|
||||
: null;
|
||||
const elevatedLabel = elevatedLevel === "on" ? "elevated" : null;
|
||||
const optionParts = [
|
||||
`Runtime: ${runtime.label}`,
|
||||
`Think: ${thinkLevel}`,
|
||||
@@ -400,7 +395,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
|
||||
"/think <level>",
|
||||
"/verbose on|full|off",
|
||||
"/reasoning on|off",
|
||||
"/elevated on|off|ask|full",
|
||||
"/elevated on|off",
|
||||
"/model <id>",
|
||||
"/usage off|tokens|full",
|
||||
];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
export type VerboseLevel = "off" | "on" | "full";
|
||||
export type ElevatedLevel = "off" | "on" | "ask" | "full";
|
||||
export type ElevatedMode = "off" | "ask" | "full";
|
||||
export type ElevatedLevel = "off" | "on";
|
||||
export type ReasoningLevel = "off" | "on" | "stream";
|
||||
export type UsageDisplayLevel = "off" | "tokens" | "full";
|
||||
|
||||
@@ -113,18 +112,10 @@ export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | und
|
||||
if (!raw) return undefined;
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0"].includes(key)) return "off";
|
||||
if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) return "full";
|
||||
if (["ask", "prompt", "approval", "approve"].includes(key)) return "ask";
|
||||
if (["on", "true", "yes", "1"].includes(key)) return "on";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveElevatedMode(level?: ElevatedLevel | null): ElevatedMode {
|
||||
if (!level || level === "off") return "off";
|
||||
if (level === "full") return "full";
|
||||
return "ask";
|
||||
}
|
||||
|
||||
// Normalize reasoning visibility flags used to toggle reasoning exposure.
|
||||
export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
|
||||
@@ -180,11 +180,3 @@ export function decorateClawdProfile(
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureProfileCleanExit(userDataDir: string) {
|
||||
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
|
||||
const prefs = safeReadJson(preferencesPath) ?? {};
|
||||
setDeep(prefs, ["exit_type"], "Normal");
|
||||
setDeep(prefs, ["exited_cleanly"], true);
|
||||
safeWriteJson(preferencesPath, prefs);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
decorateClawdProfile,
|
||||
ensureProfileCleanExit,
|
||||
findChromeExecutableMac,
|
||||
findChromeExecutableWindows,
|
||||
isChromeReachable,
|
||||
@@ -104,18 +103,6 @@ describe("browser chrome profile decoration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("writes clean exit prefs to avoid restore prompts", async () => {
|
||||
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
|
||||
try {
|
||||
ensureProfileCleanExit(userDataDir);
|
||||
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
|
||||
expect(prefs.exit_type).toBe("Normal");
|
||||
expect(prefs.exited_cleanly).toBe(true);
|
||||
} finally {
|
||||
await fsp.rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent when rerun on an existing profile", async () => {
|
||||
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
|
||||
try {
|
||||
|
||||
+2
-18
@@ -13,11 +13,7 @@ import {
|
||||
type BrowserExecutable,
|
||||
resolveBrowserExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
import {
|
||||
decorateClawdProfile,
|
||||
ensureProfileCleanExit,
|
||||
isProfileDecorated,
|
||||
} from "./chrome.profile-decoration.js";
|
||||
import { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
|
||||
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
|
||||
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
|
||||
|
||||
@@ -30,11 +26,7 @@ export {
|
||||
findChromeExecutableWindows,
|
||||
resolveBrowserExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
export {
|
||||
decorateClawdProfile,
|
||||
ensureProfileCleanExit,
|
||||
isProfileDecorated,
|
||||
} from "./chrome.profile-decoration.js";
|
||||
export { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
|
||||
|
||||
function exists(filePath: string) {
|
||||
try {
|
||||
@@ -186,8 +178,6 @@ export async function launchClawdChrome(
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-features=Translate,MediaRouter",
|
||||
"--disable-session-crashed-bubble",
|
||||
"--hide-crash-restore-bubble",
|
||||
"--password-store=basic",
|
||||
];
|
||||
|
||||
@@ -256,12 +246,6 @@ export async function launchClawdChrome(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ensureProfileCleanExit(userDataDir);
|
||||
} catch (err) {
|
||||
log.warn(`clawd browser clean-exit prefs failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
const proc = spawnOnce();
|
||||
// Wait for CDP to come up.
|
||||
const readyDeadline = Date.now() + 15_000;
|
||||
|
||||
@@ -1 +1 @@
|
||||
a99455ba0c4d0aad0a110bf25440c208b798198d5524b269f0f2d3f984262ae4
|
||||
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce
|
||||
@@ -171,7 +171,7 @@ describe("canvas host", () => {
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("ws open timeout")), 5000);
|
||||
const timer = setTimeout(() => reject(new Error("ws open timeout")), 2000);
|
||||
ws.on("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
@@ -183,14 +183,13 @@ describe("canvas host", () => {
|
||||
});
|
||||
|
||||
const msg = new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("reload timeout")), 10_000);
|
||||
const timer = setTimeout(() => reject(new Error("reload timeout")), 4000);
|
||||
ws.on("message", (data) => {
|
||||
clearTimeout(timer);
|
||||
resolve(rawDataToString(data));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await fs.writeFile(index, "<html><body>v2</body></html>", "utf8");
|
||||
expect(await msg).toBe("reload");
|
||||
ws.close();
|
||||
@@ -198,7 +197,7 @@ describe("canvas host", () => {
|
||||
await server.close();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
}, 10_000);
|
||||
|
||||
it("serves the gateway-hosted A2UI scaffold", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-"));
|
||||
|
||||
+3
-21
@@ -1,12 +1,10 @@
|
||||
import { resolveCommitHash } from "../infra/git-commit.js";
|
||||
import { visibleWidth } from "../terminal/ansi.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { pickTagline, type TaglineOptions } from "./tagline.js";
|
||||
|
||||
type BannerOptions = TaglineOptions & {
|
||||
argv?: string[];
|
||||
commit?: string | null;
|
||||
columns?: number;
|
||||
richTty?: boolean;
|
||||
};
|
||||
|
||||
@@ -38,28 +36,12 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
|
||||
const tagline = pickTagline(options);
|
||||
const rich = options.richTty ?? isRich();
|
||||
const title = "🦞 Clawdbot";
|
||||
const prefix = "🦞 ";
|
||||
const columns = options.columns ?? process.stdout.columns ?? 120;
|
||||
const plainFullLine = `${title} ${version} (${commitLabel}) — ${tagline}`;
|
||||
const fitsOnOneLine = visibleWidth(plainFullLine) <= columns;
|
||||
if (rich) {
|
||||
if (fitsOnOneLine) {
|
||||
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
|
||||
`(${commitLabel})`,
|
||||
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
|
||||
}
|
||||
const line1 = `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
|
||||
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
|
||||
`(${commitLabel})`,
|
||||
)}`;
|
||||
const line2 = `${" ".repeat(prefix.length)}${theme.muted("—")} ${theme.accentDim(tagline)}`;
|
||||
return `${line1}\n${line2}`;
|
||||
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
|
||||
}
|
||||
if (fitsOnOneLine) {
|
||||
return plainFullLine;
|
||||
}
|
||||
const line1 = `${title} ${version} (${commitLabel})`;
|
||||
const line2 = `${" ".repeat(prefix.length)}— ${tagline}`;
|
||||
return `${line1}\n${line2}`;
|
||||
return `${title} ${version} (${commitLabel}) — ${tagline}`;
|
||||
}
|
||||
|
||||
const LOBSTER_ASCII = [
|
||||
|
||||
@@ -2,12 +2,10 @@ import type { Command } from "commander";
|
||||
import { gatewayStatusCommand } from "../../commands/gateway-status.js";
|
||||
import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js";
|
||||
import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js";
|
||||
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
|
||||
import { withProgress } from "../progress.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import {
|
||||
@@ -60,41 +58,6 @@ function runGatewayCommand(action: () => Promise<void>, label?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseDaysOption(raw: unknown, fallback = 30): number {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(1, Math.floor(raw));
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed));
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function renderCostUsageSummary(summary: CostUsageSummary, days: number, rich: boolean): string[] {
|
||||
const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00";
|
||||
const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0";
|
||||
const lines = [
|
||||
colorize(rich, theme.heading, `Usage cost (${days} days)`),
|
||||
`${colorize(rich, theme.muted, "Total:")} ${totalCost} · ${totalTokens} tokens`,
|
||||
];
|
||||
|
||||
if (summary.totals.missingCostEntries > 0) {
|
||||
lines.push(
|
||||
`${colorize(rich, theme.muted, "Missing entries:")} ${summary.totals.missingCostEntries}`,
|
||||
);
|
||||
}
|
||||
|
||||
const latest = summary.daily.at(-1);
|
||||
if (latest) {
|
||||
const latestCost = formatUsd(latest.totalCost) ?? "$0.00";
|
||||
const latestTokens = formatTokenCount(latest.totalTokens) ?? "0";
|
||||
lines.push(
|
||||
`${colorize(rich, theme.muted, "Latest day:")} ${latest.date} · ${latestCost} · ${latestTokens} tokens`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function registerGatewayCli(program: Command) {
|
||||
const gateway = addGatewayRunCommand(
|
||||
program
|
||||
@@ -197,28 +160,6 @@ export function registerGatewayCli(program: Command) {
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("usage-cost")
|
||||
.description("Fetch usage cost summary from session logs")
|
||||
.option("--days <days>", "Number of days to include", "30")
|
||||
.action(async (opts) => {
|
||||
await runGatewayCommand(async () => {
|
||||
const days = parseDaysOption(opts.days);
|
||||
const result = await callGatewayCli("usage.cost", opts, { days });
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const rich = isRich();
|
||||
const summary = result as CostUsageSummary;
|
||||
for (const line of renderCostUsageSummary(summary, days, rich)) {
|
||||
defaultRuntime.log(line);
|
||||
}
|
||||
}, "Gateway usage cost failed");
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("health")
|
||||
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user