Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf9dec89c | ||
|
|
438a41f91f | ||
|
|
a0cd295c0f | ||
|
|
14d3d72bcc | ||
|
|
03916ed10e | ||
|
|
5bd55037e4 | ||
|
|
ec51bb700c | ||
|
|
051d518078 | ||
|
|
294886b54f | ||
|
|
6629e31789 | ||
|
|
9d7087168f | ||
|
|
daceeaa24c | ||
|
|
778800be70 | ||
|
|
1b973caf7a | ||
|
|
ea775025c0 | ||
|
|
0b2830470c | ||
|
|
e81ca7ab00 | ||
|
|
27acfa59c5 | ||
|
|
b333c4a994 | ||
|
|
23f7dd8b25 | ||
|
|
77d9451712 | ||
|
|
a4fc2b4536 | ||
|
|
20a7dd8a80 | ||
|
|
450d2d25e2 | ||
|
|
df024afc97 | ||
|
|
12168dc64f | ||
|
|
4232081fcb | ||
|
|
17f3635109 | ||
|
|
c74551c2ae | ||
|
|
7e59c15496 | ||
|
|
079af0d0b0 |
No files matched your search
@@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- Update: 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`.
|
||||
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
|
||||
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||
@@ -20,20 +21,27 @@ Docs: https://docs.clawd.bot
|
||||
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
||||
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
|
||||
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
|
||||
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
|
||||
### Fixes
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
||||
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
|
||||
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
|
||||
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
||||
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
||||
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
|
||||
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
|
||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
|
||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||
|
||||
@@ -479,27 +479,27 @@ Core contributors:
|
||||
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/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/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/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/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/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/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/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/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/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/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/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/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
|
||||
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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>
|
||||
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
@Bindable var store: ChannelsStore
|
||||
@State private var hasLoaded = false
|
||||
@State private var activeSectionKey: String?
|
||||
@State private var activeSubsection: SubsectionSelection?
|
||||
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
self.content
|
||||
HStack(spacing: 16) {
|
||||
self.sidebar
|
||||
self.detail
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
|
||||
await self.store.loadConfigSchema()
|
||||
await self.store.loadConfig()
|
||||
}
|
||||
.onAppear { self.ensureSelection() }
|
||||
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||
if !loading { self.ensureSelection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.actionRow
|
||||
Group {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = self.store.configSchema {
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
||||
.disabled(self.isNixMode)
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
private enum SubsectionSelection: Hashable {
|
||||
case all
|
||||
case key(String)
|
||||
}
|
||||
|
||||
private struct ConfigSection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
|
||||
var id: String { self.key }
|
||||
}
|
||||
|
||||
private struct ConfigSubsection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
|
||||
var id: String { self.key }
|
||||
}
|
||||
|
||||
private var sections: [ConfigSection] {
|
||||
guard let schema = self.store.configSchema else { return [] }
|
||||
return self.resolveSections(schema)
|
||||
}
|
||||
|
||||
private var activeSection: ConfigSection? {
|
||||
self.sections.first { $0.key == self.activeSectionKey }
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if self.sections.isEmpty {
|
||||
Text("No config sections available.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(self.sections) { section in
|
||||
self.sidebarRow(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
private var detail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let section = self.activeSection {
|
||||
self.sectionDetail(section)
|
||||
} else if self.store.configSchema != nil {
|
||||
self.emptyDetail
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var emptyDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.header
|
||||
Text("Select a config section to view settings.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
|
||||
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.actionRow
|
||||
self.sectionHeader(section)
|
||||
self.subsectionNav(section)
|
||||
self.sectionForm(section)
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -71,6 +158,18 @@ extension ConfigSettings {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ section: ConfigSection) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(section.label)
|
||||
.font(.title3.weight(.semibold))
|
||||
if let help = section.help {
|
||||
Text(help)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var actionRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button("Reload") {
|
||||
@@ -85,6 +184,203 @@ extension ConfigSettings {
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private func sidebarRow(_ section: ConfigSection) -> some View {
|
||||
let isSelected = self.activeSectionKey == section.key
|
||||
return Button {
|
||||
self.selectSection(section)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section.label)
|
||||
if let help = section.help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.background(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func subsectionNav(_ section: ConfigSection) -> some View {
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
if subsections.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
self.subsectionButton(
|
||||
title: "All",
|
||||
isSelected: self.activeSubsection == .all)
|
||||
{
|
||||
self.activeSubsection = .all
|
||||
}
|
||||
ForEach(subsections) { subsection in
|
||||
self.subsectionButton(
|
||||
title: subsection.label,
|
||||
isSelected: self.activeSubsection == .key(subsection.key))
|
||||
{
|
||||
self.activeSubsection = .key(subsection.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func subsectionButton(
|
||||
title: String,
|
||||
isSelected: Bool,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(isSelected ? Color.accentColor : .primary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||
let subsection = self.activeSubsection
|
||||
let defaultPath: ConfigPath = [.key(section.key)]
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
let resolved: (ConfigSchemaNode, ConfigPath) = {
|
||||
if case let .key(key) = subsection,
|
||||
let match = subsections.first(where: { $0.key == key }) {
|
||||
return (match.node, match.path)
|
||||
}
|
||||
return (self.resolvedSchemaNode(section.node), defaultPath)
|
||||
}()
|
||||
|
||||
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
|
||||
.disabled(self.isNixMode)
|
||||
}
|
||||
|
||||
private func ensureSelection() {
|
||||
guard let schema = self.store.configSchema else { return }
|
||||
let sections = self.resolveSections(schema)
|
||||
guard !sections.isEmpty else { return }
|
||||
|
||||
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
|
||||
if self.activeSectionKey != active.key {
|
||||
self.activeSectionKey = active.key
|
||||
}
|
||||
self.ensureSubsection(for: active)
|
||||
}
|
||||
|
||||
private func ensureSubsection(for section: ConfigSection) {
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
guard !subsections.isEmpty else {
|
||||
self.activeSubsection = nil
|
||||
return
|
||||
}
|
||||
|
||||
switch self.activeSubsection {
|
||||
case .all:
|
||||
return
|
||||
case let .key(key):
|
||||
if subsections.contains(where: { $0.key == key }) { return }
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectSection(_ section: ConfigSection) {
|
||||
guard self.activeSectionKey != section.key else { return }
|
||||
self.activeSectionKey = section.key
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
} else {
|
||||
self.activeSubsection = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
|
||||
let node = self.resolvedSchemaNode(root)
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSection(key: key, label: label, help: help, node: child)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
|
||||
let node = self.resolvedSchemaNode(section.node)
|
||||
guard node.schemaType == "object" else { return [] }
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(section.key), .key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSubsection(
|
||||
key: key,
|
||||
label: label,
|
||||
help: help,
|
||||
node: child,
|
||||
path: path)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
|
||||
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
|
||||
if !variants.isEmpty {
|
||||
let nonNull = variants.filter { !$0.isNullSchema }
|
||||
if nonNull.count == 1, let only = nonNull.first { return only }
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
private func humanize(_ key: String) -> String {
|
||||
key.replacingOccurrences(of: "_", with: " ")
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -554,13 +554,16 @@ actor GatewayEndpointStore {
|
||||
{
|
||||
switch bindMode {
|
||||
case "tailnet":
|
||||
tailscaleIP ?? "127.0.0.1"
|
||||
return tailscaleIP ?? "127.0.0.1"
|
||||
case "auto":
|
||||
"127.0.0.1"
|
||||
if let tailscaleIP, !tailscaleIP.isEmpty {
|
||||
return tailscaleIP
|
||||
}
|
||||
return "127.0.0.1"
|
||||
case "custom":
|
||||
customBindHost ?? "127.0.0.1"
|
||||
return customBindHost ?? "127.0.0.1"
|
||||
default:
|
||||
"127.0.0.1"
|
||||
return "127.0.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
/// Manages Tailscale integration and status checking.
|
||||
@Observable
|
||||
@@ -101,15 +104,12 @@ final class TailscaleService {
|
||||
|
||||
func checkTailscaleStatus() async {
|
||||
self.isInstalled = self.checkAppInstallation()
|
||||
guard self.isInstalled else {
|
||||
if !self.isInstalled {
|
||||
self.isRunning = false
|
||||
self.tailscaleHostname = nil
|
||||
self.tailscaleIP = nil
|
||||
self.statusError = "Tailscale is not installed"
|
||||
return
|
||||
}
|
||||
|
||||
if let apiResponse = await fetchTailscaleStatus() {
|
||||
} else if let apiResponse = await fetchTailscaleStatus() {
|
||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
||||
|
||||
if self.isRunning {
|
||||
@@ -138,6 +138,15 @@ final class TailscaleService {
|
||||
self.statusError = "Please start the Tailscale app"
|
||||
self.logger.info("Tailscale API not responding; app likely not running")
|
||||
}
|
||||
|
||||
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
|
||||
self.tailscaleIP = fallback
|
||||
if !self.isRunning {
|
||||
self.isRunning = true
|
||||
}
|
||||
self.statusError = nil
|
||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func openTailscaleApp() {
|
||||
@@ -163,4 +172,46 @@ final class TailscaleService {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
private static func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if Self.isTailnetIPv4(ip) { return ip }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
struct ConnectOptions {
|
||||
var url: String?
|
||||
@@ -268,7 +271,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
||||
}
|
||||
|
||||
let port = config.port ?? 18789
|
||||
let host = "127.0.0.1"
|
||||
let host = resolveLocalHost(bind: config.bind)
|
||||
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
||||
}
|
||||
@@ -304,3 +307,56 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
|
||||
}
|
||||
return config.password
|
||||
}
|
||||
|
||||
private func resolveLocalHost(bind: String?) -> String {
|
||||
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let tailnetIP = detectTailnetIPv4()
|
||||
switch normalized {
|
||||
case "tailnet", "auto":
|
||||
return tailnetIP ?? "127.0.0.1"
|
||||
default:
|
||||
return "127.0.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
private func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if isTailnetIPv4(ip) { return ip }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
+2
-1
@@ -23,10 +23,11 @@ clawdbot nodes approve <requestId>
|
||||
clawdbot nodes status
|
||||
```
|
||||
|
||||
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
|
||||
|
||||
## Invoke / run
|
||||
|
||||
```bash
|
||||
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
||||
clawdbot nodes run --node <id|name|ip> <command...>
|
||||
```
|
||||
|
||||
@@ -21,4 +21,4 @@ clawdbot security audit --fix
|
||||
```
|
||||
|
||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
||||
It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
|
||||
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||
@@ -1774,6 +1774,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
||||
- `tools.web.fetch.maxChars` (default 50000)
|
||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.maxRedirects` (default 3)
|
||||
- `tools.web.fetch.userAgent` (optional override)
|
||||
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
||||
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
||||
@@ -2614,10 +2615,13 @@ Defaults:
|
||||
// noSandbox: false,
|
||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
|
||||
|
||||
### `ui` (Appearance)
|
||||
|
||||
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
||||
|
||||
@@ -500,6 +500,7 @@ Notes:
|
||||
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
||||
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
||||
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
||||
- Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-clawd-managed-browser)).
|
||||
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
||||
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
||||
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
||||
|
||||
@@ -215,6 +215,7 @@ Fetch a URL and extract readable content.
|
||||
maxChars: 50000,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
maxRedirects: 3,
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
readability: true,
|
||||
firecrawl: {
|
||||
@@ -241,6 +242,7 @@ Notes:
|
||||
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
||||
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
||||
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
||||
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
|
||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||
|
||||
@@ -34,6 +34,10 @@ export type SkillInstallResult = {
|
||||
code: number | null;
|
||||
};
|
||||
|
||||
function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
|
||||
return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function");
|
||||
}
|
||||
|
||||
function summarizeInstallOutput(text: string): string | undefined {
|
||||
const raw = text.trim();
|
||||
if (!raw) return undefined;
|
||||
@@ -154,13 +158,10 @@ async function downloadFile(
|
||||
}
|
||||
await ensureDir(path.dirname(destPath));
|
||||
const file = fs.createWriteStream(destPath);
|
||||
const body = response.body;
|
||||
// Node's Readable.fromWeb expects the node:stream/web type; cast through unknown for DOM streams.
|
||||
const nodeStream = body as unknown as NodeJS.ReadableStream;
|
||||
const readable =
|
||||
typeof nodeStream.pipe === "function"
|
||||
? nodeStream
|
||||
: Readable.fromWeb(body as unknown as NodeReadableStream);
|
||||
const body = response.body as unknown;
|
||||
const readable = isNodeReadableStream(body)
|
||||
? body
|
||||
: Readable.fromWeb(body as NodeReadableStream);
|
||||
await pipeline(readable, file);
|
||||
const stat = await fs.promises.stat(destPath);
|
||||
return { bytes: stat.size };
|
||||
|
||||
@@ -49,9 +49,10 @@ const browserConfigMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
const configMocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||
}));
|
||||
vi.mock("../../config/config.js", () => configMocks);
|
||||
|
||||
const toolCommonMocks = vi.hoisted(() => ({
|
||||
imageResultFromFile: vi.fn(),
|
||||
@@ -70,11 +71,12 @@ import { createBrowserTool } from "./browser-tool.js";
|
||||
describe("browser tool snapshot maxChars", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||
});
|
||||
|
||||
it("applies the default ai snapshot limit", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.(null, { action: "snapshot", format: "ai" });
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
@@ -90,7 +92,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
const override = 2_000;
|
||||
await tool.execute?.(null, {
|
||||
action: "snapshot",
|
||||
format: "ai",
|
||||
snapshotFormat: "ai",
|
||||
maxChars: override,
|
||||
});
|
||||
|
||||
@@ -106,7 +108,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.(null, {
|
||||
action: "snapshot",
|
||||
format: "ai",
|
||||
snapshotFormat: "ai",
|
||||
maxChars: 0,
|
||||
});
|
||||
|
||||
@@ -124,7 +126,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
|
||||
it("passes refs mode through to browser snapshot", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.(null, { action: "snapshot", format: "ai", refs: "aria" });
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
@@ -135,9 +137,36 @@ describe("browser tool snapshot maxChars", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses config snapshot defaults when mode is not provided", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||
});
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
expect.objectContaining({
|
||||
mode: "efficient",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||
});
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "aria" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalled();
|
||||
const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
|
||||
expect(opts?.mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
|
||||
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
||||
await tool.execute?.(null, { action: "snapshot", profile: "chrome", format: "ai" });
|
||||
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
@@ -151,6 +180,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
describe("browser tool snapshot labels", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||
});
|
||||
|
||||
it("returns image + text when labels are requested", async () => {
|
||||
@@ -175,7 +205,7 @@ describe("browser tool snapshot labels", () => {
|
||||
|
||||
const result = await tool.execute?.(null, {
|
||||
action: "snapshot",
|
||||
format: "ai",
|
||||
snapshotFormat: "ai",
|
||||
labels: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -190,11 +190,17 @@ export function createBrowserTool(opts?: {
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "snapshot": {
|
||||
const snapshotDefaults = loadConfig().browser?.snapshotDefaults;
|
||||
const format =
|
||||
params.snapshotFormat === "ai" || params.snapshotFormat === "aria"
|
||||
? (params.snapshotFormat as "ai" | "aria")
|
||||
: "ai";
|
||||
const mode = params.mode === "efficient" ? "efficient" : undefined;
|
||||
const mode =
|
||||
params.mode === "efficient"
|
||||
? "efficient"
|
||||
: format === "ai" && snapshotDefaults?.mode === "efficient"
|
||||
? "efficient"
|
||||
: undefined;
|
||||
const labels = typeof params.labels === "boolean" ? params.labels : undefined;
|
||||
const refs = params.refs === "aria" || params.refs === "role" ? params.refs : undefined;
|
||||
const hasMaxChars = Object.hasOwn(params, "maxChars");
|
||||
|
||||
@@ -3,6 +3,8 @@ import crypto from "node:crypto";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/io.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||
import {
|
||||
formatDoctorNonInteractiveHint,
|
||||
@@ -77,11 +79,42 @@ export function createGatewayTool(opts?: {
|
||||
: undefined;
|
||||
const note =
|
||||
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
|
||||
// Extract channel + threadId for routing after restart
|
||||
let deliveryContext: { channel?: string; to?: string; accountId?: string } | undefined;
|
||||
let threadId: string | undefined;
|
||||
if (sessionKey) {
|
||||
const threadMarker = ":thread:";
|
||||
const threadIndex = sessionKey.lastIndexOf(threadMarker);
|
||||
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
|
||||
const threadIdRaw =
|
||||
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
|
||||
threadId = threadIdRaw?.trim() || undefined;
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
let entry = store[sessionKey];
|
||||
if (!entry?.deliveryContext && threadIndex !== -1 && baseSessionKey) {
|
||||
entry = store[baseSessionKey];
|
||||
}
|
||||
if (entry?.deliveryContext) {
|
||||
deliveryContext = {
|
||||
channel: entry.deliveryContext.channel,
|
||||
to: entry.deliveryContext.to,
|
||||
accountId: entry.deliveryContext.accountId,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore: best-effort
|
||||
}
|
||||
}
|
||||
const payload: RestartSentinelPayload = {
|
||||
kind: "restart",
|
||||
status: "ok",
|
||||
ts: Date.now(),
|
||||
sessionKey,
|
||||
deliveryContext,
|
||||
threadId,
|
||||
message: note ?? reason ?? null,
|
||||
doctorHint: formatDoctorNonInteractiveHint(),
|
||||
stats: {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const lookupMock = vi.fn();
|
||||
|
||||
vi.mock("node:dns/promises", () => ({
|
||||
lookup: lookupMock,
|
||||
}));
|
||||
|
||||
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
|
||||
return {
|
||||
get: (key) => map[key.toLowerCase()] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function redirectResponse(location: string): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status: 302,
|
||||
headers: makeHeaders({ location }),
|
||||
body: { cancel: vi.fn() },
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function textResponse(body: string): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: makeHeaders({ "content-type": "text/plain" }),
|
||||
text: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("web_fetch SSRF protection", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-expect-error restore
|
||||
global.fetch = priorFetch;
|
||||
lookupMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("blocks localhost hostnames before fetch/firecrawl", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const { createWebFetchTool } = await import("./web-tools.js");
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
cacheTtlMinutes: 0,
|
||||
firecrawl: { apiKey: "firecrawl-test" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tool?.execute?.("call", { url: "http://localhost/test" })).rejects.toThrow(
|
||||
/Blocked hostname/i,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(lookupMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private IP literals without DNS", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const { createWebFetchTool } = await import("./web-tools.js");
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tool?.execute?.("call", { url: "http://127.0.0.1/test" })).rejects.toThrow(
|
||||
/private|internal|blocked/i,
|
||||
);
|
||||
await expect(tool?.execute?.("call", { url: "http://[::ffff:127.0.0.1]/" })).rejects.toThrow(
|
||||
/private|internal|blocked/i,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(lookupMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks when DNS resolves to private addresses", async () => {
|
||||
lookupMock.mockImplementation(async (hostname: string) => {
|
||||
if (hostname === "public.test") {
|
||||
return [{ address: "93.184.216.34", family: 4 }];
|
||||
}
|
||||
return [{ address: "10.0.0.5", family: 4 }];
|
||||
});
|
||||
|
||||
const fetchSpy = vi.fn();
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const { createWebFetchTool } = await import("./web-tools.js");
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tool?.execute?.("call", { url: "https://private.test/resource" })).rejects.toThrow(
|
||||
/private|internal|blocked/i,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks redirects to private hosts", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1/secret"));
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const { createWebFetchTool } = await import("./web-tools.js");
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tool?.execute?.("call", { url: "https://example.com" })).rejects.toThrow(
|
||||
/private|internal|blocked/i,
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows public hosts", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValue(textResponse("ok"));
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const { createWebFetchTool } = await import("./web-tools.js");
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool?.execute?.("call", { url: "https://example.com" });
|
||||
expect(result?.details).toMatchObject({
|
||||
status: 200,
|
||||
extractor: "raw",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { assertPublicHostname, SsrFBlockedError } from "../../infra/net/ssrf.js";
|
||||
import { stringEnum } from "../schema/typebox.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||
@@ -29,6 +30,7 @@ export { extractReadableContent } from "./web-fetch-utils.js";
|
||||
const EXTRACT_MODES = ["markdown", "text"] as const;
|
||||
|
||||
const DEFAULT_FETCH_MAX_CHARS = 50_000;
|
||||
const DEFAULT_FETCH_MAX_REDIRECTS = 3;
|
||||
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
||||
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
||||
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
||||
@@ -144,6 +146,11 @@ function resolveMaxChars(value: unknown, fallback: number): number {
|
||||
return Math.max(100, Math.floor(parsed));
|
||||
}
|
||||
|
||||
function resolveMaxRedirects(value: unknown, fallback: number): number {
|
||||
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
return Math.max(0, Math.floor(parsed));
|
||||
}
|
||||
|
||||
function looksLikeHtml(value: string): boolean {
|
||||
const trimmed = value.trimStart();
|
||||
if (!trimmed) return false;
|
||||
@@ -151,6 +158,68 @@ function looksLikeHtml(value: string): boolean {
|
||||
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
||||
}
|
||||
|
||||
function isRedirectStatus(status: number): boolean {
|
||||
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
||||
}
|
||||
|
||||
async function fetchWithRedirects(params: {
|
||||
url: string;
|
||||
maxRedirects: number;
|
||||
timeoutSeconds: number;
|
||||
userAgent: string;
|
||||
}): Promise<{ response: Response; finalUrl: string }> {
|
||||
const signal = withTimeout(undefined, params.timeoutSeconds * 1000);
|
||||
const visited = new Set<string>();
|
||||
let currentUrl = params.url;
|
||||
let redirectCount = 0;
|
||||
|
||||
while (true) {
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(currentUrl);
|
||||
} catch {
|
||||
throw new Error("Invalid URL: must be http or https");
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
throw new Error("Invalid URL: must be http or https");
|
||||
}
|
||||
|
||||
await assertPublicHostname(parsedUrl.hostname);
|
||||
|
||||
const res = await fetch(parsedUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"User-Agent": params.userAgent,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (isRedirectStatus(res.status)) {
|
||||
const location = res.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error(`Redirect missing location header (${res.status})`);
|
||||
}
|
||||
redirectCount += 1;
|
||||
if (redirectCount > params.maxRedirects) {
|
||||
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
|
||||
}
|
||||
const nextUrl = new URL(location, parsedUrl).toString();
|
||||
if (visited.has(nextUrl)) {
|
||||
throw new Error("Redirect loop detected");
|
||||
}
|
||||
visited.add(nextUrl);
|
||||
void res.body?.cancel();
|
||||
currentUrl = nextUrl;
|
||||
continue;
|
||||
}
|
||||
|
||||
return { response: res, finalUrl: currentUrl };
|
||||
}
|
||||
}
|
||||
|
||||
function formatWebFetchErrorDetail(params: {
|
||||
detail: string;
|
||||
contentType?: string | null;
|
||||
@@ -247,6 +316,7 @@ async function runWebFetch(params: {
|
||||
url: string;
|
||||
extractMode: ExtractMode;
|
||||
maxChars: number;
|
||||
maxRedirects: number;
|
||||
timeoutSeconds: number;
|
||||
cacheTtlMs: number;
|
||||
userAgent: string;
|
||||
@@ -278,20 +348,23 @@ async function runWebFetch(params: {
|
||||
|
||||
const start = Date.now();
|
||||
let res: Response;
|
||||
let finalUrl = params.url;
|
||||
try {
|
||||
res = await fetch(parsedUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"User-Agent": params.userAgent,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
||||
const result = await fetchWithRedirects({
|
||||
url: params.url,
|
||||
maxRedirects: params.maxRedirects,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
userAgent: params.userAgent,
|
||||
});
|
||||
res = result.response;
|
||||
finalUrl = result.finalUrl;
|
||||
} catch (error) {
|
||||
if (error instanceof SsrFBlockedError) {
|
||||
throw error;
|
||||
}
|
||||
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url: params.url,
|
||||
url: finalUrl,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
@@ -304,7 +377,7 @@ async function runWebFetch(params: {
|
||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||
const payload = {
|
||||
url: params.url,
|
||||
finalUrl: firecrawl.finalUrl || params.url,
|
||||
finalUrl: firecrawl.finalUrl || finalUrl,
|
||||
status: firecrawl.status ?? 200,
|
||||
contentType: "text/markdown",
|
||||
title: firecrawl.title,
|
||||
@@ -339,7 +412,7 @@ async function runWebFetch(params: {
|
||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||
const payload = {
|
||||
url: params.url,
|
||||
finalUrl: firecrawl.finalUrl || params.url,
|
||||
finalUrl: firecrawl.finalUrl || finalUrl,
|
||||
status: firecrawl.status ?? res.status,
|
||||
contentType: "text/markdown",
|
||||
title: firecrawl.title,
|
||||
@@ -374,7 +447,7 @@ async function runWebFetch(params: {
|
||||
if (params.readabilityEnabled) {
|
||||
const readable = await extractReadableContent({
|
||||
html: body,
|
||||
url: res.url || params.url,
|
||||
url: finalUrl,
|
||||
extractMode: params.extractMode,
|
||||
});
|
||||
if (readable?.text) {
|
||||
@@ -382,7 +455,7 @@ async function runWebFetch(params: {
|
||||
title = readable.title;
|
||||
extractor = "readability";
|
||||
} else {
|
||||
const firecrawl = await tryFirecrawlFallback(params);
|
||||
const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl });
|
||||
if (firecrawl) {
|
||||
text = firecrawl.text;
|
||||
title = firecrawl.title;
|
||||
@@ -411,7 +484,7 @@ async function runWebFetch(params: {
|
||||
const truncated = truncateText(text, params.maxChars);
|
||||
const payload = {
|
||||
url: params.url,
|
||||
finalUrl: res.url || params.url,
|
||||
finalUrl,
|
||||
status: res.status,
|
||||
contentType,
|
||||
title,
|
||||
@@ -508,6 +581,7 @@ export function createWebFetchTool(options?: {
|
||||
url,
|
||||
extractMode,
|
||||
maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS),
|
||||
maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS),
|
||||
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
||||
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||
userAgent,
|
||||
|
||||
@@ -1 +1 @@
|
||||
c1cdb1b463e70d87976d88abf13e373e774c057e6796c947227c56b459af9d77
|
||||
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5
|
||||
+17440
-15247
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
const clientMocks = vi.hoisted(() => ({
|
||||
browserSnapshot: vi.fn(async () => ({
|
||||
ok: true,
|
||||
format: "ai",
|
||||
targetId: "t1",
|
||||
url: "https://example.com",
|
||||
snapshot: "ok",
|
||||
})),
|
||||
resolveBrowserControlUrl: vi.fn(() => "http://127.0.0.1:18791"),
|
||||
}));
|
||||
vi.mock("../browser/client.js", () => clientMocks);
|
||||
|
||||
const configMocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||
}));
|
||||
vi.mock("../config/config.js", () => configMocks);
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: runtime,
|
||||
}));
|
||||
|
||||
describe("browser cli snapshot defaults", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||
});
|
||||
|
||||
it("uses config snapshot defaults when mode is not provided", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||
});
|
||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||
const program = new Command();
|
||||
const browser = program.command("browser").option("--json", false);
|
||||
registerBrowserInspectCommands(browser, () => ({}));
|
||||
|
||||
await program.parseAsync(["browser", "snapshot"], { from: "user" });
|
||||
|
||||
expect(clientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
expect.objectContaining({
|
||||
format: "ai",
|
||||
mode: "efficient",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||
});
|
||||
clientMocks.browserSnapshot.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
format: "aria",
|
||||
targetId: "t1",
|
||||
url: "https://example.com",
|
||||
nodes: [],
|
||||
});
|
||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||
const program = new Command();
|
||||
const browser = program.command("browser").option("--json", false);
|
||||
registerBrowserInspectCommands(browser, () => ({}));
|
||||
|
||||
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
|
||||
|
||||
expect(clientMocks.browserSnapshot).toHaveBeenCalled();
|
||||
const [, opts] = clientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
|
||||
expect(opts?.mode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
||||
|
||||
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import { browserScreenshotAction } from "../browser/client-actions.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
@@ -62,7 +63,11 @@ export function registerBrowserInspectCommands(
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
const format = opts.format === "aria" ? "aria" : "ai";
|
||||
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : undefined;
|
||||
const configMode =
|
||||
format === "ai" && loadConfig().browser?.snapshotDefaults?.mode === "efficient"
|
||||
? "efficient"
|
||||
: undefined;
|
||||
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
|
||||
try {
|
||||
const result = await browserSnapshot(baseUrl, {
|
||||
format,
|
||||
|
||||
@@ -246,4 +246,128 @@ describe("cron cli", () => {
|
||||
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||
expect(patch?.patch?.payload?.deliver).toBe(false);
|
||||
});
|
||||
|
||||
it("does not include undefined delivery fields when updating message", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCli(program);
|
||||
|
||||
// Update message without delivery flags - should NOT include undefined delivery fields
|
||||
await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||
const patch = updateCall?.[2] as {
|
||||
patch?: {
|
||||
payload?: {
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
bestEffortDeliver?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Should include the new message
|
||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||
|
||||
// Should NOT include delivery fields at all (to preserve existing values)
|
||||
expect(patch?.patch?.payload).not.toHaveProperty("deliver");
|
||||
expect(patch?.patch?.payload).not.toHaveProperty("channel");
|
||||
expect(patch?.patch?.payload).not.toHaveProperty("to");
|
||||
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
|
||||
});
|
||||
|
||||
it("includes delivery fields when explicitly provided with message", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCli(program);
|
||||
|
||||
// Update message AND delivery - should include both
|
||||
await program.parseAsync(
|
||||
[
|
||||
"cron",
|
||||
"edit",
|
||||
"job-1",
|
||||
"--message",
|
||||
"Updated message",
|
||||
"--deliver",
|
||||
"--channel",
|
||||
"telegram",
|
||||
"--to",
|
||||
"19098680",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||
const patch = updateCall?.[2] as {
|
||||
patch?: {
|
||||
payload?: {
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Should include everything
|
||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||
expect(patch?.patch?.payload?.deliver).toBe(true);
|
||||
expect(patch?.patch?.payload?.channel).toBe("telegram");
|
||||
expect(patch?.patch?.payload?.to).toBe("19098680");
|
||||
});
|
||||
|
||||
it("includes best-effort delivery when provided with message", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
["cron", "edit", "job-1", "--message", "Updated message", "--best-effort-deliver"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||
const patch = updateCall?.[2] as {
|
||||
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
|
||||
};
|
||||
|
||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(true);
|
||||
});
|
||||
|
||||
it("includes no-best-effort delivery when provided with message", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
["cron", "edit", "job-1", "--message", "Updated message", "--no-best-effort-deliver"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||
const patch = updateCall?.[2] as {
|
||||
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
|
||||
};
|
||||
|
||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,15 @@ import {
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
|
||||
const assignIf = (
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
shouldAssign: boolean,
|
||||
) => {
|
||||
if (shouldAssign) target[key] = value;
|
||||
};
|
||||
|
||||
export function registerCronEditCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
@@ -136,18 +145,19 @@ export function registerCronEditCommand(cron: Command) {
|
||||
};
|
||||
} else if (hasAgentTurnPatch) {
|
||||
const payload: Record<string, unknown> = { kind: "agentTurn" };
|
||||
if (typeof opts.message === "string") payload.message = String(opts.message);
|
||||
if (model) payload.model = model;
|
||||
if (thinking) payload.thinking = thinking;
|
||||
if (hasTimeoutSeconds) {
|
||||
payload.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
if (typeof opts.deliver === "boolean") payload.deliver = opts.deliver;
|
||||
if (typeof opts.channel === "string") payload.channel = opts.channel;
|
||||
if (typeof opts.to === "string") payload.to = opts.to;
|
||||
if (typeof opts.bestEffortDeliver === "boolean") {
|
||||
payload.bestEffortDeliver = opts.bestEffortDeliver;
|
||||
}
|
||||
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
|
||||
assignIf(payload, "model", model, Boolean(model));
|
||||
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
||||
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||
assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean");
|
||||
assignIf(payload, "channel", opts.channel, typeof opts.channel === "string");
|
||||
assignIf(payload, "to", opts.to, typeof opts.to === "string");
|
||||
assignIf(
|
||||
payload,
|
||||
"bestEffortDeliver",
|
||||
opts.bestEffortDeliver,
|
||||
typeof opts.bestEffortDeliver === "boolean",
|
||||
);
|
||||
patch.payload = payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Command } from "commander";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGatewayFromCli = vi.fn();
|
||||
|
||||
vi.mock("./gateway-rpc.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
|
||||
return {
|
||||
...actual,
|
||||
callGatewayFromCli: (...args: unknown[]) => callGatewayFromCli(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("logs cli", () => {
|
||||
afterEach(() => {
|
||||
callGatewayFromCli.mockReset();
|
||||
});
|
||||
|
||||
it("writes output directly to stdout/stderr", async () => {
|
||||
callGatewayFromCli.mockResolvedValueOnce({
|
||||
file: "/tmp/clawdbot.log",
|
||||
cursor: 1,
|
||||
size: 123,
|
||||
lines: ["raw line"],
|
||||
truncated: true,
|
||||
reset: true,
|
||||
});
|
||||
|
||||
const stdoutWrites: string[] = [];
|
||||
const stderrWrites: string[] = [];
|
||||
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||
stdoutWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
const { registerLogsCli } = await import("./logs-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerLogsCli(program);
|
||||
|
||||
await program.parseAsync(["logs"], { from: "user" });
|
||||
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
|
||||
expect(stdoutWrites.join("")).toContain("Log file:");
|
||||
expect(stdoutWrites.join("")).toContain("raw line");
|
||||
expect(stderrWrites.join("")).toContain("Log tail truncated");
|
||||
expect(stderrWrites.join("")).toContain("Log cursor reset");
|
||||
});
|
||||
|
||||
it("warns when the output pipe closes", async () => {
|
||||
callGatewayFromCli.mockResolvedValueOnce({
|
||||
file: "/tmp/clawdbot.log",
|
||||
lines: ["line one"],
|
||||
});
|
||||
|
||||
const stderrWrites: string[] = [];
|
||||
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => {
|
||||
const err = new Error("EPIPE") as NodeJS.ErrnoException;
|
||||
err.code = "EPIPE";
|
||||
throw err;
|
||||
});
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
const { registerLogsCli } = await import("./logs-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerLogsCli(program);
|
||||
|
||||
await program.parseAsync(["logs"], { from: "user" });
|
||||
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
|
||||
expect(stderrWrites.join("")).toContain("output stdout closed");
|
||||
});
|
||||
});
|
||||
+97
-45
@@ -2,8 +2,9 @@ import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { Command } from "commander";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import { parseLogLine } from "../logging/parse-log-line.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { clearActiveProgressLine } from "../terminal/progress-line.js";
|
||||
import { createSafeStreamWriter } from "../terminal/stream-writer.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
||||
@@ -104,10 +105,28 @@ function formatLogLine(
|
||||
return [head, messageValue].filter(Boolean).join(" ").trim();
|
||||
}
|
||||
|
||||
function emitJsonLine(payload: Record<string, unknown>, toStdErr = false) {
|
||||
const text = `${JSON.stringify(payload)}\n`;
|
||||
if (toStdErr) process.stderr.write(text);
|
||||
else process.stdout.write(text);
|
||||
function createLogWriters() {
|
||||
const writer = createSafeStreamWriter({
|
||||
beforeWrite: () => clearActiveProgressLine(),
|
||||
onBrokenPipe: (err, stream) => {
|
||||
const code = err.code ?? "EPIPE";
|
||||
const target = stream === process.stdout ? "stdout" : "stderr";
|
||||
const message = `clawdbot logs: output ${target} closed (${code}). Stopping tail.`;
|
||||
try {
|
||||
clearActiveProgressLine();
|
||||
process.stderr.write(`${message}\n`);
|
||||
} catch {
|
||||
// ignore secondary failures while reporting the broken pipe
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
logLine: (text: string) => writer.writeLine(process.stdout, text),
|
||||
errorLine: (text: string) => writer.writeLine(process.stderr, text),
|
||||
emitJsonLine: (payload: Record<string, unknown>, toStdErr = false) =>
|
||||
writer.write(toStdErr ? process.stderr : process.stdout, `${JSON.stringify(payload)}\n`),
|
||||
};
|
||||
}
|
||||
|
||||
function emitGatewayError(
|
||||
@@ -115,6 +134,8 @@ function emitGatewayError(
|
||||
opts: LogsCliOptions,
|
||||
mode: "json" | "text",
|
||||
rich: boolean,
|
||||
emitJsonLine: (payload: Record<string, unknown>, toStdErr?: boolean) => boolean,
|
||||
errorLine: (text: string) => boolean,
|
||||
) {
|
||||
const details = buildGatewayConnectionDetails({ url: opts.url });
|
||||
const message = "Gateway not reachable. Is it running and accessible?";
|
||||
@@ -122,22 +143,26 @@ function emitGatewayError(
|
||||
const errorText = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (mode === "json") {
|
||||
emitJsonLine(
|
||||
{
|
||||
type: "error",
|
||||
message,
|
||||
error: errorText,
|
||||
details,
|
||||
hint,
|
||||
},
|
||||
true,
|
||||
);
|
||||
if (
|
||||
!emitJsonLine(
|
||||
{
|
||||
type: "error",
|
||||
message,
|
||||
error: errorText,
|
||||
details,
|
||||
hint,
|
||||
},
|
||||
true,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.error(colorize(rich, theme.error, message));
|
||||
defaultRuntime.error(details.message);
|
||||
defaultRuntime.error(colorize(rich, theme.muted, hint));
|
||||
if (!errorLine(colorize(rich, theme.error, message))) return;
|
||||
if (!errorLine(details.message)) return;
|
||||
errorLine(colorize(rich, theme.muted, hint));
|
||||
}
|
||||
|
||||
export function registerLogsCli(program: Command) {
|
||||
@@ -159,6 +184,7 @@ export function registerLogsCli(program: Command) {
|
||||
addGatewayClientOptions(logs);
|
||||
|
||||
logs.action(async (opts: LogsCliOptions) => {
|
||||
const { logLine, errorLine, emitJsonLine } = createLogWriters();
|
||||
const interval = parsePositiveInt(opts.interval, 1000);
|
||||
let cursor: number | undefined;
|
||||
let first = true;
|
||||
@@ -171,58 +197,84 @@ export function registerLogsCli(program: Command) {
|
||||
try {
|
||||
payload = await fetchLogs(opts, cursor);
|
||||
} catch (err) {
|
||||
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich);
|
||||
defaultRuntime.exit(1);
|
||||
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich, emitJsonLine, errorLine);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const lines = Array.isArray(payload.lines) ? payload.lines : [];
|
||||
if (jsonMode) {
|
||||
if (first) {
|
||||
emitJsonLine({
|
||||
type: "meta",
|
||||
file: payload.file,
|
||||
cursor: payload.cursor,
|
||||
size: payload.size,
|
||||
});
|
||||
if (
|
||||
!emitJsonLine({
|
||||
type: "meta",
|
||||
file: payload.file,
|
||||
cursor: payload.cursor,
|
||||
size: payload.size,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
const parsed = parseLogLine(line);
|
||||
if (parsed) {
|
||||
emitJsonLine({ type: "log", ...parsed });
|
||||
if (!emitJsonLine({ type: "log", ...parsed })) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
emitJsonLine({ type: "raw", raw: line });
|
||||
if (!emitJsonLine({ type: "raw", raw: line })) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (payload.truncated) {
|
||||
emitJsonLine({
|
||||
type: "notice",
|
||||
message: "Log tail truncated (increase --max-bytes).",
|
||||
});
|
||||
if (
|
||||
!emitJsonLine({
|
||||
type: "notice",
|
||||
message: "Log tail truncated (increase --max-bytes).",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (payload.reset) {
|
||||
emitJsonLine({
|
||||
type: "notice",
|
||||
message: "Log cursor reset (file rotated).",
|
||||
});
|
||||
if (
|
||||
!emitJsonLine({
|
||||
type: "notice",
|
||||
message: "Log cursor reset (file rotated).",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (first && payload.file) {
|
||||
const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:";
|
||||
defaultRuntime.log(`${prefix} ${payload.file}`);
|
||||
if (!logLine(`${prefix} ${payload.file}`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
defaultRuntime.log(
|
||||
formatLogLine(line, {
|
||||
pretty,
|
||||
rich,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
!logLine(
|
||||
formatLogLine(line, {
|
||||
pretty,
|
||||
rich,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (payload.truncated) {
|
||||
defaultRuntime.error("Log tail truncated (increase --max-bytes).");
|
||||
if (!errorLine("Log tail truncated (increase --max-bytes).")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (payload.reset) {
|
||||
defaultRuntime.error("Log cursor reset (file rotated).");
|
||||
if (!errorLine("Log cursor reset (file rotated).")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor =
|
||||
|
||||
@@ -4,6 +4,8 @@ import { formatAge, formatPermissions, parseNodeList, parsePairingList } from ".
|
||||
import { runNodesCommand } from "./cli-utils.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
import { renderTable } from "../../terminal/table.js";
|
||||
import { isRich, theme } from "../../terminal/theme.js";
|
||||
|
||||
function formatVersionLabel(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
@@ -155,23 +157,65 @@ export function registerNodesStatusCommands(nodes: Command) {
|
||||
}
|
||||
const { pending, paired } = parsePairingList(result);
|
||||
defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
|
||||
const rich = isRich();
|
||||
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
||||
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
||||
const warn = (text: string) => (rich ? theme.warn(text) : text);
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const now = Date.now();
|
||||
|
||||
if (pending.length > 0) {
|
||||
defaultRuntime.log("\nPending:");
|
||||
for (const r of pending) {
|
||||
const name = r.displayName || r.nodeId;
|
||||
const repair = r.isRepair ? " (repair)" : "";
|
||||
const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
|
||||
const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : "";
|
||||
defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`);
|
||||
}
|
||||
const pendingRows = pending.map((r) => ({
|
||||
Request: r.requestId,
|
||||
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
|
||||
IP: r.remoteIp ?? "",
|
||||
Requested:
|
||||
typeof r.ts === "number"
|
||||
? `${formatAge(Math.max(0, now - r.ts))} ago`
|
||||
: muted("unknown"),
|
||||
Repair: r.isRepair ? warn("yes") : "",
|
||||
}));
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(heading("Pending"));
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Request", header: "Request", minWidth: 8 },
|
||||
{ key: "Node", header: "Node", minWidth: 14, flex: true },
|
||||
{ key: "IP", header: "IP", minWidth: 10 },
|
||||
{ key: "Requested", header: "Requested", minWidth: 12 },
|
||||
{ key: "Repair", header: "Repair", minWidth: 6 },
|
||||
],
|
||||
rows: pendingRows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
}
|
||||
|
||||
if (paired.length > 0) {
|
||||
defaultRuntime.log("\nPaired:");
|
||||
for (const n of paired) {
|
||||
const name = n.displayName || n.nodeId;
|
||||
const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
|
||||
defaultRuntime.log(`- ${n.nodeId}: ${name}${ip}`);
|
||||
}
|
||||
const pairedRows = paired.map((n) => ({
|
||||
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
|
||||
Id: n.nodeId,
|
||||
IP: n.remoteIp ?? "",
|
||||
LastConnect:
|
||||
typeof n.lastConnectedAtMs === "number"
|
||||
? `${formatAge(Math.max(0, now - n.lastConnectedAtMs))} ago`
|
||||
: muted("unknown"),
|
||||
}));
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(heading("Paired"));
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Node", header: "Node", minWidth: 14, flex: true },
|
||||
{ key: "Id", header: "ID", minWidth: 10 },
|
||||
{ key: "IP", header: "IP", minWidth: 10 },
|
||||
{ key: "LastConnect", header: "Last Connect", minWidth: 14 },
|
||||
],
|
||||
rows: pairedRows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -83,6 +83,7 @@ export type PairedNode = {
|
||||
permissions?: Record<string, boolean>;
|
||||
createdAtMs?: number;
|
||||
approvedAtMs?: number;
|
||||
lastConnectedAtMs?: number;
|
||||
};
|
||||
|
||||
export type PairingList = {
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import { normalizeEnv } from "../infra/env.js";
|
||||
import { isMainModule } from "../infra/is-main.js";
|
||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||
import { formatUncaughtError } from "../infra/errors.js";
|
||||
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||
import { enableConsoleCapture } from "../logging.js";
|
||||
import { tryRouteCli } from "./route.js";
|
||||
@@ -42,7 +43,7 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
installUnhandledRejectionHandler();
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
|
||||
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||
import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js";
|
||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||
|
||||
/** Image content block for Claude API multimodal messages. */
|
||||
export type ImageContent = {
|
||||
|
||||
+9
-2
@@ -57,6 +57,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
|
||||
];
|
||||
|
||||
const CONFIG_BACKUP_COUNT = 5;
|
||||
const loggedInvalidConfigs = new Set<string>();
|
||||
|
||||
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
|
||||
|
||||
@@ -244,8 +245,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const details = validated.issues
|
||||
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
||||
.join("\n");
|
||||
deps.logger.error(`Invalid config:\\n${details}`);
|
||||
throw new Error("Invalid config");
|
||||
if (!loggedInvalidConfigs.has(configPath)) {
|
||||
loggedInvalidConfigs.add(configPath);
|
||||
deps.logger.error(`Invalid config:\\n${details}`);
|
||||
}
|
||||
const error = new Error("Invalid config");
|
||||
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
|
||||
(error as { code?: string; details?: string }).details = details;
|
||||
throw error;
|
||||
}
|
||||
if (validated.warnings.length > 0) {
|
||||
const details = validated.warnings
|
||||
|
||||
@@ -178,6 +178,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.web.fetch.maxChars": "Web Fetch Max Chars",
|
||||
"tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)",
|
||||
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
|
||||
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||
@@ -249,6 +250,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"commands.useAccessGroups": "Use Access Groups",
|
||||
"ui.seamColor": "Accent Color",
|
||||
"browser.controlUrl": "Browser Control URL",
|
||||
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
||||
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
|
||||
"session.dmScope": "DM Session Scope",
|
||||
@@ -378,6 +381,7 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
|
||||
"tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.",
|
||||
"tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.",
|
||||
"tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).",
|
||||
"tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.",
|
||||
"tools.web.fetch.readability":
|
||||
"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).",
|
||||
|
||||
@@ -8,6 +8,10 @@ export type BrowserProfileConfig = {
|
||||
/** Profile color (hex). Auto-assigned at creation. */
|
||||
color: string;
|
||||
};
|
||||
export type BrowserSnapshotDefaults = {
|
||||
/** Default snapshot mode (applies when mode is not provided). */
|
||||
mode?: "efficient";
|
||||
};
|
||||
export type BrowserConfig = {
|
||||
enabled?: boolean;
|
||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||
@@ -39,4 +43,6 @@ export type BrowserConfig = {
|
||||
defaultProfile?: string;
|
||||
/** Named browser profiles with explicit CDP ports or URLs. */
|
||||
profiles?: Record<string, BrowserProfileConfig>;
|
||||
/** Default snapshot options (applied by the browser tool/CLI when unset). */
|
||||
snapshotDefaults?: BrowserSnapshotDefaults;
|
||||
};
|
||||
@@ -309,6 +309,8 @@ export type ToolsConfig = {
|
||||
timeoutSeconds?: number;
|
||||
/** Cache TTL in minutes for fetched content. */
|
||||
cacheTtlMinutes?: number;
|
||||
/** Maximum number of redirects to follow (default: 3). */
|
||||
maxRedirects?: number;
|
||||
/** Override User-Agent header for fetch requests. */
|
||||
userAgent?: string;
|
||||
/** Use Readability to extract main content (default: true). */
|
||||
|
||||
@@ -147,6 +147,7 @@ export const ToolsWebFetchSchema = z
|
||||
maxChars: z.number().int().positive().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
cacheTtlMinutes: z.number().nonnegative().optional(),
|
||||
maxRedirects: z.number().int().nonnegative().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -6,6 +6,13 @@ import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-
|
||||
import { ChannelsSchema } from "./zod-schema.providers.js";
|
||||
import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.session.js";
|
||||
|
||||
const BrowserSnapshotDefaultsSchema = z
|
||||
.object({
|
||||
mode: z.literal("efficient").optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const ClawdbotSchema = z
|
||||
.object({
|
||||
meta: z
|
||||
@@ -113,6 +120,7 @@ export const ClawdbotSchema = z
|
||||
noSandbox: z.boolean().optional(),
|
||||
attachOnly: z.boolean().optional(),
|
||||
defaultProfile: z.string().optional(),
|
||||
snapshotDefaults: BrowserSnapshotDefaultsSchema,
|
||||
profiles: z
|
||||
.record(
|
||||
z
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ErrorObject } from "ajv";
|
||||
|
||||
import { formatValidationErrors } from "./index.js";
|
||||
|
||||
const makeError = (overrides: Partial<ErrorObject>): ErrorObject => ({
|
||||
keyword: "type",
|
||||
instancePath: "",
|
||||
schemaPath: "#/",
|
||||
params: {},
|
||||
message: "validation error",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("formatValidationErrors", () => {
|
||||
it("returns unknown validation error when missing errors", () => {
|
||||
expect(formatValidationErrors(undefined)).toBe("unknown validation error");
|
||||
expect(formatValidationErrors(null)).toBe("unknown validation error");
|
||||
});
|
||||
|
||||
it("returns unknown validation error when errors list is empty", () => {
|
||||
expect(formatValidationErrors([])).toBe("unknown validation error");
|
||||
});
|
||||
|
||||
it("formats additionalProperties at root", () => {
|
||||
const err = makeError({
|
||||
keyword: "additionalProperties",
|
||||
params: { additionalProperty: "token" },
|
||||
});
|
||||
|
||||
expect(formatValidationErrors([err])).toBe("at root: unexpected property 'token'");
|
||||
});
|
||||
|
||||
it("formats additionalProperties with instancePath", () => {
|
||||
const err = makeError({
|
||||
keyword: "additionalProperties",
|
||||
instancePath: "/auth",
|
||||
params: { additionalProperty: "token" },
|
||||
});
|
||||
|
||||
expect(formatValidationErrors([err])).toBe("at /auth: unexpected property 'token'");
|
||||
});
|
||||
|
||||
it("formats message with path for other errors", () => {
|
||||
const err = makeError({
|
||||
keyword: "required",
|
||||
instancePath: "/auth",
|
||||
message: "must have required property 'token'",
|
||||
});
|
||||
|
||||
expect(formatValidationErrors([err])).toBe("at /auth: must have required property 'token'");
|
||||
});
|
||||
|
||||
it("de-dupes repeated entries", () => {
|
||||
const err = makeError({
|
||||
keyword: "required",
|
||||
instancePath: "/auth",
|
||||
message: "must have required property 'token'",
|
||||
});
|
||||
|
||||
expect(formatValidationErrors([err, err])).toBe(
|
||||
"at /auth: must have required property 'token'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -310,8 +310,37 @@ export const validateWebLoginStartParams =
|
||||
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(WebLoginWaitParamsSchema);
|
||||
|
||||
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
|
||||
if (!errors) return "unknown validation error";
|
||||
return ajv.errorsText(errors, { separator: "; " });
|
||||
if (!errors?.length) return "unknown validation error";
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const err of errors) {
|
||||
const keyword = typeof err?.keyword === "string" ? err.keyword : "";
|
||||
const instancePath = typeof err?.instancePath === "string" ? err.instancePath : "";
|
||||
|
||||
if (keyword === "additionalProperties") {
|
||||
const params = err?.params as { additionalProperty?: unknown } | undefined;
|
||||
const additionalProperty = params?.additionalProperty;
|
||||
if (typeof additionalProperty === "string" && additionalProperty.trim()) {
|
||||
const where = instancePath ? `at ${instancePath}` : "at root";
|
||||
parts.push(`${where}: unexpected property '${additionalProperty}'`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof err?.message === "string" && err.message.trim() ? err.message : "validation error";
|
||||
const where = instancePath ? `at ${instancePath}: ` : "";
|
||||
parts.push(`${where}${message}`);
|
||||
}
|
||||
|
||||
// De-dupe while preserving order.
|
||||
const unique = Array.from(new Set(parts.filter((part) => part.trim())));
|
||||
if (!unique.length) {
|
||||
const fallback = ajv.errorsText(errors, { separator: "; " });
|
||||
return fallback || "unknown validation error";
|
||||
}
|
||||
return unique.join("; ");
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -28,9 +28,30 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadMarker = ":thread:";
|
||||
const threadIndex = sessionKey.lastIndexOf(threadMarker);
|
||||
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
|
||||
const threadIdRaw =
|
||||
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
|
||||
const sessionThreadId = threadIdRaw?.trim() || undefined;
|
||||
|
||||
const { cfg, entry } = loadSessionEntry(sessionKey);
|
||||
const parsedTarget = resolveAnnounceTargetFromKey(sessionKey);
|
||||
const origin = mergeDeliveryContext(deliveryContextFromSession(entry), parsedTarget ?? undefined);
|
||||
const parsedTarget = resolveAnnounceTargetFromKey(baseSessionKey);
|
||||
|
||||
// Prefer delivery context from sentinel (captured at restart) over session store
|
||||
// Handles race condition where store wasn't flushed before restart
|
||||
const sentinelContext = payload.deliveryContext;
|
||||
let sessionDeliveryContext = deliveryContextFromSession(entry);
|
||||
if (!sessionDeliveryContext && threadIndex !== -1 && baseSessionKey) {
|
||||
const { entry: baseEntry } = loadSessionEntry(baseSessionKey);
|
||||
sessionDeliveryContext = deliveryContextFromSession(baseEntry);
|
||||
}
|
||||
|
||||
const origin = mergeDeliveryContext(
|
||||
sentinelContext,
|
||||
mergeDeliveryContext(sessionDeliveryContext, parsedTarget ?? undefined),
|
||||
);
|
||||
|
||||
const channelRaw = origin?.channel;
|
||||
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
|
||||
const to = origin?.to;
|
||||
@@ -51,6 +72,11 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId =
|
||||
payload.threadId ??
|
||||
sessionThreadId ??
|
||||
(origin?.threadId != null ? String(origin.threadId) : undefined);
|
||||
|
||||
try {
|
||||
await agentCommand(
|
||||
{
|
||||
@@ -61,6 +87,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
||||
deliver: true,
|
||||
bestEffortDeliver: true,
|
||||
messageChannel: channel,
|
||||
threadId,
|
||||
},
|
||||
defaultRuntime,
|
||||
params.deps,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
updatePairedDeviceMetadata,
|
||||
verifyDeviceToken,
|
||||
} from "../../../infra/device-pairing.js";
|
||||
import { updatePairedNodeMetadata } from "../../../infra/node-pairing.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
|
||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
import { upsertPresence } from "../../../infra/system-presence.js";
|
||||
@@ -292,7 +293,9 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
|
||||
const device = connectParams.device;
|
||||
let devicePublicKey: string | null = null;
|
||||
if (!device) {
|
||||
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
|
||||
const hasTokenAuth = !!connectParams.auth?.token;
|
||||
if (!device && !hasTokenAuth) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-required", {
|
||||
client: connectParams.client.id,
|
||||
@@ -465,7 +468,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
});
|
||||
let authOk = authResult.ok;
|
||||
let authMethod = authResult.method ?? "none";
|
||||
if (!authOk && connectParams.auth?.token) {
|
||||
if (!authOk && connectParams.auth?.token && device) {
|
||||
const tokenCheck = await verifyDeviceToken({
|
||||
deviceId: device.id,
|
||||
token: connectParams.auth.token,
|
||||
@@ -716,6 +719,13 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
if (role === "node") {
|
||||
const context = buildRequestContext();
|
||||
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
|
||||
void updatePairedNodeMetadata(nodeSession.nodeId, {
|
||||
lastConnectedAtMs: nodeSession.connectedAtMs,
|
||||
}).catch((err) =>
|
||||
logGateway.warn(
|
||||
`failed to record last connect for ${nodeSession.nodeId}: ${formatForLog(err)}`,
|
||||
),
|
||||
);
|
||||
recordRemoteNodeInfo({
|
||||
nodeId: nodeSession.nodeId,
|
||||
displayName: nodeSession.displayName,
|
||||
|
||||
+3
-5
@@ -28,6 +28,7 @@ import {
|
||||
PortInUseError,
|
||||
} from "./infra/ports.js";
|
||||
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
|
||||
import { formatUncaughtError } from "./infra/errors.js";
|
||||
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
|
||||
import { enableConsoleCapture } from "./logging.js";
|
||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||
@@ -82,15 +83,12 @@ if (isMain) {
|
||||
installUnhandledRejectionHandler();
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
|
||||
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
void program.parseAsync(process.argv).catch((err) => {
|
||||
console.error(
|
||||
"[clawdbot] CLI failed:",
|
||||
err instanceof Error ? (err.stack ?? err.message) : err,
|
||||
);
|
||||
console.error("[clawdbot] CLI failed:", formatUncaughtError(err));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -20,3 +20,13 @@ export function formatErrorMessage(err: unknown): string {
|
||||
return Object.prototype.toString.call(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatUncaughtError(err: unknown): string {
|
||||
if (extractErrorCode(err) === "INVALID_CONFIG") {
|
||||
return formatErrorMessage(err);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err.stack ?? err.message ?? err.name;
|
||||
}
|
||||
return formatErrorMessage(err);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
|
||||
export class SsrFBlockedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SsrFBlockedError";
|
||||
}
|
||||
}
|
||||
|
||||
type LookupFn = typeof dnsLookup;
|
||||
|
||||
const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"];
|
||||
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]);
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
||||
return normalized.slice(1, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseIpv4(address: string): number[] | null {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const numbers = parts.map((part) => Number.parseInt(part, 10));
|
||||
if (numbers.some((value) => Number.isNaN(value) || value < 0 || value > 255)) return null;
|
||||
return numbers;
|
||||
}
|
||||
|
||||
function parseIpv4FromMappedIpv6(mapped: string): number[] | null {
|
||||
if (mapped.includes(".")) {
|
||||
return parseIpv4(mapped);
|
||||
}
|
||||
const parts = mapped.split(":").filter(Boolean);
|
||||
if (parts.length === 1) {
|
||||
const value = Number.parseInt(parts[0], 16);
|
||||
if (Number.isNaN(value) || value < 0 || value > 0xffff_ffff) return null;
|
||||
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
|
||||
}
|
||||
if (parts.length !== 2) return null;
|
||||
const high = Number.parseInt(parts[0], 16);
|
||||
const low = Number.parseInt(parts[1], 16);
|
||||
if (
|
||||
Number.isNaN(high) ||
|
||||
Number.isNaN(low) ||
|
||||
high < 0 ||
|
||||
low < 0 ||
|
||||
high > 0xffff ||
|
||||
low > 0xffff
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const value = (high << 16) + low;
|
||||
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
|
||||
}
|
||||
|
||||
function isPrivateIpv4(parts: number[]): boolean {
|
||||
const [octet1, octet2] = parts;
|
||||
if (octet1 === 0) return true;
|
||||
if (octet1 === 10) return true;
|
||||
if (octet1 === 127) return true;
|
||||
if (octet1 === 169 && octet2 === 254) return true;
|
||||
if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) return true;
|
||||
if (octet1 === 192 && octet2 === 168) return true;
|
||||
if (octet1 === 100 && octet2 >= 64 && octet2 <= 127) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPrivateIpAddress(address: string): boolean {
|
||||
let normalized = address.trim().toLowerCase();
|
||||
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
||||
normalized = normalized.slice(1, -1);
|
||||
}
|
||||
if (!normalized) return false;
|
||||
|
||||
if (normalized.startsWith("::ffff:")) {
|
||||
const mapped = normalized.slice("::ffff:".length);
|
||||
const ipv4 = parseIpv4FromMappedIpv6(mapped);
|
||||
if (ipv4) return isPrivateIpv4(ipv4);
|
||||
}
|
||||
|
||||
if (normalized.includes(":")) {
|
||||
if (normalized === "::" || normalized === "::1") return true;
|
||||
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
const ipv4 = parseIpv4(normalized);
|
||||
if (!ipv4) return false;
|
||||
return isPrivateIpv4(ipv4);
|
||||
}
|
||||
|
||||
export function isBlockedHostname(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
if (!normalized) return false;
|
||||
if (BLOCKED_HOSTNAMES.has(normalized)) return true;
|
||||
return (
|
||||
normalized.endsWith(".localhost") ||
|
||||
normalized.endsWith(".local") ||
|
||||
normalized.endsWith(".internal")
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertPublicHostname(
|
||||
hostname: string,
|
||||
lookupFn: LookupFn = dnsLookup,
|
||||
): Promise<void> {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid hostname");
|
||||
}
|
||||
|
||||
if (isBlockedHostname(normalized)) {
|
||||
throw new SsrFBlockedError(`Blocked hostname: ${hostname}`);
|
||||
}
|
||||
|
||||
if (isPrivateIpAddress(normalized)) {
|
||||
throw new SsrFBlockedError("Blocked: private/internal IP address");
|
||||
}
|
||||
|
||||
const results = await lookupFn(normalized, { all: true });
|
||||
if (results.length === 0) {
|
||||
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
||||
}
|
||||
|
||||
for (const entry of results) {
|
||||
if (isPrivateIpAddress(entry.address)) {
|
||||
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export type NodePairingPairedNode = {
|
||||
remoteIp?: string;
|
||||
createdAtMs: number;
|
||||
approvedAtMs: number;
|
||||
lastConnectedAtMs?: number;
|
||||
};
|
||||
|
||||
export type NodePairingList = {
|
||||
@@ -295,6 +296,7 @@ export async function updatePairedNodeMetadata(
|
||||
commands: patch.commands ?? existing.commands,
|
||||
bins: patch.bins ?? existing.bins,
|
||||
permissions: patch.permissions ?? existing.permissions,
|
||||
lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs,
|
||||
};
|
||||
|
||||
state.pairedByNodeId[normalized] = next;
|
||||
|
||||
@@ -33,6 +33,14 @@ export type RestartSentinelPayload = {
|
||||
status: "ok" | "error" | "skipped";
|
||||
ts: number;
|
||||
sessionKey?: string;
|
||||
/** Delivery context captured at restart time to ensure channel routing survives restart. */
|
||||
deliveryContext?: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
/** Thread ID for reply threading (e.g., Slack thread_ts). */
|
||||
threadId?: string;
|
||||
message?: string | null;
|
||||
doctorHint?: string | null;
|
||||
stats?: RestartSentinelStats | null;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import process from "node:process";
|
||||
|
||||
import { formatUncaughtError } from "./errors.js";
|
||||
|
||||
type UnhandledRejectionHandler = (reason: unknown) => boolean;
|
||||
|
||||
const handlers = new Set<UnhandledRejectionHandler>();
|
||||
@@ -28,10 +30,7 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
|
||||
export function installUnhandledRejectionHandler(): void {
|
||||
process.on("unhandledRejection", (reason, _promise) => {
|
||||
if (isUnhandledRejectionHandled(reason)) return;
|
||||
console.error(
|
||||
"[clawdbot] Unhandled promise rejection:",
|
||||
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
|
||||
);
|
||||
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+2
-1
@@ -55,6 +55,7 @@ async function main() {
|
||||
|
||||
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
|
||||
assertSupportedRuntime();
|
||||
const { formatUncaughtError } = await import("../infra/errors.js");
|
||||
const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js");
|
||||
|
||||
const { buildProgram } = await import("../cli/program.js");
|
||||
@@ -63,7 +64,7 @@ async function main() {
|
||||
installUnhandledRejectionHandler();
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
|
||||
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { assertPublicHostname } from "../infra/net/ssrf.js";
|
||||
|
||||
type CanvasModule = typeof import("@napi-rs/canvas");
|
||||
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
@@ -107,50 +107,6 @@ export const DEFAULT_INPUT_PDF_MAX_PAGES = 4;
|
||||
export const DEFAULT_INPUT_PDF_MAX_PIXELS = 4_000_000;
|
||||
export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200;
|
||||
|
||||
const PRIVATE_IPV4_PATTERNS = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^192\.168\./,
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
||||
/^0\./,
|
||||
];
|
||||
const PRIVATE_IPV6_PREFIXES = ["::1", "fe80:", "fec0:", "fc", "fd"];
|
||||
|
||||
function isPrivateIpAddress(address: string): boolean {
|
||||
if (address.includes(":")) {
|
||||
const lower = address.toLowerCase();
|
||||
if (lower === "::1") return true;
|
||||
return PRIVATE_IPV6_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||||
}
|
||||
return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(address));
|
||||
}
|
||||
|
||||
function isBlockedHostname(hostname: string): boolean {
|
||||
const lower = hostname.toLowerCase();
|
||||
return (
|
||||
lower === "localhost" ||
|
||||
lower.endsWith(".localhost") ||
|
||||
lower.endsWith(".local") ||
|
||||
lower.endsWith(".internal")
|
||||
);
|
||||
}
|
||||
|
||||
async function assertPublicHostname(hostname: string): Promise<void> {
|
||||
if (isBlockedHostname(hostname)) {
|
||||
throw new Error(`Blocked hostname: ${hostname}`);
|
||||
}
|
||||
|
||||
const results = await lookup(hostname, { all: true });
|
||||
if (results.length === 0) {
|
||||
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
||||
}
|
||||
for (const entry of results) {
|
||||
if (isPrivateIpAddress(entry.address)) {
|
||||
throw new Error(`Private IP addresses are not allowed: ${entry.address}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRedirectStatus(status: number): boolean {
|
||||
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSafeStreamWriter } from "./stream-writer.js";
|
||||
|
||||
describe("createSafeStreamWriter", () => {
|
||||
it("signals broken pipes and closes the writer", () => {
|
||||
const onBrokenPipe = vi.fn();
|
||||
const writer = createSafeStreamWriter({ onBrokenPipe });
|
||||
const stream = {
|
||||
write: vi.fn(() => {
|
||||
const err = new Error("EPIPE") as NodeJS.ErrnoException;
|
||||
err.code = "EPIPE";
|
||||
throw err;
|
||||
}),
|
||||
} as unknown as NodeJS.WriteStream;
|
||||
|
||||
expect(writer.writeLine(stream, "hello")).toBe(false);
|
||||
expect(writer.isClosed()).toBe(true);
|
||||
expect(onBrokenPipe).toHaveBeenCalledTimes(1);
|
||||
|
||||
onBrokenPipe.mockClear();
|
||||
expect(writer.writeLine(stream, "again")).toBe(false);
|
||||
expect(onBrokenPipe).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("treats broken pipes from beforeWrite as closed", () => {
|
||||
const onBrokenPipe = vi.fn();
|
||||
const writer = createSafeStreamWriter({
|
||||
onBrokenPipe,
|
||||
beforeWrite: () => {
|
||||
const err = new Error("EIO") as NodeJS.ErrnoException;
|
||||
err.code = "EIO";
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
const stream = { write: vi.fn(() => true) } as unknown as NodeJS.WriteStream;
|
||||
|
||||
expect(writer.write(stream, "hi")).toBe(false);
|
||||
expect(writer.isClosed()).toBe(true);
|
||||
expect(onBrokenPipe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
export type SafeStreamWriterOptions = {
|
||||
beforeWrite?: () => void;
|
||||
onBrokenPipe?: (err: NodeJS.ErrnoException, stream: NodeJS.WriteStream) => void;
|
||||
};
|
||||
|
||||
export type SafeStreamWriter = {
|
||||
write: (stream: NodeJS.WriteStream, text: string) => boolean;
|
||||
writeLine: (stream: NodeJS.WriteStream, text: string) => boolean;
|
||||
reset: () => void;
|
||||
isClosed: () => boolean;
|
||||
};
|
||||
|
||||
function isBrokenPipeError(err: unknown): err is NodeJS.ErrnoException {
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
return code === "EPIPE" || code === "EIO";
|
||||
}
|
||||
|
||||
export function createSafeStreamWriter(options: SafeStreamWriterOptions = {}): SafeStreamWriter {
|
||||
let closed = false;
|
||||
let notified = false;
|
||||
|
||||
const noteBrokenPipe = (err: NodeJS.ErrnoException, stream: NodeJS.WriteStream) => {
|
||||
if (notified) return;
|
||||
notified = true;
|
||||
options.onBrokenPipe?.(err, stream);
|
||||
};
|
||||
|
||||
const handleError = (err: unknown, stream: NodeJS.WriteStream): boolean => {
|
||||
if (!isBrokenPipeError(err)) {
|
||||
throw err;
|
||||
}
|
||||
closed = true;
|
||||
noteBrokenPipe(err, stream);
|
||||
return false;
|
||||
};
|
||||
|
||||
const write = (stream: NodeJS.WriteStream, text: string): boolean => {
|
||||
if (closed) return false;
|
||||
try {
|
||||
options.beforeWrite?.();
|
||||
} catch (err) {
|
||||
return handleError(err, process.stderr);
|
||||
}
|
||||
try {
|
||||
stream.write(text);
|
||||
return !closed;
|
||||
} catch (err) {
|
||||
return handleError(err, stream);
|
||||
}
|
||||
};
|
||||
|
||||
const writeLine = (stream: NodeJS.WriteStream, text: string): boolean =>
|
||||
write(stream, `${text}\n`);
|
||||
|
||||
return {
|
||||
write,
|
||||
writeLine,
|
||||
reset: () => {
|
||||
closed = false;
|
||||
notified = false;
|
||||
},
|
||||
isClosed: () => closed,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@import "./chat/layout.css";
|
||||
@import "./chat/legacy.css";
|
||||
@import "./chat/text.css";
|
||||
@import "./chat/grouped.css";
|
||||
@import "./chat/tool-cards.css";
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
|
||||
/* Minimal Bubble Design - dynamic width based on content */
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
@@ -102,6 +103,77 @@
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-bubble.has-copy {
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.chat-copy-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease-out, background 120ms ease-out;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-bubble:hover .chat-copy-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-copy-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copying="1"] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-error="1"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
border-color: rgba(255, 69, 58, 0.8);
|
||||
background: rgba(255, 69, 58, 0.18);
|
||||
color: rgba(255, 69, 58, 1);
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copied="1"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
border-color: rgba(52, 199, 183, 0.8);
|
||||
background: rgba(52, 199, 183, 0.18);
|
||||
color: rgba(52, 199, 183, 1);
|
||||
}
|
||||
|
||||
.chat-copy-btn:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.chat-copy-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-bubble:hover {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
@@ -145,4 +217,3 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/* =============================================
|
||||
LEGACY CHAT LINE LAYOUT (non-grouped)
|
||||
============================================= */
|
||||
|
||||
.chat-line {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-line.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-line.assistant,
|
||||
.chat-line.other,
|
||||
.chat-line.tool {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chat-line.tool .chat-bubble {
|
||||
border-style: dashed;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: min(900px, 95%);
|
||||
}
|
||||
|
||||
.chat-line.user .chat-msg {
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.chat-stamp {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-line.user .chat-stamp {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -358,6 +358,98 @@
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* Section Hero */
|
||||
.config-section-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-section-hero {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
.config-section-hero__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.config-section-hero__icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.config-section-hero__text {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-section-hero__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-section-hero__desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Subsection Nav */
|
||||
.config-subnav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 20px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-subnav {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.config-subnav__item {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-subnav__item {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.config-subnav__item:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-subnav__item:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.config-subnav__item.active {
|
||||
color: var(--accent);
|
||||
border-color: rgba(245, 159, 74, 0.4);
|
||||
background: rgba(245, 159, 74, 0.12);
|
||||
}
|
||||
|
||||
/* Content Area */
|
||||
.config-content {
|
||||
flex: 1;
|
||||
@@ -1243,6 +1335,14 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.config-section-hero {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.config-subnav {
|
||||
padding: 8px 16px 10px;
|
||||
}
|
||||
|
||||
.config-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -39,10 +39,6 @@ export function renderTab(state: AppViewState, tab: Tab) {
|
||||
|
||||
export function renderChatControls(state: AppViewState) {
|
||||
const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
|
||||
// Icon for list view (legacy)
|
||||
const listIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`;
|
||||
// Icon for grouped view
|
||||
const groupIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`;
|
||||
// Refresh icon
|
||||
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
|
||||
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
|
||||
@@ -116,18 +112,6 @@ export function renderChatControls(state: AppViewState) {
|
||||
>
|
||||
${focusIcon}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${state.settings.useNewChatLayout ? "active" : ""}"
|
||||
@click=${() =>
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
useNewChatLayout: !state.settings.useNewChatLayout,
|
||||
})}
|
||||
aria-pressed=${state.settings.useNewChatLayout}
|
||||
title="${state.settings.useNewChatLayout ? "Switch to list view" : "Switch to grouped view"}"
|
||||
>
|
||||
${state.settings.useNewChatLayout ? groupIcon : listIcon}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
+6
-10
@@ -429,11 +429,7 @@ export function renderApp(state: AppViewState) {
|
||||
disabledReason: chatDisabledReason,
|
||||
error: state.lastError,
|
||||
sessions: state.sessionsResult,
|
||||
isToolOutputExpanded: (id) => state.toolOutputExpanded.has(id),
|
||||
onToolOutputToggle: (id, expanded) =>
|
||||
state.toggleToolOutput(id, expanded),
|
||||
focusMode: state.settings.chatFocusMode,
|
||||
useNewChatLayout: state.settings.useNewChatLayout,
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return loadChatHistory(state);
|
||||
@@ -443,11 +439,6 @@ export function renderApp(state: AppViewState) {
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
}),
|
||||
onToggleLayout: () =>
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
useNewChatLayout: !state.settings.useNewChatLayout,
|
||||
}),
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
@@ -485,11 +476,16 @@ export function renderApp(state: AppViewState) {
|
||||
originalValue: state.configFormOriginal,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
activeSubsection: state.configActiveSubsection,
|
||||
onRawChange: (next) => (state.configRaw = next),
|
||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||
onSectionChange: (section) => (state.configActiveSection = section),
|
||||
onSectionChange: (section) => {
|
||||
state.configActiveSection = section;
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
|
||||
@@ -31,7 +31,6 @@ type ToolStreamHost = {
|
||||
toolStreamById: Map<string, ToolStreamEntry>;
|
||||
toolStreamOrder: string[];
|
||||
chatToolMessages: Record<string, unknown>[];
|
||||
toolOutputExpanded: Set<string>;
|
||||
toolStreamSyncTimer: number | null;
|
||||
};
|
||||
|
||||
@@ -136,20 +135,9 @@ export function resetToolStream(host: ToolStreamHost) {
|
||||
host.toolStreamById.clear();
|
||||
host.toolStreamOrder = [];
|
||||
host.chatToolMessages = [];
|
||||
host.toolOutputExpanded = new Set();
|
||||
flushToolStreamSync(host);
|
||||
}
|
||||
|
||||
export function toggleToolOutput(host: ToolStreamHost, id: string, expanded: boolean) {
|
||||
const next = new Set(host.toolOutputExpanded);
|
||||
if (expanded) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
host.toolOutputExpanded = next;
|
||||
}
|
||||
|
||||
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||
if (!payload || payload.stream !== "tool") return;
|
||||
const sessionKey =
|
||||
|
||||
+1
-9
@@ -33,7 +33,6 @@ import type { DevicePairingList } from "./controllers/devices";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||
import {
|
||||
resetToolStream as resetToolStreamInternal,
|
||||
toggleToolOutput as toggleToolOutputInternal,
|
||||
type ToolStreamEntry,
|
||||
} from "./app-tool-stream";
|
||||
import {
|
||||
@@ -109,7 +108,6 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
@state() toolOutputExpanded = new Set<string>();
|
||||
// Sidebar state for tool output viewing
|
||||
@state() sidebarOpen = false;
|
||||
@state() sidebarContent: string | null = null;
|
||||
@@ -152,6 +150,7 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() configFormMode: "form" | "raw" = "form";
|
||||
@state() configSearchQuery = "";
|
||||
@state() configActiveSection: string | null = null;
|
||||
@state() configActiveSubsection: string | null = null;
|
||||
|
||||
@state() channelsLoading = false;
|
||||
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
||||
@@ -300,13 +299,6 @@ export class ClawdbotApp extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
toggleToolOutput(id: string, expanded: boolean) {
|
||||
toggleToolOutputInternal(
|
||||
this as unknown as Parameters<typeof toggleToolOutputInternal>[0],
|
||||
id,
|
||||
expanded,
|
||||
);
|
||||
}
|
||||
applySettings(next: UiSettings) {
|
||||
applySettingsInternal(
|
||||
this as unknown as Parameters<typeof applySettingsInternal>[0],
|
||||
|
||||
@@ -28,12 +28,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("chat markdown rendering", () => {
|
||||
it("renders markdown inside tool result cards", async () => {
|
||||
localStorage.setItem(
|
||||
"clawdbot.control.settings.v1",
|
||||
JSON.stringify({ useNewChatLayout: false }),
|
||||
);
|
||||
|
||||
it("renders markdown inside tool output sidebar", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
@@ -48,12 +43,16 @@ describe("chat markdown rendering", () => {
|
||||
timestamp,
|
||||
},
|
||||
];
|
||||
// Expand the tool output card so its markdown is rendered into the DOM.
|
||||
app.toolOutputExpanded = new Set([`${timestamp}:1`]);
|
||||
|
||||
await app.updateComplete;
|
||||
|
||||
const strong = app.querySelector(".chat-tool-card__output strong");
|
||||
const toolCard = app.querySelector(".chat-tool-card") as HTMLElement | null;
|
||||
expect(toolCard).not.toBeNull();
|
||||
toolCard?.click();
|
||||
|
||||
await app.updateComplete;
|
||||
|
||||
const strong = app.querySelector(".sidebar-markdown strong");
|
||||
expect(strong?.textContent).toBe("world");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { renderEmojiIcon, setEmojiIcon } from "../icons";
|
||||
|
||||
const COPIED_FOR_MS = 1500;
|
||||
const ERROR_FOR_MS = 2000;
|
||||
const COPY_LABEL = "Copy as markdown";
|
||||
const COPIED_LABEL = "Copied";
|
||||
const ERROR_LABEL = "Copy failed";
|
||||
const COPY_ICON = "📋";
|
||||
const COPIED_ICON = "✓";
|
||||
const ERROR_ICON = "!";
|
||||
|
||||
type CopyButtonOptions = {
|
||||
text: () => string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
if (!text) return false;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setButtonLabel(button: HTMLButtonElement, label: string) {
|
||||
button.title = label;
|
||||
button.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
function createCopyButton(options: CopyButtonOptions): TemplateResult {
|
||||
const idleLabel = options.label ?? COPY_LABEL;
|
||||
return html`
|
||||
<button
|
||||
class="chat-copy-btn"
|
||||
type="button"
|
||||
title=${idleLabel}
|
||||
aria-label=${idleLabel}
|
||||
@click=${async (e: Event) => {
|
||||
const btn = e.currentTarget as HTMLButtonElement | null;
|
||||
const icon = btn?.querySelector(
|
||||
".chat-copy-btn__icon",
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!btn || btn.dataset.copying === "1") return;
|
||||
|
||||
btn.dataset.copying = "1";
|
||||
btn.setAttribute("aria-busy", "true");
|
||||
btn.disabled = true;
|
||||
|
||||
const copied = await copyTextToClipboard(options.text());
|
||||
if (!btn.isConnected) return;
|
||||
|
||||
delete btn.dataset.copying;
|
||||
btn.removeAttribute("aria-busy");
|
||||
btn.disabled = false;
|
||||
|
||||
if (!copied) {
|
||||
btn.dataset.error = "1";
|
||||
setButtonLabel(btn, ERROR_LABEL);
|
||||
setEmojiIcon(icon, ERROR_ICON);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!btn.isConnected) return;
|
||||
delete btn.dataset.error;
|
||||
setButtonLabel(btn, idleLabel);
|
||||
setEmojiIcon(icon, COPY_ICON);
|
||||
}, ERROR_FOR_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
btn.dataset.copied = "1";
|
||||
setButtonLabel(btn, COPIED_LABEL);
|
||||
setEmojiIcon(icon, COPIED_ICON);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!btn.isConnected) return;
|
||||
delete btn.dataset.copied;
|
||||
setButtonLabel(btn, idleLabel);
|
||||
setEmojiIcon(icon, COPY_ICON);
|
||||
}, COPIED_FOR_MS);
|
||||
}}
|
||||
>
|
||||
${renderEmojiIcon(COPY_ICON, "chat-copy-btn__icon")}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderCopyAsMarkdownButton(markdown: string): TemplateResult {
|
||||
return createCopyButton({ text: () => markdown, label: COPY_LABEL });
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||
import type { MessageGroup } from "../types/chat-types";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
|
||||
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
|
||||
import {
|
||||
extractText,
|
||||
@@ -150,9 +151,11 @@ function renderGroupedMessage(
|
||||
? formatReasoningMarkdown(extractedThinking)
|
||||
: null;
|
||||
const markdown = markdownBase;
|
||||
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
||||
|
||||
const bubbleClasses = [
|
||||
"chat-bubble",
|
||||
canCopyMarkdown ? "has-copy" : "",
|
||||
opts.isStreaming ? "streaming" : "",
|
||||
"fade-in",
|
||||
]
|
||||
@@ -169,6 +172,7 @@ function renderGroupedMessage(
|
||||
|
||||
return html`
|
||||
<div class="${bubbleClasses}">
|
||||
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
||||
${reasoningMarkdown
|
||||
? html`<div class="chat-thinking">${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||
@@ -181,4 +185,3 @@ function renderGroupedMessage(
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||
import {
|
||||
isToolResultMessage,
|
||||
normalizeRoleForGrouping,
|
||||
} from "./message-normalizer";
|
||||
import {
|
||||
extractText,
|
||||
extractThinking,
|
||||
formatReasoningMarkdown,
|
||||
} from "./message-extract";
|
||||
import { extractToolCards, renderToolCardLegacy } from "./tool-cards";
|
||||
|
||||
export type LegacyToolOutputProps = {
|
||||
isToolOutputExpanded?: (id: string) => boolean;
|
||||
onToolOutputToggle?: (id: string, expanded: boolean) => void;
|
||||
};
|
||||
|
||||
export function renderReadingIndicator() {
|
||||
return html`
|
||||
<div class="chat-line assistant">
|
||||
<div class="chat-msg">
|
||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
||||
<span class="chat-reading-indicator__dots">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderMessage(
|
||||
message: unknown,
|
||||
props?: LegacyToolOutputProps,
|
||||
opts?: { streaming?: boolean; showReasoning?: boolean },
|
||||
) {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const toolCards = extractToolCards(message);
|
||||
const hasToolCards = toolCards.length > 0;
|
||||
const isToolResult =
|
||||
isToolResultMessage(message) ||
|
||||
typeof m.toolCallId === "string" ||
|
||||
typeof m.tool_call_id === "string";
|
||||
const extractedText = extractText(message);
|
||||
const extractedThinking =
|
||||
opts?.showReasoning && role === "assistant" ? extractThinking(message) : null;
|
||||
const contentText = typeof m.content === "string" ? m.content : null;
|
||||
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
|
||||
|
||||
const display =
|
||||
!isToolResult && extractedText?.trim()
|
||||
? { kind: "text" as const, value: extractedText }
|
||||
: !isToolResult && contentText?.trim()
|
||||
? { kind: "text" as const, value: contentText }
|
||||
: !isToolResult && fallback
|
||||
? { kind: "json" as const, value: fallback }
|
||||
: null;
|
||||
|
||||
const markdownBase =
|
||||
display?.kind === "json"
|
||||
? ["```json", display.value, "```"].join("\n")
|
||||
: (display?.value ?? null);
|
||||
const reasoningMarkdown = extractedThinking
|
||||
? formatReasoningMarkdown(extractedThinking)
|
||||
: null;
|
||||
const markdown = markdownBase;
|
||||
|
||||
const timestamp =
|
||||
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
||||
|
||||
const normalizedRole = normalizeRoleForGrouping(role);
|
||||
const klass =
|
||||
normalizedRole === "assistant"
|
||||
? "assistant"
|
||||
: normalizedRole === "user"
|
||||
? "user"
|
||||
: normalizedRole === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
const who =
|
||||
normalizedRole === "assistant"
|
||||
? "Assistant"
|
||||
: normalizedRole === "user"
|
||||
? "You"
|
||||
: normalizedRole === "tool"
|
||||
? "Working"
|
||||
: normalizedRole;
|
||||
|
||||
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
|
||||
const toolCardBase =
|
||||
toolCallId ||
|
||||
(typeof m.id === "string" ? m.id : "") ||
|
||||
(typeof m.messageId === "string" ? m.messageId : "") ||
|
||||
(typeof m.timestamp === "number" ? String(m.timestamp) : "tool-card");
|
||||
|
||||
return html`
|
||||
<div class="chat-line ${klass}">
|
||||
<div class="chat-msg">
|
||||
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
|
||||
${reasoningMarkdown
|
||||
? html`<div class="chat-thinking">${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||
)}</div>`
|
||||
: nothing}
|
||||
${markdown
|
||||
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||
: nothing}
|
||||
${toolCards.map((card, index) =>
|
||||
renderToolCardLegacy(card, {
|
||||
id: `${toolCardBase}:${index}`,
|
||||
expanded: props?.isToolOutputExpanded
|
||||
? props.isToolOutputExpanded(`${toolCardBase}:${index}`)
|
||||
: false,
|
||||
onToggle: props?.onToolOutputToggle,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
<div class="chat-stamp mono">
|
||||
${who}${timestamp ? html` · ${timestamp}` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
|
||||
import type { ToolCard } from "../types/chat-types";
|
||||
import { TOOL_INLINE_THRESHOLD } from "./constants";
|
||||
@@ -54,60 +52,6 @@ export function extractToolCards(message: unknown): ToolCard[] {
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function renderToolCardLegacy(
|
||||
card: ToolCard,
|
||||
opts?: {
|
||||
id: string;
|
||||
expanded: boolean;
|
||||
onToggle?: (id: string, expanded: boolean) => void;
|
||||
},
|
||||
) {
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args });
|
||||
const detail = formatToolDetail(display);
|
||||
const hasOutput = typeof card.text === "string" && card.text.length > 0;
|
||||
const expanded = opts?.expanded ?? false;
|
||||
const id = opts?.id ?? `${card.name}-${Math.random()}`;
|
||||
return html`
|
||||
<div class="chat-tool-card">
|
||||
<div class="chat-tool-card__header">
|
||||
<div class="chat-tool-card__title">
|
||||
<span class="chat-tool-card__icon">${display.emoji}</span>
|
||||
<span>${display.label}</span>
|
||||
</div>
|
||||
${!hasOutput ? html`<span class="chat-tool-card__status">✓</span>` : nothing}
|
||||
</div>
|
||||
${detail
|
||||
? html`<div class="chat-tool-card__detail">${detail}</div>`
|
||||
: nothing}
|
||||
${hasOutput
|
||||
? html`
|
||||
<details
|
||||
class="chat-tool-card__details"
|
||||
?open=${expanded}
|
||||
@toggle=${(e: Event) => {
|
||||
if (!opts?.onToggle) return;
|
||||
const target = e.currentTarget as HTMLDetailsElement;
|
||||
opts.onToggle(id, target.open);
|
||||
}}
|
||||
>
|
||||
<summary class="chat-tool-card__summary">
|
||||
${expanded ? "Hide output" : "Show output"}
|
||||
<span class="chat-tool-card__summary-meta">
|
||||
(${card.text?.length ?? 0} chars)
|
||||
</span>
|
||||
</summary>
|
||||
${expanded
|
||||
? html`<div class="chat-tool-card__output chat-text">
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))}
|
||||
</div>`
|
||||
: nothing}
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderToolCardSidebar(
|
||||
card: ToolCard,
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
@@ -197,4 +141,3 @@ function extractToolText(item: Record<string, unknown>): string | undefined {
|
||||
if (typeof item.content === "string") return item.content;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type ConfigState = {
|
||||
configFormMode: "form" | "raw";
|
||||
configSearchQuery: string;
|
||||
configActiveSection: string | null;
|
||||
configActiveSubsection: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
|
||||
export function renderEmojiIcon(icon: string, className: string): TemplateResult {
|
||||
return html`<span class=${className} aria-hidden="true">${icon}</span>`;
|
||||
}
|
||||
|
||||
export function setEmojiIcon(target: HTMLElement | null, icon: string): void {
|
||||
if (!target) return;
|
||||
target.textContent = icon;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ export type UiSettings = {
|
||||
chatFocusMode: boolean;
|
||||
chatShowThinking: boolean;
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
useNewChatLayout: boolean; // Slack-style grouped messages layout
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
};
|
||||
@@ -31,7 +30,6 @@ export function loadSettings(): UiSettings {
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
useNewChatLayout: true, // Enabled by default
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
};
|
||||
@@ -77,10 +75,6 @@ export function loadSettings(): UiSettings {
|
||||
parsed.splitRatio <= 0.7
|
||||
? parsed.splitRatio
|
||||
: defaults.splitRatio,
|
||||
useNewChatLayout:
|
||||
typeof parsed.useNewChatLayout === "boolean"
|
||||
? parsed.useNewChatLayout
|
||||
: defaults.useNewChatLayout,
|
||||
navCollapsed:
|
||||
typeof parsed.navCollapsed === "boolean"
|
||||
? parsed.navCollapsed
|
||||
|
||||
+10
-31
@@ -8,7 +8,6 @@ import {
|
||||
normalizeRoleForGrouping,
|
||||
} from "../chat/message-normalizer";
|
||||
import { extractText } from "../chat/message-extract";
|
||||
import { renderMessage, renderReadingIndicator } from "../chat/legacy-render";
|
||||
import {
|
||||
renderMessageGroup,
|
||||
renderReadingIndicatorGroup,
|
||||
@@ -36,14 +35,9 @@ export type ChatProps = {
|
||||
disabledReason: string | null;
|
||||
error: string | null;
|
||||
sessions: SessionsListResult | null;
|
||||
// Legacy tool output expand/collapse (used when useNewChatLayout is false)
|
||||
isToolOutputExpanded: (id: string) => boolean;
|
||||
onToolOutputToggle: (id: string, expanded: boolean) => void;
|
||||
// Focus mode
|
||||
focusMode: boolean;
|
||||
// Feature flag for new Slack-style layout with sidebar
|
||||
useNewChatLayout?: boolean;
|
||||
// Sidebar state (used when useNewChatLayout is true)
|
||||
// Sidebar state
|
||||
sidebarOpen?: boolean;
|
||||
sidebarContent?: string | null;
|
||||
sidebarError?: string | null;
|
||||
@@ -51,7 +45,6 @@ export type ChatProps = {
|
||||
// Event handlers
|
||||
onRefresh: () => void;
|
||||
onToggleFocusMode: () => void;
|
||||
onToggleLayout?: () => void;
|
||||
onDraftChange: (next: string) => void;
|
||||
onSend: () => void;
|
||||
onAbort?: () => void;
|
||||
@@ -78,7 +71,6 @@ export function renderChat(props: ChatProps) {
|
||||
|
||||
const splitRatio = props.splitRatio ?? 0.6;
|
||||
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
||||
const useNewLayout = props.useNewChatLayout ?? false;
|
||||
|
||||
return html`
|
||||
<section class="card chat">
|
||||
@@ -122,27 +114,15 @@ export function renderChat(props: ChatProps) {
|
||||
: nothing}
|
||||
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||
if (item.kind === "reading-indicator") {
|
||||
return useNewLayout
|
||||
? renderReadingIndicatorGroup()
|
||||
: renderReadingIndicator();
|
||||
return renderReadingIndicatorGroup();
|
||||
}
|
||||
|
||||
if (item.kind === "stream") {
|
||||
return useNewLayout
|
||||
? renderStreamingGroup(
|
||||
item.text,
|
||||
item.startedAt,
|
||||
props.onOpenSidebar,
|
||||
)
|
||||
: renderMessage(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: item.text }],
|
||||
timestamp: item.startedAt,
|
||||
},
|
||||
props,
|
||||
{ streaming: true, showReasoning },
|
||||
);
|
||||
return renderStreamingGroup(
|
||||
item.text,
|
||||
item.startedAt,
|
||||
props.onOpenSidebar,
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "group") {
|
||||
@@ -152,12 +132,12 @@ export function renderChat(props: ChatProps) {
|
||||
});
|
||||
}
|
||||
|
||||
return renderMessage(item.message, props, { showReasoning });
|
||||
return nothing;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${useNewLayout && sidebarOpen
|
||||
${sidebarOpen
|
||||
? html`
|
||||
<resizable-divider
|
||||
.splitRatio=${splitRatio}
|
||||
@@ -348,8 +328,7 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
||||
}
|
||||
}
|
||||
|
||||
if (props.useNewChatLayout) return groupMessages(items);
|
||||
return items;
|
||||
return groupMessages(items);
|
||||
}
|
||||
|
||||
function messageKey(message: unknown, index: number): string {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ConfigUiHints } from "../types";
|
||||
import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared";
|
||||
import {
|
||||
hintForPath,
|
||||
humanize,
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "./config-form.shared";
|
||||
import { renderNode } from "./config-form.node";
|
||||
|
||||
export type ConfigFormProps = {
|
||||
@@ -11,6 +16,7 @@ export type ConfigFormProps = {
|
||||
unsupportedPaths?: string[];
|
||||
searchQuery?: string;
|
||||
activeSection?: string | null;
|
||||
activeSubsection?: string | null;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -146,6 +152,7 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
const properties = schema.properties;
|
||||
const searchQuery = props.searchQuery ?? "";
|
||||
const activeSection = props.activeSection;
|
||||
const activeSubsection = props.activeSubsection ?? null;
|
||||
|
||||
// Filter and sort entries
|
||||
let entries = Object.entries(properties);
|
||||
@@ -168,6 +175,25 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
|
||||
let subsectionContext:
|
||||
| { sectionKey: string; subsectionKey: string; schema: JsonSchema }
|
||||
| null = null;
|
||||
if (activeSection && activeSubsection && entries.length === 1) {
|
||||
const sectionSchema = entries[0]?.[1];
|
||||
if (
|
||||
sectionSchema &&
|
||||
schemaType(sectionSchema) === "object" &&
|
||||
sectionSchema.properties &&
|
||||
sectionSchema.properties[activeSubsection]
|
||||
) {
|
||||
subsectionContext = {
|
||||
sectionKey: activeSection,
|
||||
subsectionKey: activeSubsection,
|
||||
schema: sectionSchema.properties[activeSubsection],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return html`
|
||||
<div class="config-empty">
|
||||
@@ -183,38 +209,76 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
|
||||
return html`
|
||||
<div class="config-form config-form--modern">
|
||||
${entries.map(([key, node]) => {
|
||||
const meta = SECTION_META[key] ?? {
|
||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
description: node.description ?? ""
|
||||
};
|
||||
|
||||
return html`
|
||||
<section class="config-section-card" id="config-section-${key}">
|
||||
<div class="config-section-card__header">
|
||||
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
|
||||
<div class="config-section-card__titles">
|
||||
<h3 class="config-section-card__title">${meta.label}</h3>
|
||||
${meta.description ? html`
|
||||
<p class="config-section-card__desc">${meta.description}</p>
|
||||
` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-section-card__content">
|
||||
${renderNode({
|
||||
schema: node,
|
||||
value: (value as Record<string, unknown>)[key],
|
||||
path: [key],
|
||||
hints: props.uiHints,
|
||||
unsupported,
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
})}
|
||||
${subsectionContext
|
||||
? (() => {
|
||||
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
|
||||
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
|
||||
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
|
||||
const description = hint?.help ?? node.description ?? "";
|
||||
const sectionValue = (value as Record<string, unknown>)[sectionKey];
|
||||
const scopedValue =
|
||||
sectionValue && typeof sectionValue === "object"
|
||||
? (sectionValue as Record<string, unknown>)[subsectionKey]
|
||||
: undefined;
|
||||
const id = `config-section-${sectionKey}-${subsectionKey}`;
|
||||
return html`
|
||||
<section class="config-section-card" id=${id}>
|
||||
<div class="config-section-card__header">
|
||||
<span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
|
||||
<div class="config-section-card__titles">
|
||||
<h3 class="config-section-card__title">${label}</h3>
|
||||
${description
|
||||
? html`<p class="config-section-card__desc">${description}</p>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-section-card__content">
|
||||
${renderNode({
|
||||
schema: node,
|
||||
value: scopedValue,
|
||||
path: [sectionKey, subsectionKey],
|
||||
hints: props.uiHints,
|
||||
unsupported,
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
})()
|
||||
: entries.map(([key, node]) => {
|
||||
const meta = SECTION_META[key] ?? {
|
||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
description: node.description ?? "",
|
||||
};
|
||||
|
||||
return html`
|
||||
<section class="config-section-card" id="config-section-${key}">
|
||||
<div class="config-section-card__header">
|
||||
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
|
||||
<div class="config-section-card__titles">
|
||||
<h3 class="config-section-card__title">${meta.label}</h3>
|
||||
${meta.description
|
||||
? html`<p class="config-section-card__desc">${meta.description}</p>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-section-card__content">
|
||||
${renderNode({
|
||||
schema: node,
|
||||
value: (value as Record<string, unknown>)[key],
|
||||
path: [key],
|
||||
hints: props.uiHints,
|
||||
unsupported,
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -21,13 +21,20 @@ describe("config view", () => {
|
||||
uiHints: {},
|
||||
formMode: "form" as const,
|
||||
formValue: {},
|
||||
originalValue: {},
|
||||
searchQuery: "",
|
||||
activeSection: null,
|
||||
activeSubsection: null,
|
||||
onRawChange: vi.fn(),
|
||||
onFormModeChange: vi.fn(),
|
||||
onFormPatch: vi.fn(),
|
||||
onSearchChange: vi.fn(),
|
||||
onSectionChange: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onApply: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
onSubsectionChange: vi.fn(),
|
||||
});
|
||||
|
||||
it("disables save when form is unsafe", () => {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ConfigUiHints } from "../types";
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
|
||||
import {
|
||||
hintForPath,
|
||||
humanize,
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "./config-form.shared";
|
||||
|
||||
export type ConfigProps = {
|
||||
raw: string;
|
||||
@@ -19,11 +25,13 @@ export type ConfigProps = {
|
||||
originalValue: Record<string, unknown> | null;
|
||||
searchQuery: string;
|
||||
activeSection: string | null;
|
||||
activeSubsection: string | null;
|
||||
onRawChange: (next: string) => void;
|
||||
onFormModeChange: (mode: "form" | "raw") => void;
|
||||
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSectionChange: (section: string | null) => void;
|
||||
onSubsectionChange: (section: string | null) => void;
|
||||
onReload: () => void;
|
||||
onSave: () => void;
|
||||
onApply: () => void;
|
||||
@@ -80,10 +88,49 @@ const SECTIONS: Array<{ key: string; label: string }> = [
|
||||
{ key: "wizard", label: "Setup Wizard" },
|
||||
];
|
||||
|
||||
type SubsectionEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
const ALL_SUBSECTION = "__all__";
|
||||
|
||||
function getSectionIcon(key: string) {
|
||||
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
|
||||
}
|
||||
|
||||
function resolveSectionMeta(key: string, schema?: JsonSchema): {
|
||||
label: string;
|
||||
description?: string;
|
||||
} {
|
||||
const meta = SECTION_META[key];
|
||||
if (meta) return meta;
|
||||
return {
|
||||
label: schema?.title ?? humanize(key),
|
||||
description: schema?.description ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSubsections(params: {
|
||||
key: string;
|
||||
schema: JsonSchema | undefined;
|
||||
uiHints: ConfigUiHints;
|
||||
}): SubsectionEntry[] {
|
||||
const { key, schema, uiHints } = params;
|
||||
if (!schema || schemaType(schema) !== "object" || !schema.properties) return [];
|
||||
const entries = Object.entries(schema.properties).map(([subKey, node]) => {
|
||||
const hint = hintForPath([key, subKey], uiHints);
|
||||
const label = hint?.label ?? node.title ?? humanize(subKey);
|
||||
const description = hint?.help ?? node.description ?? "";
|
||||
const order = hint?.order ?? 50;
|
||||
return { key: subKey, label, description, order };
|
||||
});
|
||||
entries.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.key.localeCompare(b.key)));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function computeDiff(
|
||||
original: Record<string, unknown> | null,
|
||||
current: Record<string, unknown> | null
|
||||
@@ -164,6 +211,31 @@ export function renderConfig(props: ConfigProps) {
|
||||
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
|
||||
|
||||
const allSections = [...availableSections, ...extraSections];
|
||||
|
||||
const activeSectionSchema =
|
||||
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
|
||||
? (analysis.schema.properties?.[props.activeSection] as JsonSchema | undefined)
|
||||
: undefined;
|
||||
const activeSectionMeta = props.activeSection
|
||||
? resolveSectionMeta(props.activeSection, activeSectionSchema)
|
||||
: null;
|
||||
const subsections = props.activeSection
|
||||
? resolveSubsections({
|
||||
key: props.activeSection,
|
||||
schema: activeSectionSchema,
|
||||
uiHints: props.uiHints,
|
||||
})
|
||||
: [];
|
||||
const allowSubnav =
|
||||
props.formMode === "form" &&
|
||||
Boolean(props.activeSection) &&
|
||||
subsections.length > 0;
|
||||
const isAllSubsection = props.activeSubsection === ALL_SUBSECTION;
|
||||
const effectiveSubsection = props.searchQuery
|
||||
? null
|
||||
: isAllSubsection
|
||||
? null
|
||||
: props.activeSubsection ?? (subsections[0]?.key ?? null);
|
||||
|
||||
// Compute diff for showing changes
|
||||
const diff = props.formMode === "form"
|
||||
@@ -304,6 +376,46 @@ export function renderConfig(props: ConfigProps) {
|
||||
</details>
|
||||
` : nothing}
|
||||
|
||||
${activeSectionMeta && props.formMode === "form"
|
||||
? html`
|
||||
<div class="config-section-hero">
|
||||
<div class="config-section-hero__icon">${getSectionIcon(props.activeSection ?? "")}</div>
|
||||
<div class="config-section-hero__text">
|
||||
<div class="config-section-hero__title">${activeSectionMeta.label}</div>
|
||||
${activeSectionMeta.description
|
||||
? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${allowSubnav
|
||||
? html`
|
||||
<div class="config-subnav">
|
||||
<button
|
||||
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
|
||||
@click=${() => props.onSubsectionChange(ALL_SUBSECTION)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
${subsections.map(
|
||||
(entry) => html`
|
||||
<button
|
||||
class="config-subnav__item ${
|
||||
effectiveSubsection === entry.key ? "active" : ""
|
||||
}"
|
||||
title=${entry.description || entry.label}
|
||||
@click=${() => props.onSubsectionChange(entry.key)}
|
||||
>
|
||||
${entry.label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<!-- Form content -->
|
||||
<div class="config-content">
|
||||
${props.formMode === "form"
|
||||
@@ -322,6 +434,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
onPatch: props.onFormPatch,
|
||||
searchQuery: props.searchQuery,
|
||||
activeSection: props.activeSection,
|
||||
activeSubsection: effectiveSubsection,
|
||||
})}
|
||||
${formUnsafe
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
|
||||
Reference in new issue
Block a user