Merge main branch and resolve conflicts with attachment support

This commit is contained in:
justelson 2026-01-26 15:57:52 +03:00
commit d206e2ee10
72 changed files with 2967 additions and 169 deletions

174
.github/labeler.yml vendored Normal file
View File

@ -0,0 +1,174 @@
"channel: bluebubbles":
- changed-files:
- any-glob-to-any-file:
- "extensions/bluebubbles/**"
- "docs/channels/bluebubbles.md"
"channel: discord":
- changed-files:
- any-glob-to-any-file:
- "src/discord/**"
- "extensions/discord/**"
- "docs/channels/discord.md"
"channel: googlechat":
- changed-files:
- any-glob-to-any-file:
- "extensions/googlechat/**"
- "docs/channels/googlechat.md"
"channel: imessage":
- changed-files:
- any-glob-to-any-file:
- "src/imessage/**"
- "extensions/imessage/**"
- "docs/channels/imessage.md"
"channel: line":
- changed-files:
- any-glob-to-any-file:
- "extensions/line/**"
"channel: matrix":
- changed-files:
- any-glob-to-any-file:
- "extensions/matrix/**"
- "docs/channels/matrix.md"
"channel: mattermost":
- changed-files:
- any-glob-to-any-file:
- "extensions/mattermost/**"
- "docs/channels/mattermost.md"
"channel: msteams":
- changed-files:
- any-glob-to-any-file:
- "extensions/msteams/**"
- "docs/channels/msteams.md"
"channel: nextcloud-talk":
- changed-files:
- any-glob-to-any-file:
- "extensions/nextcloud-talk/**"
- "docs/channels/nextcloud-talk.md"
"channel: nostr":
- changed-files:
- any-glob-to-any-file:
- "extensions/nostr/**"
- "docs/channels/nostr.md"
"channel: signal":
- changed-files:
- any-glob-to-any-file:
- "src/signal/**"
- "extensions/signal/**"
- "docs/channels/signal.md"
"channel: slack":
- changed-files:
- any-glob-to-any-file:
- "src/slack/**"
- "extensions/slack/**"
- "docs/channels/slack.md"
"channel: telegram":
- changed-files:
- any-glob-to-any-file:
- "src/telegram/**"
- "extensions/telegram/**"
- "docs/channels/telegram.md"
"channel: tlon":
- changed-files:
- any-glob-to-any-file:
- "extensions/tlon/**"
- "docs/channels/tlon.md"
"channel: voice-call":
- changed-files:
- any-glob-to-any-file:
- "extensions/voice-call/**"
"channel: whatsapp-web":
- changed-files:
- any-glob-to-any-file:
- "src/web/**"
- "extensions/whatsapp/**"
- "docs/channels/whatsapp.md"
"channel: zalo":
- changed-files:
- any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zalouser":
- changed-files:
- any-glob-to-any-file:
- "extensions/zalouser/**"
- "docs/channels/zalouser.md"
"app: android":
- changed-files:
- any-glob-to-any-file:
- "apps/android/**"
- "docs/platforms/android.md"
"app: ios":
- changed-files:
- any-glob-to-any-file:
- "apps/ios/**"
- "docs/platforms/ios.md"
"app: macos":
- changed-files:
- any-glob-to-any-file:
- "apps/macos/**"
- "docs/platforms/macos.md"
- "docs/platforms/mac/**"
"app: web-ui":
- changed-files:
- any-glob-to-any-file:
- "ui/**"
- "src/gateway/control-ui.ts"
- "src/gateway/control-ui-shared.ts"
- "src/gateway/protocol/**"
- "src/gateway/server-methods/chat.ts"
- "src/infra/control-ui-assets.ts"
"gateway":
- changed-files:
- any-glob-to-any-file:
- "src/gateway/**"
- "src/daemon/**"
- "docs/gateway/**"
"docs":
- changed-files:
- any-glob-to-any-file:
- "docs/**"
- "docs.acp.md"
"extensions: copilot-proxy":
- changed-files:
- any-glob-to-any-file:
- "extensions/copilot-proxy/**"
"extensions: diagnostics-otel":
- changed-files:
- any-glob-to-any-file:
- "extensions/diagnostics-otel/**"
"extensions: google-antigravity-auth":
- changed-files:
- any-glob-to-any-file:
- "extensions/google-antigravity-auth/**"
"extensions: google-gemini-cli-auth":
- changed-files:
- any-glob-to-any-file:
- "extensions/google-gemini-cli-auth/**"
"extensions: llm-task":
- changed-files:
- any-glob-to-any-file:
- "extensions/llm-task/**"
"extensions: lobster":
- changed-files:
- any-glob-to-any-file:
- "extensions/lobster/**"
"extensions: memory-core":
- changed-files:
- any-glob-to-any-file:
- "extensions/memory-core/**"
"extensions: memory-lancedb":
- changed-files:
- any-glob-to-any-file:
- "extensions/memory-lancedb/**"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:
- "extensions/open-prose/**"
"extensions: qwen-portal-auth":
- changed-files:
- any-glob-to-any-file:
- "extensions/qwen-portal-auth/**"

59
.github/workflows/auto-response.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: Auto response
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
permissions:
issues: write
pull-requests: write
jobs:
auto-response:
runs-on: ubuntu-latest
steps:
- name: Handle labeled items
uses: actions/github-script@v7
with:
script: |
const rules = [
{
label: "skill-clawdhub",
close: true,
message:
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. Were keeping the core lean on skills, so Im closing this out.",
},
];
const labelName = context.payload.label?.name;
if (!labelName) {
return;
}
const rule = rules.find((item) => item.label === labelName);
if (!rule) {
return;
}
const issueNumber = context.payload.issue?.number ?? context.payload.pull_request?.number;
if (!issueNumber) {
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: rule.message,
});
if (rule.close) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: "closed",
});
}

View File

@ -342,6 +342,8 @@ jobs:
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }} - name: Run ${{ matrix.task }}
env:
NODE_OPTIONS: --max-old-space-size=4096
run: ${{ matrix.command }} run: ${{ matrix.command }}
macos-app: macos-app:

23
.github/workflows/labeler.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: Labeler
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/labeler@v5
with:
configuration-path: .github/labeler.yml
repo-token: ${{ steps.app-token.outputs.token }}

View File

@ -13,6 +13,7 @@
- Core channel docs: `docs/channels/` - Core channel docs: `docs/channels/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing` - Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`) - Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
- When adding channels/extensions/apps/docs, review `.github/labeler.yml` for label coverage.
## Docs Linking (Mintlify) ## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.clawd.bot). - Docs are hosted on Mintlify (docs.clawd.bot).

View File

@ -6,7 +6,37 @@ Docs: https://docs.clawd.bot
Status: unreleased. Status: unreleased.
### Changes ### Changes
- TBD. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.
- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.
- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
### Fixes
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
## 2026.1.24-3 ## 2026.1.24-3
@ -41,7 +71,7 @@ Status: unreleased.
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal. - Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram - Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web - Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg. - UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg.
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands - Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. - Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags - Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags

View File

@ -479,31 +479,32 @@ Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></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/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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></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/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/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/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/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/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/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/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/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/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/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/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/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/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/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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/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/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/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/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/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/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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></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/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/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/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/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></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/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/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/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/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/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/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/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/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a>
<a href="https://github.com/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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/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/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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></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/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/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/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/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/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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></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/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/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/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></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/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></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/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/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/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/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=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a>
<a href="https://github.com/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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=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/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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/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/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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a>
<a href="https://github.com/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/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/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/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/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/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/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a>
<a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/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/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a>
<a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/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/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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a>
<a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
<a href="https://github.com/search?q=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/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></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/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></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/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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/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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></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=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/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a>
<a href="https://github.com/search?q=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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a>
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></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/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>

View File

@ -123,8 +123,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual", "location" : "https://github.com/gonzalezreal/textual",
"state" : { "state" : {
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3", "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
"version" : "0.2.0" "version" : "0.3.1"
} }
} }
], ],

View File

@ -413,10 +413,17 @@ final class AppState {
} }
private func updateRemoteTarget(host: String) { private func updateRemoteTarget(host: String) {
let parsed = CommandResolver.parseSSHTarget(self.remoteTarget) let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
let user = parsed?.user ?? NSUserName() guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return }
let port = parsed?.port ?? 22 let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
let assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)" let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
let port = parsed.port
let assembled: String
if let user {
assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
} else {
assembled = port == 22 ? host : "\(host):\(port)"
}
if assembled != self.remoteTarget { if assembled != self.remoteTarget {
self.remoteTarget = assembled self.remoteTarget = assembled
} }

View File

@ -15,7 +15,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"), .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"),
], ],
targets: [ targets: [
.target( .target(

View File

@ -788,6 +788,14 @@
{ {
"source": "/install/railway/", "source": "/install/railway/",
"destination": "/railway" "destination": "/railway"
},
{
"source": "/gcp",
"destination": "/platforms/gcp"
},
{
"source": "/gcp/",
"destination": "/platforms/gcp"
} }
], ],
"navigation": { "navigation": {
@ -827,6 +835,7 @@
"install/nix", "install/nix",
"install/docker", "install/docker",
"railway", "railway",
"render",
"install/bun" "install/bun"
] ]
}, },
@ -983,6 +992,7 @@
"bedrock", "bedrock",
"providers/moonshot", "providers/moonshot",
"providers/minimax", "providers/minimax",
"providers/vercel-ai-gateway",
"providers/openrouter", "providers/openrouter",
"providers/synthetic", "providers/synthetic",
"providers/opencode", "providers/opencode",
@ -1055,6 +1065,7 @@
"platforms/linux", "platforms/linux",
"platforms/fly", "platforms/fly",
"platforms/hetzner", "platforms/hetzner",
"platforms/gcp",
"platforms/exe-dev" "platforms/exe-dev"
] ]
}, },

View File

@ -2878,10 +2878,11 @@ Auth and Tailscale:
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback (`tailscale-user-login`) to satisfy auth when the request arrives on loopback
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. Clawdbot
`true`, Serve requests do not need a token/password; set `false` to require verifies the identity by resolving the `x-forwarded-for` address via
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and `tailscale whois` before accepting it. When `true`, Serve requests do not need
auth mode is not `password`. a token/password; set `false` to require explicit credentials. Defaults to
`true` when `tailscale.mode = "serve"` and auth mode is not `password`.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.

View File

@ -333,9 +333,11 @@ Rotation checklist (token/password):
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
accepts Tailscale Serve identity headers (`tailscale-user-login`) as accepts Tailscale Serve identity headers (`tailscale-user-login`) as
authentication. This only triggers for requests that hit loopback and include authentication. Clawdbot verifies the identity by resolving the
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by `x-forwarded-for` address through the local Tailscale daemon (`tailscale whois`)
Tailscale. and matching it to the header. This only triggers for requests that hit loopback
and include `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as
injected by Tailscale.
**Security rule:** do not forward these headers from your own reverse proxy. If **Security rule:** do not forward these headers from your own reverse proxy. If
you terminate TLS or proxy in front of the gateway, disable you terminate TLS or proxy in front of the gateway, disable

View File

@ -25,9 +25,12 @@ Set `gateway.auth.mode` to control the handshake:
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`, When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
valid Serve proxy requests can authenticate via Tailscale identity headers valid Serve proxy requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) without supplying a token/password. Clawdbot only (`tailscale-user-login`) without supplying a token/password. Clawdbot verifies
treats a request as Serve when it arrives from loopback with Tailscales the identity by resolving the `x-forwarded-for` address via the local Tailscale
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers. daemon (`tailscale whois`) and matching it to the header before accepting it.
Clawdbot only treats a request as Serve when it arrives from loopback with
Tailscales `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`
headers.
To require explicit credentials, set `gateway.auth.allowTailscale: false` or To require explicit credentials, set `gateway.auth.allowTailscale: false` or
force `gateway.auth.mode: "password"`. force `gateway.auth.mode: "password"`.

View File

@ -0,0 +1,239 @@
---
summary: "Clawdbot on DigitalOcean (cheapest paid VPS option)"
read_when:
- Setting up Clawdbot on DigitalOcean
- Looking for cheap VPS hosting for Clawdbot
---
# Clawdbot on DigitalOcean
## Goal
Run a persistent Clawdbot Gateway on DigitalOcean for **$6/month** (or $4/mo with reserved pricing).
If you want something even cheaper, see [Oracle Cloud (Free Tier)](#oracle-cloud-free-alternative) at the bottom — it's **actually free forever**.
## Cost Comparison (2026)
| Provider | Plan | Specs | Price/mo | Notes |
|----------|------|-------|----------|-------|
| **Oracle Cloud** | Always Free ARM | 4 OCPU, 24GB RAM | **$0** | Best value, requires ARM-compatible setup |
| **Hetzner** | CX22 | 2 vCPU, 4GB RAM | €3.79 (~$4) | Cheapest paid, EU datacenters |
| **DigitalOcean** | Basic | 1 vCPU, 1GB RAM | $6 | Easy UI, good docs |
| **Vultr** | Cloud Compute | 1 vCPU, 1GB RAM | $6 | Many locations |
| **Linode** | Nanode | 1 vCPU, 1GB RAM | $5 | Now part of Akamai |
**Recommendation:**
- **Free:** Oracle Cloud ARM (if you can handle the signup process)
- **Paid:** Hetzner CX22 (best specs per dollar) — see [Hetzner guide](/platforms/hetzner)
- **Easy:** DigitalOcean (this guide) — beginner-friendly UI
---
## Prerequisites
- DigitalOcean account ([signup with $200 free credit](https://m.do.co/c/signup))
- SSH key pair (or willingness to use password auth)
- ~20 minutes
## 1) Create a Droplet
1. Log into [DigitalOcean](https://cloud.digitalocean.com/)
2. Click **Create → Droplets**
3. Choose:
- **Region:** Closest to you (or your users)
- **Image:** Ubuntu 24.04 LTS
- **Size:** Basic → Regular → **$6/mo** (1 vCPU, 1GB RAM, 25GB SSD)
- **Authentication:** SSH key (recommended) or password
4. Click **Create Droplet**
5. Note the IP address
## 2) Connect via SSH
```bash
ssh root@YOUR_DROPLET_IP
```
## 3) Install Clawdbot
```bash
# Update system
apt update && apt upgrade -y
# Install Node.js 22
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
# Install Clawdbot
curl -fsSL https://clawd.bot/install.sh | bash
# Verify
clawdbot --version
```
## 4) Run Onboarding
```bash
clawdbot onboard --install-daemon
```
The wizard will walk you through:
- Model auth (API keys or OAuth)
- Channel setup (Telegram, WhatsApp, Discord, etc.)
- Gateway token (auto-generated)
- Daemon installation (systemd)
## 5) Verify the Gateway
```bash
# Check status
clawdbot status
# Check service
systemctl status clawdbot
# View logs
journalctl -u clawdbot -f
```
## 6) Access the Dashboard
The gateway binds to loopback by default. To access the Control UI:
**Option A: SSH Tunnel (recommended)**
```bash
# From your local machine
ssh -L 18789:localhost:18789 root@YOUR_DROPLET_IP
# Then open: http://localhost:18789
```
**Option B: Tailscale (easier long-term)**
```bash
# On the droplet
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
# Configure gateway to bind to Tailscale
clawdbot config set gateway.bind tailnet
clawdbot gateway restart
```
Then access via your Tailscale IP: `http://100.x.x.x:18789`
## 7) Connect Your Channels
### Telegram
```bash
clawdbot pairing list telegram
clawdbot pairing approve telegram <CODE>
```
### WhatsApp
```bash
clawdbot channels login whatsapp
# Scan QR code
```
See [Channels](/channels) for other providers.
---
## Optimizations for 1GB RAM
The $6 droplet only has 1GB RAM. To keep things running smoothly:
### Add swap (recommended)
```bash
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
```
### Use a lighter model
If you're hitting OOMs, consider:
- Using API-based models (Claude, GPT) instead of local models
- Setting `agents.defaults.model.primary` to a smaller model
### Monitor memory
```bash
free -h
htop
```
---
## Persistence
All state lives in:
- `~/.clawdbot/` — config, credentials, session data
- `~/clawd/` — workspace (SOUL.md, memory, etc.)
These survive reboots. Back them up periodically:
```bash
tar -czvf clawdbot-backup.tar.gz ~/.clawdbot ~/clawd
```
---
## Oracle Cloud Free Alternative
Oracle Cloud offers **Always Free** ARM instances that are significantly more powerful:
| What you get | Specs |
|--------------|-------|
| **4 OCPUs** | ARM Ampere A1 |
| **24GB RAM** | More than enough |
| **200GB storage** | Block volume |
| **Forever free** | No credit card charges |
### Quick setup:
1. Sign up at [oracle.com/cloud/free](https://www.oracle.com/cloud/free/)
2. Create a VM.Standard.A1.Flex instance (ARM)
3. Choose Oracle Linux or Ubuntu
4. Allocate up to 4 OCPU / 24GB RAM within free tier
5. Follow the same Clawdbot install steps above
**Caveats:**
- Signup can be finicky (retry if it fails)
- ARM architecture — most things work, but some binaries need ARM builds
- Oracle may reclaim idle instances (keep them active)
For the full Oracle guide, see the [community docs](https://gist.github.com/rssnyder/51e3cfedd730e7dd5f4a816143b25dbd).
---
## Troubleshooting
### Gateway won't start
```bash
clawdbot gateway status
clawdbot doctor --non-interactive
journalctl -u clawdbot --no-pager -n 50
```
### Port already in use
```bash
lsof -i :18789
kill <PID>
```
### Out of memory
```bash
# Check memory
free -h
# Add more swap
# Or upgrade to $12/mo droplet (2GB RAM)
```
---
## See Also
- [Hetzner guide](/platforms/hetzner) — cheaper, more powerful
- [Docker install](/install/docker) — containerized setup
- [Tailscale](/gateway/tailscale) — secure remote access
- [Configuration](/gateway/configuration) — full config reference

498
docs/platforms/gcp.md Normal file
View File

@ -0,0 +1,498 @@
---
summary: "Run Clawdbot Gateway 24/7 on a GCP Compute Engine VM (Docker) with durable state"
read_when:
- You want Clawdbot running 24/7 on GCP
- You want a production-grade, always-on Gateway on your own VM
- You want full control over persistence, binaries, and restart behavior
---
# Clawdbot on GCP Compute Engine (Docker, Production VPS Guide)
## Goal
Run a persistent Clawdbot Gateway on a GCP Compute Engine VM using Docker, with durable state, baked-in binaries, and safe restart behavior.
If you want "Clawdbot 24/7 for ~$5-12/mo", this is a reliable setup on Google Cloud.
Pricing varies by machine type and region; pick the smallest VM that fits your workload and scale up if you hit OOMs.
## What are we doing (simple terms)?
- Create a GCP project and enable billing
- Create a Compute Engine VM
- Install Docker (isolated app runtime)
- Start the Clawdbot Gateway in Docker
- Persist `~/.clawdbot` + `~/clawd` on the host (survives restarts/rebuilds)
- Access the Control UI from your laptop via an SSH tunnel
The Gateway can be accessed via:
- SSH port forwarding from your laptop
- Direct port exposure if you manage firewalling and tokens yourself
This guide uses Debian on GCP Compute Engine.
Ubuntu also works; map packages accordingly.
For the generic Docker flow, see [Docker](/install/docker).
---
## Quick path (experienced operators)
1) Create GCP project + enable Compute Engine API
2) Create Compute Engine VM (e2-small, Debian 12, 20GB)
3) SSH into the VM
4) Install Docker
5) Clone Clawdbot repository
6) Create persistent host directories
7) Configure `.env` and `docker-compose.yml`
8) Bake required binaries, build, and launch
---
## What you need
- GCP account (free tier eligible for e2-micro)
- gcloud CLI installed (or use Cloud Console)
- SSH access from your laptop
- Basic comfort with SSH + copy/paste
- ~20-30 minutes
- Docker and Docker Compose
- Model auth credentials
- Optional provider credentials
- WhatsApp QR
- Telegram bot token
- Gmail OAuth
---
## 1) Install gcloud CLI (or use Console)
**Option A: gcloud CLI** (recommended for automation)
Install from https://cloud.google.com/sdk/docs/install
Initialize and authenticate:
```bash
gcloud init
gcloud auth login
```
**Option B: Cloud Console**
All steps can be done via the web UI at https://console.cloud.google.com
---
## 2) Create a GCP project
**CLI:**
```bash
gcloud projects create my-clawdbot-project --name="Clawdbot Gateway"
gcloud config set project my-clawdbot-project
```
Enable billing at https://console.cloud.google.com/billing (required for Compute Engine).
Enable the Compute Engine API:
```bash
gcloud services enable compute.googleapis.com
```
**Console:**
1. Go to IAM & Admin > Create Project
2. Name it and create
3. Enable billing for the project
4. Navigate to APIs & Services > Enable APIs > search "Compute Engine API" > Enable
---
## 3) Create the VM
**Machine types:**
| Type | Specs | Cost | Notes |
|------|-------|------|-------|
| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Recommended |
| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | May OOM under load |
**CLI:**
```bash
gcloud compute instances create clawdbot-gateway \
--zone=us-central1-a \
--machine-type=e2-small \
--boot-disk-size=20GB \
--image-family=debian-12 \
--image-project=debian-cloud
```
**Console:**
1. Go to Compute Engine > VM instances > Create instance
2. Name: `clawdbot-gateway`
3. Region: `us-central1`, Zone: `us-central1-a`
4. Machine type: `e2-small`
5. Boot disk: Debian 12, 20GB
6. Create
---
## 4) SSH into the VM
**CLI:**
```bash
gcloud compute ssh clawdbot-gateway --zone=us-central1-a
```
**Console:**
Click the "SSH" button next to your VM in the Compute Engine dashboard.
Note: SSH key propagation can take 1-2 minutes after VM creation. If connection is refused, wait and retry.
---
## 5) Install Docker (on the VM)
```bash
sudo apt-get update
sudo apt-get install -y git curl ca-certificates
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
```
Log out and back in for the group change to take effect:
```bash
exit
```
Then SSH back in:
```bash
gcloud compute ssh clawdbot-gateway --zone=us-central1-a
```
Verify:
```bash
docker --version
docker compose version
```
---
## 6) Clone the Clawdbot repository
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
```
This guide assumes you will build a custom image to guarantee binary persistence.
---
## 7) Create persistent host directories
Docker containers are ephemeral.
All long-lived state must live on the host.
```bash
mkdir -p ~/.clawdbot
mkdir -p ~/clawd
```
---
## 8) Configure environment variables
Create `.env` in the repository root.
```bash
CLAWDBOT_IMAGE=clawdbot:latest
CLAWDBOT_GATEWAY_TOKEN=change-me-now
CLAWDBOT_GATEWAY_BIND=lan
CLAWDBOT_GATEWAY_PORT=18789
CLAWDBOT_CONFIG_DIR=/home/$USER/.clawdbot
CLAWDBOT_WORKSPACE_DIR=/home/$USER/clawd
GOG_KEYRING_PASSWORD=change-me-now
XDG_CONFIG_HOME=/home/node/.clawdbot
```
Generate strong secrets:
```bash
openssl rand -hex 32
```
**Do not commit this file.**
---
## 9) Docker Compose configuration
Create or update `docker-compose.yml`.
```yaml
services:
clawdbot-gateway:
image: ${CLAWDBOT_IMAGE}
build: .
restart: unless-stopped
env_file:
- .env
environment:
- HOME=/home/node
- NODE_ENV=production
- TERM=xterm-256color
- CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
- CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
- GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
- XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
- PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
ports:
# Recommended: keep the Gateway loopback-only on the VM; access via SSH tunnel.
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789"
# Optional: only if you run iOS/Android nodes against this VM and need Canvas host.
# If you expose this publicly, read /gateway/security and firewall accordingly.
# - "18793:18793"
command:
[
"node",
"dist/index.js",
"gateway",
"--bind",
"${CLAWDBOT_GATEWAY_BIND}",
"--port",
"${CLAWDBOT_GATEWAY_PORT}"
]
```
---
## 10) Bake required binaries into the image (critical)
Installing binaries inside a running container is a trap.
Anything installed at runtime will be lost on restart.
All external binaries required by skills must be installed at image build time.
The examples below show three common binaries only:
- `gog` for Gmail access
- `goplaces` for Google Places
- `wacli` for WhatsApp
These are examples, not a complete list.
You may install as many binaries as needed using the same pattern.
If you add new skills later that depend on additional binaries, you must:
1. Update the Dockerfile
2. Rebuild the image
3. Restart the containers
**Example Dockerfile**
```dockerfile
FROM node:22-bookworm
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
# Example binary 1: Gmail CLI
RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog
# Example binary 2: Google Places CLI
RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces
# Example binary 3: WhatsApp CLI
RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli
# Add more binaries below using the same pattern
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY scripts ./scripts
RUN corepack enable
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
ENV NODE_ENV=production
CMD ["node","dist/index.js"]
```
---
## 11) Build and launch
```bash
docker compose build
docker compose up -d clawdbot-gateway
```
Verify binaries:
```bash
docker compose exec clawdbot-gateway which gog
docker compose exec clawdbot-gateway which goplaces
docker compose exec clawdbot-gateway which wacli
```
Expected output:
```
/usr/local/bin/gog
/usr/local/bin/goplaces
/usr/local/bin/wacli
```
---
## 12) Verify Gateway
```bash
docker compose logs -f clawdbot-gateway
```
Success:
```
[gateway] listening on ws://0.0.0.0:18789
```
---
## 13) Access from your laptop
Create an SSH tunnel to forward the Gateway port:
```bash
gcloud compute ssh clawdbot-gateway --zone=us-central1-a -- -L 18789:127.0.0.1:18789
```
Open in your browser:
`http://127.0.0.1:18789/`
Paste your gateway token.
---
## What persists where (source of truth)
Clawdbot runs in Docker, but Docker is not the source of truth.
All long-lived state must survive restarts, rebuilds, and reboots.
| Component | Location | Persistence mechanism | Notes |
|---|---|---|---|
| Gateway config | `/home/node/.clawdbot/` | Host volume mount | Includes `clawdbot.json`, tokens |
| Model auth profiles | `/home/node/.clawdbot/` | Host volume mount | OAuth tokens, API keys |
| Skill configs | `/home/node/.clawdbot/skills/` | Host volume mount | Skill-level state |
| Agent workspace | `/home/node/clawd/` | Host volume mount | Code and agent artifacts |
| WhatsApp session | `/home/node/.clawdbot/` | Host volume mount | Preserves QR login |
| Gmail keyring | `/home/node/.clawdbot/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
| OS packages | Container filesystem | Docker image | Do not install at runtime |
| Docker container | Ephemeral | Restartable | Safe to destroy |
---
## Updates
To update Clawdbot on the VM:
```bash
cd ~/clawdbot
git pull
docker compose build
docker compose up -d
```
---
## Troubleshooting
**SSH connection refused**
SSH key propagation can take 1-2 minutes after VM creation. Wait and retry.
**OS Login issues**
Check your OS Login profile:
```bash
gcloud compute os-login describe-profile
```
Ensure your account has the required IAM permissions (Compute OS Login or Compute OS Admin Login).
**Out of memory (OOM)**
If using e2-micro and hitting OOM, upgrade to e2-small or e2-medium:
```bash
# Stop the VM first
gcloud compute instances stop clawdbot-gateway --zone=us-central1-a
# Change machine type
gcloud compute instances set-machine-type clawdbot-gateway \
--zone=us-central1-a \
--machine-type=e2-small
# Start the VM
gcloud compute instances start clawdbot-gateway --zone=us-central1-a
```
---
## Service accounts (security best practice)
For personal use, your default user account works fine.
For automation or CI/CD pipelines, create a dedicated service account with minimal permissions:
1. Create a service account:
```bash
gcloud iam service-accounts create clawdbot-deploy \
--display-name="Clawdbot Deployment"
```
2. Grant Compute Instance Admin role (or narrower custom role):
```bash
gcloud projects add-iam-policy-binding my-clawdbot-project \
--member="serviceAccount:clawdbot-deploy@my-clawdbot-project.iam.gserviceaccount.com" \
--role="roles/compute.instanceAdmin.v1"
```
Avoid using the Owner role for automation. Use the principle of least privilege.
See https://cloud.google.com/iam/docs/understanding-roles for IAM role details.
---
## Next steps
- Set up messaging channels: [Channels](/channels)
- Pair local devices as nodes: [Nodes](/nodes)
- Configure the Gateway: [Gateway configuration](/gateway/configuration)

View File

@ -27,6 +27,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
- Railway (one-click): [Railway](/railway) - Railway (one-click): [Railway](/railway)
- Fly.io: [Fly.io](/platforms/fly) - Fly.io: [Fly.io](/platforms/fly)
- Hetzner (Docker): [Hetzner](/platforms/hetzner) - Hetzner (Docker): [Hetzner](/platforms/hetzner)
- GCP (Compute Engine): [GCP](/platforms/gcp)
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev) - exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
## Common links ## Common links

View File

@ -0,0 +1,354 @@
---
summary: "Clawdbot on Raspberry Pi (budget self-hosted setup)"
read_when:
- Setting up Clawdbot on a Raspberry Pi
- Running Clawdbot on ARM devices
- Building a cheap always-on personal AI
---
# Clawdbot on Raspberry Pi
## Goal
Run a persistent, always-on Clawdbot Gateway on a Raspberry Pi for **~$35-80** one-time cost (no monthly fees).
Perfect for:
- 24/7 personal AI assistant
- Home automation hub
- Low-power, always-available Telegram/WhatsApp bot
## Hardware Requirements
| Pi Model | RAM | Works? | Notes |
|----------|-----|--------|-------|
| **Pi 5** | 4GB/8GB | ✅ Best | Fastest, recommended |
| **Pi 4** | 4GB | ✅ Good | Sweet spot for most users |
| **Pi 4** | 2GB | ✅ OK | Works, add swap |
| **Pi 4** | 1GB | ⚠️ Tight | Possible with swap, minimal config |
| **Pi 3B+** | 1GB | ⚠️ Slow | Works but sluggish |
| **Pi Zero 2 W** | 512MB | ❌ | Not recommended |
**Minimum specs:** 1GB RAM, 1 core, 500MB disk
**Recommended:** 2GB+ RAM, 64-bit OS, 16GB+ SD card (or USB SSD)
## What You'll Need
- Raspberry Pi 4 or 5 (2GB+ recommended)
- MicroSD card (16GB+) or USB SSD (better performance)
- Power supply (official Pi PSU recommended)
- Network connection (Ethernet or WiFi)
- ~30 minutes
## 1) Flash the OS
Use **Raspberry Pi OS Lite (64-bit)** — no desktop needed for a headless server.
1. Download [Raspberry Pi Imager](https://www.raspberrypi.com/software/)
2. Choose OS: **Raspberry Pi OS Lite (64-bit)**
3. Click the gear icon (⚙️) to pre-configure:
- Set hostname: `gateway-host`
- Enable SSH
- Set username/password
- Configure WiFi (if not using Ethernet)
4. Flash to your SD card / USB drive
5. Insert and boot the Pi
## 2) Connect via SSH
```bash
ssh user@gateway-host
# or use the IP address
ssh user@192.168.x.x
```
## 3) System Setup
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install essential packages
sudo apt install -y git curl build-essential
# Set timezone (important for cron/reminders)
sudo timedatectl set-timezone America/Chicago # Change to your timezone
```
## 4) Install Node.js 22 (ARM64)
```bash
# Install Node.js via NodeSource
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Verify
node --version # Should show v22.x.x
npm --version
```
## 5) Add Swap (Important for 2GB or less)
Swap prevents out-of-memory crashes:
```bash
# Create 2GB swap file
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# Make permanent
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# Optimize for low RAM (reduce swappiness)
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
```
## 6) Install Clawdbot
### Option A: Standard Install (Recommended)
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
### Option B: Hackable Install (For tinkering)
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
npm install
npm run build
npm link
```
The hackable install gives you direct access to logs and code — useful for debugging ARM-specific issues.
## 7) Run Onboarding
```bash
clawdbot onboard --install-daemon
```
Follow the wizard:
1. **Gateway mode:** Local
2. **Auth:** API keys recommended (OAuth can be finicky on headless Pi)
3. **Channels:** Telegram is easiest to start with
4. **Daemon:** Yes (systemd)
## 8) Verify Installation
```bash
# Check status
clawdbot status
# Check service
sudo systemctl status clawdbot
# View logs
journalctl -u clawdbot -f
```
## 9) Access the Dashboard
Since the Pi is headless, use an SSH tunnel:
```bash
# From your laptop/desktop
ssh -L 18789:localhost:18789 user@gateway-host
# Then open in browser
open http://localhost:18789
```
Or use Tailscale for always-on access:
```bash
# On the Pi
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Update config
clawdbot config set gateway.bind tailnet
sudo systemctl restart clawdbot
```
---
## Performance Optimizations
### Use a USB SSD (Huge Improvement)
SD cards are slow and wear out. A USB SSD dramatically improves performance:
```bash
# Check if booting from USB
lsblk
```
See [Pi USB boot guide](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#usb-mass-storage-boot) for setup.
### Reduce Memory Usage
```bash
# Disable GPU memory allocation (headless)
echo 'gpu_mem=16' | sudo tee -a /boot/config.txt
# Disable Bluetooth if not needed
sudo systemctl disable bluetooth
```
### Monitor Resources
```bash
# Check memory
free -h
# Check CPU temperature
vcgencmd measure_temp
# Live monitoring
htop
```
---
## ARM-Specific Notes
### Binary Compatibility
Most Clawdbot features work on ARM64, but some external binaries may need ARM builds:
| Tool | ARM64 Status | Notes |
|------|--------------|-------|
| Node.js | ✅ | Works great |
| WhatsApp (Baileys) | ✅ | Pure JS, no issues |
| Telegram | ✅ | Pure JS, no issues |
| gog (Gmail CLI) | ⚠️ | Check for ARM release |
| Chromium (browser) | ✅ | `sudo apt install chromium-browser` |
If a skill fails, check if its binary has an ARM build. Many Go/Rust tools do; some don't.
### 32-bit vs 64-bit
**Always use 64-bit OS.** Node.js and many modern tools require it. Check with:
```bash
uname -m
# Should show: aarch64 (64-bit) not armv7l (32-bit)
```
---
## Recommended Model Setup
Since the Pi is just the Gateway (models run in the cloud), use API-based models:
```json
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-sonnet-4-20250514",
"fallbacks": ["openai/gpt-4o-mini"]
}
}
}
}
```
**Don't try to run local LLMs on a Pi** — even small models are too slow. Let Claude/GPT do the heavy lifting.
---
## Auto-Start on Boot
The onboarding wizard sets this up, but to verify:
```bash
# Check service is enabled
sudo systemctl is-enabled clawdbot
# Enable if not
sudo systemctl enable clawdbot
# Start on boot
sudo systemctl start clawdbot
```
---
## Troubleshooting
### Out of Memory (OOM)
```bash
# Check memory
free -h
# Add more swap (see Step 5)
# Or reduce services running on the Pi
```
### Slow Performance
- Use USB SSD instead of SD card
- Disable unused services: `sudo systemctl disable cups bluetooth avahi-daemon`
- Check CPU throttling: `vcgencmd get_throttled` (should return `0x0`)
### Service Won't Start
```bash
# Check logs
journalctl -u clawdbot --no-pager -n 100
# Common fix: rebuild
cd ~/clawdbot # if using hackable install
npm run build
sudo systemctl restart clawdbot
```
### ARM Binary Issues
If a skill fails with "exec format error":
1. Check if the binary has an ARM64 build
2. Try building from source
3. Or use a Docker container with ARM support
### WiFi Drops
For headless Pis on WiFi:
```bash
# Disable WiFi power management
sudo iwconfig wlan0 power off
# Make permanent
echo 'wireless-power off' | sudo tee -a /etc/network/interfaces
```
---
## Cost Comparison
| Setup | One-Time Cost | Monthly Cost | Notes |
|-------|---------------|--------------|-------|
| **Pi 4 (2GB)** | ~$45 | $0 | + power (~$5/yr) |
| **Pi 4 (4GB)** | ~$55 | $0 | Recommended |
| **Pi 5 (4GB)** | ~$60 | $0 | Best performance |
| **Pi 5 (8GB)** | ~$80 | $0 | Overkill but future-proof |
| DigitalOcean | $0 | $6/mo | $72/year |
| Hetzner | $0 | €3.79/mo | ~$50/year |
**Break-even:** A Pi pays for itself in ~6-12 months vs cloud VPS.
---
## See Also
- [Linux guide](/platforms/linux) — general Linux setup
- [DigitalOcean guide](/platforms/digitalocean) — cloud alternative
- [Hetzner guide](/platforms/hetzner) — Docker setup
- [Tailscale](/gateway/tailscale) — remote access
- [Nodes](/nodes) — pair your laptop/phone with the Pi gateway

View File

@ -0,0 +1,145 @@
---
summary: "Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint"
read_when:
- You want to use Claude Max subscription with OpenAI-compatible tools
- You want a local API server that wraps Claude Code CLI
- You want to save money by using subscription instead of API keys
---
# Claude Max API Proxy
**claude-max-api-proxy** is a community tool that exposes your Claude Max/Pro subscription as an OpenAI-compatible API endpoint. This allows you to use your subscription with any tool that supports the OpenAI API format.
## Why Use This?
| Approach | Cost | Best For |
|----------|------|----------|
| Anthropic API | Pay per token (~$15/M input, $75/M output for Opus) | Production apps, high volume |
| Claude Max subscription | $200/month flat | Personal use, development, unlimited usage |
If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy can save you significant money.
## How It Works
```
Your App → claude-max-api-proxy → Claude Code CLI → Anthropic (via subscription)
(OpenAI format) (converts format) (uses your login)
```
The proxy:
1. Accepts OpenAI-format requests at `http://localhost:3456/v1/chat/completions`
2. Converts them to Claude Code CLI commands
3. Returns responses in OpenAI format (streaming supported)
## Installation
```bash
# Requires Node.js 20+ and Claude Code CLI
npm install -g claude-max-api-proxy
# Verify Claude CLI is authenticated
claude --version
```
## Usage
### Start the server
```bash
claude-max-api
# Server runs at http://localhost:3456
```
### Test it
```bash
# Health check
curl http://localhost:3456/health
# List models
curl http://localhost:3456/v1/models
# Chat completion
curl http://localhost:3456/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-4",
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
### With Clawdbot
You can point Clawdbot at the proxy as a custom OpenAI-compatible endpoint:
```json5
{
env: {
OPENAI_API_KEY: "not-needed",
OPENAI_BASE_URL: "http://localhost:3456/v1"
},
agents: {
defaults: {
model: { primary: "openai/claude-opus-4" }
}
}
}
```
## Available Models
| Model ID | Maps To |
|----------|---------|
| `claude-opus-4` | Claude Opus 4 |
| `claude-sonnet-4` | Claude Sonnet 4 |
| `claude-haiku-4` | Claude Haiku 4 |
## Auto-Start on macOS
Create a LaunchAgent to run the proxy automatically:
```bash
cat > ~/Library/LaunchAgents/com.claude-max-api.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.claude-max-api</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>/usr/local/lib/node_modules/claude-max-api-proxy/dist/server/standalone.js</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:~/.local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
EOF
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-max-api.plist
```
## Links
- **npm:** https://www.npmjs.com/package/claude-max-api-proxy
- **GitHub:** https://github.com/atalovesyou/claude-max-api-proxy
- **Issues:** https://github.com/atalovesyou/claude-max-api-proxy/issues
## Notes
- This is a **community tool**, not officially supported by Anthropic or Clawdbot
- Requires an active Claude Max/Pro subscription with Claude Code CLI authenticated
- The proxy runs locally and does not send data to any third-party servers
- Streaming responses are fully supported
## See Also
- [Anthropic provider](/providers/anthropic) - Native Clawdbot integration with Claude Code CLI OAuth
- [OpenAI provider](/providers/openai) - For OpenAI/Codex subscriptions

View File

@ -51,5 +51,9 @@ See [Venice AI](/providers/venice).
- [Deepgram (audio transcription)](/providers/deepgram) - [Deepgram (audio transcription)](/providers/deepgram)
## Community tools
- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration, For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers). see [Model providers](/concepts/model-providers).

View File

@ -1,4 +1,5 @@
--- ---
title: "Vercel AI Gateway"
summary: "Vercel AI Gateway setup (auth + model selection)" summary: "Vercel AI Gateway setup (auth + model selection)"
read_when: read_when:
- You want to use Vercel AI Gateway with Clawdbot - You want to use Vercel AI Gateway with Clawdbot

158
docs/render.mdx Normal file
View File

@ -0,0 +1,158 @@
---
title: Deploy on Render
---
Deploy Clawdbot on Render using Infrastructure as Code. The included `render.yaml` Blueprint defines your entire stack declaratively, service, disk, environment variables, so you can deploy with a single click and version your infrastructure alongside your code.
## Prerequisites
- A [Render account](https://render.com) (free tier available)
- An API key from your preferred [model provider](/providers)
## Deploy with a Render Blueprint
<a href="https://render.com/deploy?repo=https://github.com/clawdbot/clawdbot" target="_blank" rel="noreferrer">Deploy to Render</a>
Clicking this link will:
1. Create a new Render service from the `render.yaml` Blueprint at the root of this repo.
2. Prompt you to set `SETUP_PASSWORD`
3. Build the Docker image and deploy
Once deployed, your service URL follows the pattern `https://<service-name>.onrender.com`.
## Understanding the Blueprint
Render Blueprints are YAML files that define your infrastructure. The `render.yaml` in this
repository configures everything needed to run Clawdbot:
```yaml
services:
- type: web
name: clawdbot
runtime: docker
plan: starter
healthCheckPath: /health
envVars:
- key: PORT
value: "8080"
- key: SETUP_PASSWORD
sync: false # prompts during deploy
- key: CLAWDBOT_STATE_DIR
value: /data/.clawdbot
- key: CLAWDBOT_WORKSPACE_DIR
value: /data/workspace
- key: CLAWDBOT_GATEWAY_TOKEN
generateValue: true # auto-generates a secure token
disk:
name: clawdbot-data
mountPath: /data
sizeGB: 1
```
Key Blueprint features used:
| Feature | Purpose |
|---------|---------|
| `runtime: docker` | Builds from the repo's Dockerfile |
| `healthCheckPath` | Render monitors `/health` and restarts unhealthy instances |
| `sync: false` | Prompts for value during deploy (secrets) |
| `generateValue: true` | Auto-generates a cryptographically secure value |
| `disk` | Persistent storage that survives redeploys |
## Choosing a plan
| Plan | Spin-down | Disk | Best for |
|------|-----------|------|----------|
| Free | After 15 min idle | Not available | Testing, demos |
| Starter | Never | 1GB+ | Personal use, small teams |
| Standard+ | Never | 1GB+ | Production, multiple channels |
The Blueprint defaults to `starter`. To use free tier, change `plan: free` in your fork's
`render.yaml` (but note: no persistent disk means config resets on each deploy).
## After deployment
### Complete the setup wizard
1. Navigate to `https://<your-service>.onrender.com/setup`
2. Enter your `SETUP_PASSWORD`
3. Select a model provider and paste your API key
4. Optionally configure messaging channels (Telegram, Discord, Slack)
5. Click **Run setup**
### Access the Control UI
The web dashboard is available at `https://<your-service>.onrender.com/clawdbot`.
## Render Dashboard features
### Logs
View real-time logs in **Dashboard → your service → Logs**. Filter by:
- Build logs (Docker image creation)
- Deploy logs (service startup)
- Runtime logs (application output)
### Shell access
For debugging, open a shell session via **Dashboard → your service → Shell**. The persistent disk is mounted at `/data`.
### Environment variables
Modify variables in **Dashboard → your service → Environment**. Changes trigger an automatic redeploy.
### Auto-deploy
If you use the original Clawdbot repository, Render will not auto-deploy your Clawdbot. To update it, run a manual Blueprint sync from the dashboard.
## Custom domain
1. Go to **Dashboard → your service → Settings → Custom Domains**
2. Add your domain
3. Configure DNS as instructed (CNAME to `*.onrender.com`)
4. Render provisions a TLS certificate automatically
## Scaling
Render supports horizontal and vertical scaling:
- **Vertical**: Change the plan to get more CPU/RAM
- **Horizontal**: Increase instance count (Standard plan and above)
For Clawdbot, vertical scaling is usually sufficient. Horizontal scaling requires sticky sessions or external state management.
## Backups and migration
Export your configuration and workspace at any time:
```
https://<your-service>.onrender.com/setup/export
```
This downloads a portable backup you can restore on any Clawdbot host.
## Troubleshooting
### Service won't start
Check the deploy logs in the Render Dashboard. Common issues:
- Missing `SETUP_PASSWORD` — the Blueprint prompts for this, but verify it's set
- Port mismatch — ensure `PORT=8080` matches the Dockerfile's exposed port
### Slow cold starts (free tier)
Free tier services spin down after 15 minutes of inactivity. The first request after spin-down takes a few seconds while the container starts. Upgrade to Starter plan for always-on.
### Data loss after redeploy
This happens on free tier (no persistent disk). Upgrade to a paid plan, or
regularly export your config via `/setup/export`.
### Health check failures
Render expects a 200 response from `/health` within 30 seconds. If builds succeed but deploys fail, the service may be taking too long to start. Check:
- Build logs for errors
- Whether the container runs locally with `docker build && docker run`

View File

@ -14,6 +14,7 @@ deployments work at a high level.
- **Railway** (oneclick + browser setup): [Railway](/railway) - **Railway** (oneclick + browser setup): [Railway](/railway)
- **Fly.io**: [Fly.io](/platforms/fly) - **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner) - **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
- **GCP (Compute Engine)**: [GCP](/platforms/gcp)
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev) - **exe.dev** (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
- **AWS (EC2/Lightsail/free tier)**: works well too. Video guide: - **AWS (EC2/Lightsail/free tier)**: works well too. Video guide:
https://x.com/techfrenAJ/status/2014934471095812547 https://x.com/techfrenAJ/status/2014934471095812547

View File

@ -70,10 +70,11 @@ Open:
By default, Serve requests can authenticate via Tailscale identity headers By default, Serve requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
only accepts these when the request hits loopback with Tailscales verifies the identity by resolving the `x-forwarded-for` address with
`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force `tailscale whois` and matching it to the header, and only accepts these when the
`gateway.auth.mode: "password"`) if you want to require a token/password even request hits loopback with Tailscales `x-forwarded-*` headers. Set
for Serve traffic. `gateway.auth.allowTailscale: false` (or force `gateway.auth.mode: "password"`)
if you want to require a token/password even for Serve traffic.
### Bind to tailnet + token ### Bind to tailnet + token

View File

@ -281,6 +281,7 @@ async function loginAntigravity(params: {
openUrl: (url: string) => Promise<void>; openUrl: (url: string) => Promise<void>;
prompt: (message: string) => Promise<string>; prompt: (message: string) => Promise<string>;
note: (message: string, title?: string) => Promise<void>; note: (message: string, title?: string) => Promise<void>;
log: (message: string) => void;
progress: { update: (msg: string) => void; stop: (msg?: string) => void }; progress: { update: (msg: string) => void; stop: (msg?: string) => void };
}): Promise<{ }): Promise<{
access: string; access: string;
@ -314,6 +315,11 @@ async function loginAntigravity(params: {
].join("\n"), ].join("\n"),
"Google Antigravity OAuth", "Google Antigravity OAuth",
); );
// Output raw URL below the box for easy copying (fixes #1772)
params.log("");
params.log("Copy this URL:");
params.log(authUrl);
params.log("");
} }
if (!needsManual) { if (!needsManual) {
@ -382,6 +388,7 @@ const antigravityPlugin = {
openUrl: ctx.openUrl, openUrl: ctx.openUrl,
prompt: async (message) => String(await ctx.prompter.text({ message })), prompt: async (message) => String(await ctx.prompter.text({ message })),
note: ctx.prompter.note, note: ctx.prompter.note,
log: (message) => ctx.runtime.log(message),
progress: spin, progress: spin,
}); });

View File

@ -63,16 +63,28 @@ export async function sendGroupMessage({
const story = [{ inline: [text] }]; const story = [{ inline: [text] }];
const sentAt = Date.now(); const sentAt = Date.now();
// Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies
let formattedReplyId = replyToId;
if (replyToId && /^\d+$/.test(replyToId)) {
try {
formattedReplyId = formatUd(BigInt(replyToId));
} catch {
// Fall back to raw ID if formatting fails
}
}
const action = { const action = {
channel: { channel: {
nest: `chat/${hostShip}/${channelName}`, nest: `chat/${hostShip}/${channelName}`,
action: replyToId action: formattedReplyId
? { ? {
reply: { // Thread reply - needs post wrapper around reply action
id: replyToId, // ReplyActionAdd takes Memo: {content, author, sent} - no kind/blob/meta
delta: { post: {
add: { reply: {
memo: { id: formattedReplyId,
action: {
add: {
content: story, content: story,
author: fromShip, author: fromShip,
sent: sentAt, sent: sentAt,
@ -82,6 +94,7 @@ export async function sendGroupMessage({
}, },
} }
: { : {
// Regular post
post: { post: {
add: { add: {
content: story, content: story,

View File

@ -220,7 +220,7 @@
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260125.1", "@typescript/native-preview": "7.0.0-dev.20260124.1",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"docx-preview": "^0.3.7", "docx-preview": "^0.3.7",
"lit": "^3.3.2", "lit": "^3.3.2",

2
pnpm-lock.yaml generated
View File

@ -357,7 +357,7 @@ importers:
extensions/memory-core: extensions/memory-core:
dependencies: dependencies:
clawdbot: clawdbot:
specifier: '>=2026.1.24' specifier: '>=2026.1.25'
version: link:../.. version: link:../..
extensions/memory-lancedb: extensions/memory-lancedb:

21
render.yaml Normal file
View File

@ -0,0 +1,21 @@
services:
- type: web
name: clawdbot
runtime: docker
plan: starter
healthCheckPath: /health
envVars:
- key: PORT
value: "8080"
- key: SETUP_PASSWORD
sync: false
- key: CLAWDBOT_STATE_DIR
value: /data/.clawdbot
- key: CLAWDBOT_WORKSPACE_DIR
value: /data/workspace
- key: CLAWDBOT_GATEWAY_TOKEN
generateValue: true
disk:
name: clawdbot-data
mountPath: /data
sizeGB: 1

View File

@ -12,7 +12,10 @@
"manmal", "manmal",
"thesash", "thesash",
"rhjoh", "rhjoh",
"ysqander" "ysqander",
"atalovesyou",
"0xJonHoldsCrypto",
"hougangdev"
], ],
"seedCommit": "d6863f87", "seedCommit": "d6863f87",
"placeholderAvatar": "assets/avatar-placeholder.svg", "placeholderAvatar": "assets/avatar-placeholder.svg",

107
scripts/sync-labels.ts Normal file
View File

@ -0,0 +1,107 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
type RepoLabel = {
name: string;
color?: string;
};
const COLOR_BY_PREFIX = new Map<string, string>([
["channel", "1d76db"],
["app", "6f42c1"],
["extensions", "0e8a16"],
["docs", "0075ca"],
["cli", "f9d0c4"],
["gateway", "d4c5f9"],
]);
const configPath = resolve(".github/labeler.yml");
const labelNames = extractLabelNames(readFileSync(configPath, "utf8"));
if (!labelNames.length) {
throw new Error("labeler.yml must declare at least one label.");
}
const repo = resolveRepo();
const existing = fetchExistingLabels(repo);
const missing = labelNames.filter((label) => !existing.has(label));
if (!missing.length) {
console.log("All labeler labels already exist.");
process.exit(0);
}
for (const label of missing) {
const color = pickColor(label);
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${repo}/labels`,
"-f",
`name=${label}`,
"-f",
`color=${color}`,
],
{ stdio: "inherit" },
);
console.log(`Created label: ${label}`);
}
function extractLabelNames(contents: string): string[] {
const labels: string[] = [];
for (const line of contents.split("\n")) {
if (!line.trim() || line.trimStart().startsWith("#")) {
continue;
}
if (/^\s/.test(line)) {
continue;
}
const match = line.match(/^(["'])(.+)\1\s*:/) ?? line.match(/^([^:]+):/);
if (match) {
const name = (match[2] ?? match[1] ?? "").trim();
if (name) {
labels.push(name);
}
}
}
return labels;
}
function pickColor(label: string): string {
const prefix = label.includes(":") ? label.split(":", 1)[0].trim() : label.trim();
return COLOR_BY_PREFIX.get(prefix) ?? "ededed";
}
function resolveRepo(): string {
const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
encoding: "utf8",
}).trim();
if (!remote) {
throw new Error("Unable to determine repository from git remote.");
}
if (remote.startsWith("git@github.com:")) {
return remote.replace("git@github.com:", "").replace(/\.git$/, "");
}
if (remote.startsWith("https://github.com/")) {
return remote.replace("https://github.com/", "").replace(/\.git$/, "");
}
throw new Error(`Unsupported GitHub remote: ${remote}`);
}
function fetchExistingLabels(repo: string): Map<string, RepoLabel> {
const raw = execFileSync(
"gh",
["api", `repos/${repo}/labels?per_page=100`, "--paginate"],
{ encoding: "utf8" },
);
const labels = JSON.parse(raw) as RepoLabel[];
return new Map(labels.map((label) => [label.name, label]));
}

View File

@ -1,6 +1,7 @@
--- ---
name: discord name: discord
description: Use when you need to control Discord from Clawdbot via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels. description: Use when you need to control Discord from Clawdbot via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
metadata: {"clawdbot":{"emoji":"🎮","requires":{"config":["channels.discord"]}}}
--- ---
# Discord Actions # Discord Actions

View File

@ -1,6 +1,7 @@
--- ---
name: github name: github
description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries." description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries."
metadata: {"clawdbot":{"emoji":"🐙","requires":{"bins":["gh"]},"install":[{"id":"brew","kind":"brew","formula":"gh","bins":["gh"],"label":"Install GitHub CLI (brew)"},{"id":"apt","kind":"apt","package":"gh","bins":["gh"],"label":"Install GitHub CLI (apt)"}]}}
--- ---
# GitHub Skill # GitHub Skill

View File

@ -2,7 +2,7 @@
name: notion name: notion
description: Notion API for creating and managing pages, databases, and blocks. description: Notion API for creating and managing pages, databases, and blocks.
homepage: https://developers.notion.com homepage: https://developers.notion.com
metadata: {"clawdbot":{"emoji":"📝"}} metadata: {"clawdbot":{"emoji":"📝","requires":{"env":["NOTION_API_KEY"]},"primaryEnv":"NOTION_API_KEY"}}
--- ---
# notion # notion

View File

@ -1,6 +1,7 @@
--- ---
name: slack name: slack
description: Use when you need to control Slack from Clawdbot via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs. description: Use when you need to control Slack from Clawdbot via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs.
metadata: {"clawdbot":{"emoji":"💬","requires":{"config":["channels.slack"]}}}
--- ---
# Slack Actions # Slack Actions

View File

@ -133,8 +133,50 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
return { return {
label: "Cron", label: "Cron",
name: "cron", name: "cron",
description: description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.
"Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility. Use `contextMessages` (0-10) to add previous messages as context to the job text.",
ACTIONS:
- status: Check cron scheduler status
- list: List jobs (use includeDisabled:true to include disabled)
- add: Create job (requires job object, see schema below)
- update: Modify job (requires jobId + patch object)
- remove: Delete job (requires jobId)
- run: Trigger job immediately (requires jobId)
- runs: Get job run history (requires jobId)
- wake: Send wake event (requires text, optional mode)
JOB SCHEMA (for add action):
{
"name": "string (optional)",
"schedule": { ... }, // Required: when to run
"payload": { ... }, // Required: what to execute
"sessionTarget": "main" | "isolated", // Required
"enabled": true | false // Optional, default true
}
SCHEDULE TYPES (schedule.kind):
- "at": One-shot at absolute time
{ "kind": "at", "atMs": <unix-ms-timestamp> }
- "every": Recurring interval
{ "kind": "every", "everyMs": <interval-ms>, "anchorMs": <optional-start-ms> }
- "cron": Cron expression
{ "kind": "cron", "expr": "<cron-expression>", "tz": "<optional-timezone>" }
PAYLOAD TYPES (payload.kind):
- "systemEvent": Injects text as system event into session
{ "kind": "systemEvent", "text": "<message>" }
- "agentTurn": Runs agent with message (isolated sessions only)
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional>, "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
WAKE MODES (for wake action):
- "next-heartbeat" (default): Wake on next heartbeat
- "now": Wake immediately
Use jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.`,
parameters: CronToolSchema, parameters: CronToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;

View File

@ -14,6 +14,7 @@ export type AnnounceTarget = {
channel: string; channel: string;
to: string; to: string;
accountId?: string; accountId?: string;
threadId?: string; // Forum topic/thread ID
}; };
export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null { export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null {
@ -22,7 +23,22 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
if (parts.length < 3) return null; if (parts.length < 3) return null;
const [channelRaw, kind, ...rest] = parts; const [channelRaw, kind, ...rest] = parts;
if (kind !== "group" && kind !== "channel") return null; if (kind !== "group" && kind !== "channel") return null;
const id = rest.join(":").trim();
// Extract topic/thread ID from rest (supports both :topic: and :thread:)
// Telegram uses :topic:, other platforms use :thread:
let threadId: string | undefined;
const restJoined = rest.join(":");
const topicMatch = restJoined.match(/:topic:(\d+)$/);
const threadMatch = restJoined.match(/:thread:(\d+)$/);
const match = topicMatch || threadMatch;
if (match) {
threadId = match[1]; // Keep as string to match AgentCommandOpts.threadId
}
// Remove :topic:N or :thread:N suffix from ID for target
const id = match ? restJoined.replace(/:(topic|thread):\d+$/, "") : restJoined.trim();
if (!id) return null; if (!id) return null;
if (!channelRaw) return null; if (!channelRaw) return null;
const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw); const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
@ -37,7 +53,11 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
const normalized = normalizedChannel const normalized = normalizedChannel
? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget) ? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget)
: undefined; : undefined;
return { channel, to: normalized ?? kindTarget }; return {
channel,
to: normalized ?? kindTarget,
threadId,
};
} }
export function buildAgentToAgentMessageContext(params: { export function buildAgentToAgentMessageContext(params: {

View File

@ -369,12 +369,13 @@ export async function runAgentTurnWithFallback(params: {
// Use pipeline if available (block streaming enabled), otherwise send directly // Use pipeline if available (block streaming enabled), otherwise send directly
if (params.blockStreamingEnabled && params.blockReplyPipeline) { if (params.blockStreamingEnabled && params.blockReplyPipeline) {
params.blockReplyPipeline.enqueue(blockPayload); params.blockReplyPipeline.enqueue(blockPayload);
} else { } else if (params.blockStreamingEnabled) {
// Send directly when flushing before tool execution (no streaming). // Send directly when flushing before tool execution (no pipeline but streaming enabled).
// Track sent key to avoid duplicate in final payloads. // Track sent key to avoid duplicate in final payloads.
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload)); directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
await params.opts?.onBlockReply?.(blockPayload); await params.opts?.onBlockReply?.(blockPayload);
} }
// When streaming is disabled entirely, blocks are accumulated in final text instead.
} }
: undefined, : undefined,
onBlockReplyFlush: onBlockReplyFlush:

View File

@ -337,12 +337,56 @@ async function pageTargetId(page: Page): Promise<string | null> {
} }
} }
async function findPageByTargetId(browser: Browser, targetId: string): Promise<Page | null> { async function findPageByTargetId(
browser: Browser,
targetId: string,
cdpUrl?: string,
): Promise<Page | null> {
const pages = await getAllPages(browser); const pages = await getAllPages(browser);
// First, try the standard CDP session approach
for (const page of pages) { for (const page of pages) {
const tid = await pageTargetId(page).catch(() => null); const tid = await pageTargetId(page).catch(() => null);
if (tid && tid === targetId) return page; if (tid && tid === targetId) return page;
} }
// If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
// fall back to URL-based matching using the /json/list endpoint
if (cdpUrl) {
try {
const baseUrl = cdpUrl
.replace(/\/+$/, "")
.replace(/^ws:/, "http:")
.replace(/\/cdp$/, "");
const response = await fetch(`${baseUrl}/json/list`);
if (response.ok) {
const targets = (await response.json()) as Array<{
id: string;
url: string;
title?: string;
}>;
const target = targets.find((t) => t.id === targetId);
if (target) {
// Try to find a page with matching URL
const urlMatch = pages.filter((p) => p.url() === target.url);
if (urlMatch.length === 1) {
return urlMatch[0];
}
// If multiple URL matches, use index-based matching as fallback
// This works when Playwright and the relay enumerate tabs in the same order
if (urlMatch.length > 1) {
const sameUrlTargets = targets.filter((t) => t.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
return urlMatch[idx];
}
}
}
}
}
} catch {
// Ignore fetch errors and fall through to return null
}
}
return null; return null;
} }
@ -355,7 +399,7 @@ export async function getPageForTargetId(opts: {
if (!pages.length) throw new Error("No pages available in the connected browser."); if (!pages.length) throw new Error("No pages available in the connected browser.");
const first = pages[0]; const first = pages[0];
if (!opts.targetId) return first; if (!opts.targetId) return first;
const found = await findPageByTargetId(browser, opts.targetId); const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!found) { if (!found) {
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget), // Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright // which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
@ -496,7 +540,7 @@ export async function closePageByTargetIdViaPlaywright(opts: {
targetId: string; targetId: string;
}): Promise<void> { }): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl); const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId); const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) { if (!page) {
throw new Error("tab not found"); throw new Error("tab not found");
} }
@ -512,7 +556,7 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
targetId: string; targetId: string;
}): Promise<void> { }): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl); const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId); const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) { if (!page) {
throw new Error("tab not found"); throw new Error("tab not found");
} }

View File

@ -13,11 +13,9 @@ const providerId = "telegram";
function readTelegramSendParams(params: Record<string, unknown>) { function readTelegramSendParams(params: Record<string, unknown>) {
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, "to", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false }); const mediaUrl = readStringParam(params, "media", { trim: false });
const content = const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
readStringParam(params, "message", { const caption = readStringParam(params, "caption", { allowEmpty: true });
required: !mediaUrl, const content = message || caption || "";
allowEmpty: true,
}) ?? "";
const replyTo = readStringParam(params, "replyTo"); const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId"); const threadId = readStringParam(params, "threadId");
const buttons = params.buttons; const buttons = params.buttons;

View File

@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode <mode>", "Wizard mode: local|remote") .option("--mode <mode>", "Wizard mode: local|remote")
.option( .option(
"--auth-choice <choice>", "--auth-choice <choice>",
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", "Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
) )
.option( .option(
"--token-provider <id>", "--token-provider <id>",
@ -74,6 +74,7 @@ export function registerOnboardCommand(program: Command) {
.option("--zai-api-key <key>", "Z.AI API key") .option("--zai-api-key <key>", "Z.AI API key")
.option("--minimax-api-key <key>", "MiniMax API key") .option("--minimax-api-key <key>", "MiniMax API key")
.option("--synthetic-api-key <key>", "Synthetic API key") .option("--synthetic-api-key <key>", "Synthetic API key")
.option("--venice-api-key <key>", "Venice API key")
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key") .option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
.option("--gateway-port <port>", "Gateway port") .option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom") .option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
@ -123,6 +124,7 @@ export function registerOnboardCommand(program: Command) {
zaiApiKey: opts.zaiApiKey as string | undefined, zaiApiKey: opts.zaiApiKey as string | undefined,
minimaxApiKey: opts.minimaxApiKey as string | undefined, minimaxApiKey: opts.minimaxApiKey as string | undefined,
syntheticApiKey: opts.syntheticApiKey as string | undefined, syntheticApiKey: opts.syntheticApiKey as string | undefined,
veniceApiKey: opts.veniceApiKey as string | undefined,
opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined, opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
gatewayPort: gatewayPort:
typeof gatewayPort === "number" && Number.isFinite(gatewayPort) typeof gatewayPort === "number" && Number.isFinite(gatewayPort)

View File

@ -10,6 +10,61 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
const warnings: string[] = []; const warnings: string[] = [];
const auditHint = `- Run: ${formatCliCommand("clawdbot security audit --deep")}`; const auditHint = `- Run: ${formatCliCommand("clawdbot security audit --deep")}`;
// ===========================================
// GATEWAY NETWORK EXPOSURE CHECK
// ===========================================
// Check for dangerous gateway binding configurations
// that expose the gateway to network without proper auth
const gatewayBind = cfg.gateway?.bind ?? "loopback";
const customBindHost = cfg.gateway?.customBindHost?.trim();
const authMode = cfg.gateway?.auth?.mode ?? "off";
const authToken = cfg.gateway?.auth?.token;
const authPassword = cfg.gateway?.auth?.password;
const isLoopbackBindHost = (host: string) => {
const normalized = host.trim().toLowerCase();
return (
normalized === "localhost" ||
normalized === "::1" ||
normalized === "[::1]" ||
normalized.startsWith("127.")
);
};
// Bindings that expose gateway beyond localhost
const exposedBindings = ["all", "lan", "0.0.0.0"];
const isExposed =
exposedBindings.includes(gatewayBind) ||
(gatewayBind === "custom" && (!customBindHost || !isLoopbackBindHost(customBindHost)));
if (isExposed) {
if (authMode === "off") {
warnings.push(
`- CRITICAL: Gateway bound to "${gatewayBind}" with NO authentication.`,
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
` Fix: ${formatCliCommand("clawdbot config set gateway.bind loopback")}`,
` Or enable auth: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`,
);
} else if (authMode === "token" && !authToken) {
warnings.push(
`- CRITICAL: Gateway bound to "${gatewayBind}" with empty auth token.`,
` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`,
);
} else if (authMode === "password" && !authPassword) {
warnings.push(
`- CRITICAL: Gateway bound to "${gatewayBind}" with empty password.`,
` Fix: ${formatCliCommand("clawdbot configure")} to set a password`,
);
} else {
// Auth is configured, but still warn about network exposure
warnings.push(
`- WARNING: Gateway bound to "${gatewayBind}" (network-accessible).`,
` Ensure your auth credentials are strong and not exposed.`,
);
}
}
const warnDmPolicy = async (params: { const warnDmPolicy = async (params: {
label: string; label: string;
provider: ChannelId; provider: ChannelId;

View File

@ -20,6 +20,7 @@ import {
applyOpencodeZenConfig, applyOpencodeZenConfig,
applyOpenrouterConfig, applyOpenrouterConfig,
applySyntheticConfig, applySyntheticConfig,
applyVeniceConfig,
applyVercelAiGatewayConfig, applyVercelAiGatewayConfig,
applyZaiConfig, applyZaiConfig,
setAnthropicApiKey, setAnthropicApiKey,
@ -30,6 +31,7 @@ import {
setOpencodeZenApiKey, setOpencodeZenApiKey,
setOpenrouterApiKey, setOpenrouterApiKey,
setSyntheticApiKey, setSyntheticApiKey,
setVeniceApiKey,
setVercelAiGatewayApiKey, setVercelAiGatewayApiKey,
setZaiApiKey, setZaiApiKey,
} from "../../onboard-auth.js"; } from "../../onboard-auth.js";
@ -272,6 +274,25 @@ export async function applyNonInteractiveAuthChoice(params: {
return applySyntheticConfig(nextConfig); return applySyntheticConfig(nextConfig);
} }
if (authChoice === "venice-api-key") {
const resolved = await resolveNonInteractiveApiKey({
provider: "venice",
cfg: baseConfig,
flagValue: opts.veniceApiKey,
flagName: "--venice-api-key",
envVar: "VENICE_API_KEY",
runtime,
});
if (!resolved) return null;
if (resolved.source !== "profile") await setVeniceApiKey(resolved.key);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "venice:default",
provider: "venice",
mode: "api_key",
});
return applyVeniceConfig(nextConfig);
}
if ( if (
authChoice === "minimax-cloud" || authChoice === "minimax-cloud" ||
authChoice === "minimax-api" || authChoice === "minimax-api" ||

View File

@ -211,6 +211,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parseJson: (raw) => deps.json5.parse(raw), parseJson: (raw) => deps.json5.parse(raw),
}); });
// Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars
if (resolved && typeof resolved === "object" && "env" in resolved) {
applyConfigEnv(resolved as ClawdbotConfig, deps.env);
}
// Substitute ${VAR} env var references // Substitute ${VAR} env var references
const substituted = resolveConfigEnvVars(resolved, deps.env); const substituted = resolveConfigEnvVars(resolved, deps.env);
@ -365,6 +370,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}; };
} }
// Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars
if (resolved && typeof resolved === "object" && "env" in resolved) {
applyConfigEnv(resolved as ClawdbotConfig, deps.env);
}
// Substitute ${VAR} env var references // Substitute ${VAR} env var references
let substituted: unknown; let substituted: unknown;
try { try {

View File

@ -125,6 +125,7 @@ describe("gateway auth", () => {
const res = await authorizeGatewayConnect({ const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: true }, auth: { mode: "token", token: "secret", allowTailscale: true },
connectAuth: null, connectAuth: null,
tailscaleWhois: async () => ({ login: "peter", name: "Peter" }),
req: { req: {
socket: { remoteAddress: "127.0.0.1" }, socket: { remoteAddress: "127.0.0.1" },
headers: { headers: {
@ -143,6 +144,28 @@ describe("gateway auth", () => {
expect(res.user).toBe("peter"); expect(res.user).toBe("peter");
}); });
it("rejects mismatched tailscale identity when required", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }),
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-for": "100.64.0.1",
"x-forwarded-proto": "https",
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
"tailscale-user-login": "peter@example.com",
"tailscale-user-name": "Peter",
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("tailscale_user_mismatch");
});
it("treats trusted proxy loopback clients as direct", async () => { it("treats trusted proxy loopback clients as direct", async () => {
const res = await authorizeGatewayConnect({ const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true }, auth: { mode: "none", allowTailscale: true },

View File

@ -1,7 +1,8 @@
import { timingSafeEqual } from "node:crypto"; import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password"; export type ResolvedGatewayAuthMode = "none" | "token" | "password";
export type ResolvedGatewayAuth = { export type ResolvedGatewayAuth = {
@ -29,11 +30,17 @@ type TailscaleUser = {
profilePic?: string; profilePic?: string;
}; };
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
function safeEqual(a: string, b: string): boolean { function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b)); return timingSafeEqual(Buffer.from(a), Buffer.from(b));
} }
function normalizeLogin(login: string): string {
return login.trim().toLowerCase();
}
function isLoopbackAddress(ip: string | undefined): boolean { function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false; if (!ip) return false;
if (ip === "127.0.0.1") return true; if (ip === "127.0.0.1") return true;
@ -58,6 +65,12 @@ function headerValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
if (!req) return undefined;
const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]);
return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined;
}
function resolveRequestClientIp( function resolveRequestClientIp(
req?: IncomingMessage, req?: IncomingMessage,
trustedProxies?: string[], trustedProxies?: string[],
@ -118,6 +131,39 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req); return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
} }
async function resolveVerifiedTailscaleUser(params: {
req?: IncomingMessage;
tailscaleWhois: TailscaleWhoisLookup;
}): Promise<{ ok: true; user: TailscaleUser } | { ok: false; reason: string }> {
const { req, tailscaleWhois } = params;
const tailscaleUser = getTailscaleUser(req);
if (!tailscaleUser) {
return { ok: false, reason: "tailscale_user_missing" };
}
if (!isTailscaleProxyRequest(req)) {
return { ok: false, reason: "tailscale_proxy_missing" };
}
const clientIp = resolveTailscaleClientIp(req);
if (!clientIp) {
return { ok: false, reason: "tailscale_whois_failed" };
}
const whois = await tailscaleWhois(clientIp);
if (!whois?.login) {
return { ok: false, reason: "tailscale_whois_failed" };
}
if (normalizeLogin(whois.login) !== normalizeLogin(tailscaleUser.login)) {
return { ok: false, reason: "tailscale_user_mismatch" };
}
return {
ok: true,
user: {
login: whois.login,
name: whois.name ?? tailscaleUser.name,
profilePic: tailscaleUser.profilePic,
},
};
}
export function resolveGatewayAuth(params: { export function resolveGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null; authConfig?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
@ -155,29 +201,26 @@ export async function authorizeGatewayConnect(params: {
connectAuth?: ConnectAuth | null; connectAuth?: ConnectAuth | null;
req?: IncomingMessage; req?: IncomingMessage;
trustedProxies?: string[]; trustedProxies?: string[];
tailscaleWhois?: TailscaleWhoisLookup;
}): Promise<GatewayAuthResult> { }): Promise<GatewayAuthResult> {
const { auth, connectAuth, req, trustedProxies } = params; const { auth, connectAuth, req, trustedProxies } = params;
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
const localDirect = isLocalDirectRequest(req, trustedProxies); const localDirect = isLocalDirectRequest(req, trustedProxies);
if (auth.allowTailscale && !localDirect) { if (auth.allowTailscale && !localDirect) {
const tailscaleUser = getTailscaleUser(req); const tailscaleCheck = await resolveVerifiedTailscaleUser({
const tailscaleProxy = isTailscaleProxyRequest(req); req,
tailscaleWhois,
if (tailscaleUser && tailscaleProxy) { });
if (tailscaleCheck.ok) {
return { return {
ok: true, ok: true,
method: "tailscale", method: "tailscale",
user: tailscaleUser.login, user: tailscaleCheck.user.login,
}; };
} }
if (auth.mode === "none") { if (auth.mode === "none") {
if (!tailscaleUser) { return { ok: false, reason: tailscaleCheck.reason };
return { ok: false, reason: "tailscale_user_missing" };
}
if (!tailscaleProxy) {
return { ok: false, reason: "tailscale_proxy_missing" };
}
} }
} }
@ -192,7 +235,7 @@ export async function authorizeGatewayConnect(params: {
if (!connectAuth?.token) { if (!connectAuth?.token) {
return { ok: false, reason: "token_missing" }; return { ok: false, reason: "token_missing" };
} }
if (connectAuth.token !== auth.token) { if (!safeEqual(connectAuth.token, auth.token)) {
return { ok: false, reason: "token_mismatch" }; return { ok: false, reason: "token_mismatch" };
} }
return { ok: true, method: "token" }; return { ok: true, method: "token" };

View File

@ -36,7 +36,7 @@ function stripOptionalPort(ip: string): string {
return ip; return ip;
} }
function parseForwardedForClientIp(forwardedFor?: string): string | undefined { export function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
const raw = forwardedFor?.split(",")[0]?.trim(); const raw = forwardedFor?.split(",")[0]?.trim();
if (!raw) return undefined; if (!raw) return undefined;
return normalizeIp(stripOptionalPort(raw)); return normalizeIp(stripOptionalPort(raw));

View File

@ -35,7 +35,7 @@ export const ChatHistoryParamsSchema = Type.Object(
export const ChatSendParamsSchema = Type.Object( export const ChatSendParamsSchema = Type.Object(
{ {
sessionKey: NonEmptyString, sessionKey: NonEmptyString,
message: NonEmptyString, message: Type.String(),
thinking: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()),
deliver: Type.Optional(Type.Boolean()), deliver: Type.Optional(Type.Boolean()),
attachments: Type.Optional(Type.Array(Type.Unknown())), attachments: Type.Optional(Type.Array(Type.Unknown())),

View File

@ -338,6 +338,15 @@ export const chatHandlers: GatewayRequestHandlers = {
: undefined, : undefined,
})) }))
.filter((a) => a.content) ?? []; .filter((a) => a.content) ?? [];
const rawMessage = p.message.trim();
if (!rawMessage && normalizedAttachments.length === 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "message or attachment required"),
);
return;
}
let parsedMessage = p.message; let parsedMessage = p.message;
let parsedImages: ChatImageContent[] = []; let parsedImages: ChatImageContent[] = [];
if (normalizedAttachments.length > 0) { if (normalizedAttachments.length > 0) {

View File

@ -28,11 +28,16 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
return; return;
} }
const threadMarker = ":thread:"; // Extract topic/thread ID from sessionKey (supports both :topic: and :thread:)
const threadIndex = sessionKey.lastIndexOf(threadMarker); // Telegram uses :topic:, other platforms use :thread:
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex); const topicIndex = sessionKey.lastIndexOf(":topic:");
const threadIndex = sessionKey.lastIndexOf(":thread:");
const markerIndex = Math.max(topicIndex, threadIndex);
const marker = topicIndex > threadIndex ? ":topic:" : ":thread:";
const baseSessionKey = markerIndex === -1 ? sessionKey : sessionKey.slice(0, markerIndex);
const threadIdRaw = const threadIdRaw =
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length); markerIndex === -1 ? undefined : sessionKey.slice(markerIndex + marker.length);
const sessionThreadId = threadIdRaw?.trim() || undefined; const sessionThreadId = threadIdRaw?.trim() || undefined;
const { cfg, entry } = loadSessionEntry(sessionKey); const { cfg, entry } = loadSessionEntry(sessionKey);
@ -42,7 +47,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
// Handles race condition where store wasn't flushed before restart // Handles race condition where store wasn't flushed before restart
const sentinelContext = payload.deliveryContext; const sentinelContext = payload.deliveryContext;
let sessionDeliveryContext = deliveryContextFromSession(entry); let sessionDeliveryContext = deliveryContextFromSession(entry);
if (!sessionDeliveryContext && threadIndex !== -1 && baseSessionKey) { if (!sessionDeliveryContext && markerIndex !== -1 && baseSessionKey) {
const { entry: baseEntry } = loadSessionEntry(baseSessionKey); const { entry: baseEntry } = loadSessionEntry(baseSessionKey);
sessionDeliveryContext = deliveryContextFromSession(baseEntry); sessionDeliveryContext = deliveryContextFromSession(baseEntry);
} }
@ -74,6 +79,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
const threadId = const threadId =
payload.threadId ?? payload.threadId ??
parsedTarget?.threadId ?? // From resolveAnnounceTargetFromKey (extracts :topic:N)
sessionThreadId ?? sessionThreadId ??
(origin?.threadId != null ? String(origin.threadId) : undefined); (origin?.threadId != null ? String(origin.threadId) : undefined);

View File

@ -208,6 +208,39 @@ describe("gateway server chat", () => {
| undefined; | undefined;
expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]); expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
const callsBeforeImageOnly = spy.mock.calls.length;
const reqIdOnly = "chat-img-only";
ws.send(
JSON.stringify({
type: "req",
id: reqIdOnly,
method: "chat.send",
params: {
sessionKey: "main",
message: "",
idempotencyKey: "idem-img-only",
attachments: [
{
type: "image",
mimeType: "image/png",
fileName: "dot.png",
content: `data:image/png;base64,${pngB64}`,
},
],
},
}),
);
const imgOnlyRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqIdOnly, 8000);
expect(imgOnlyRes.ok).toBe(true);
expect(imgOnlyRes.payload?.runId).toBeDefined();
await waitFor(() => spy.mock.calls.length > callsBeforeImageOnly, 8000);
const imgOnlyOpts = spy.mock.calls.at(-1)?.[1] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
expect(imgOnlyOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(historyDir); tempDirs.push(historyDir);
testState.sessionStorePath = path.join(historyDir, "sessions.json"); testState.sessionStorePath = path.join(historyDir, "sessions.json");

View File

@ -100,6 +100,10 @@ function formatGatewayAuthFailureMessage(params: {
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)"; return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
case "tailscale_proxy_missing": case "tailscale_proxy_missing":
return "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)"; return "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)";
case "tailscale_whois_failed":
return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)";
case "tailscale_user_mismatch":
return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)";
default: default:
break; break;
} }

View File

@ -381,6 +381,31 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: ClawdbotConfig;
}; };
} }
// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data.
function mergeSessionEntryIntoCombined(params: {
combined: Record<string, SessionEntry>;
entry: SessionEntry;
agentId: string;
canonicalKey: string;
}) {
const { combined, entry, agentId, canonicalKey } = params;
const existing = combined[canonicalKey];
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
combined[canonicalKey] = {
...entry,
...existing,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy),
};
} else {
combined[canonicalKey] = {
...existing,
...entry,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
};
}
}
export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): { export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
storePath: string; storePath: string;
store: Record<string, SessionEntry>; store: Record<string, SessionEntry>;
@ -393,10 +418,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
const combined: Record<string, SessionEntry> = {}; const combined: Record<string, SessionEntry> = {};
for (const [key, entry] of Object.entries(store)) { for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key); const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
combined[canonicalKey] = { mergeSessionEntryIntoCombined({
...entry, combined,
spawnedBy: canonicalizeSpawnedByForAgent(defaultAgentId, entry.spawnedBy), entry,
}; agentId: defaultAgentId,
canonicalKey,
});
} }
return { storePath, store: combined }; return { storePath, store: combined };
} }
@ -408,13 +435,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
for (const [key, entry] of Object.entries(store)) { for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key); const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
// Merge with existing entry if present (avoid overwriting with less complete data) mergeSessionEntryIntoCombined({
const existing = combined[canonicalKey]; combined,
combined[canonicalKey] = { entry,
...existing, agentId,
...entry, canonicalKey,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy), });
};
} }
} }

View File

@ -213,6 +213,18 @@ type ExecErrorDetails = {
code?: unknown; code?: unknown;
}; };
export type TailscaleWhoisIdentity = {
login: string;
name?: string;
};
type TailscaleWhoisCacheEntry = {
value: TailscaleWhoisIdentity | null;
expiresAt: number;
};
const whoisCache = new Map<string, TailscaleWhoisCacheEntry>();
function extractExecErrorText(err: unknown) { function extractExecErrorText(err: unknown) {
const errOutput = err as ExecErrorDetails; const errOutput = err as ExecErrorDetails;
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
@ -381,3 +393,73 @@ export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
timeoutMs: 15_000, timeoutMs: 15_000,
}); });
} }
function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function parseWhoisIdentity(payload: Record<string, unknown>): TailscaleWhoisIdentity | null {
const userProfile =
readRecord(payload.UserProfile) ?? readRecord(payload.userProfile) ?? readRecord(payload.User);
const login =
getString(userProfile?.LoginName) ??
getString(userProfile?.Login) ??
getString(userProfile?.login) ??
getString(payload.LoginName) ??
getString(payload.login);
if (!login) return null;
const name =
getString(userProfile?.DisplayName) ??
getString(userProfile?.Name) ??
getString(userProfile?.displayName) ??
getString(payload.DisplayName) ??
getString(payload.name);
return { login, name };
}
function readCachedWhois(ip: string, now: number): TailscaleWhoisIdentity | null | undefined {
const cached = whoisCache.get(ip);
if (!cached) return undefined;
if (cached.expiresAt <= now) {
whoisCache.delete(ip);
return undefined;
}
return cached.value;
}
function writeCachedWhois(ip: string, value: TailscaleWhoisIdentity | null, ttlMs: number) {
whoisCache.set(ip, { value, expiresAt: Date.now() + ttlMs });
}
export async function readTailscaleWhoisIdentity(
ip: string,
exec: typeof runExec = runExec,
opts?: { timeoutMs?: number; cacheTtlMs?: number; errorTtlMs?: number },
): Promise<TailscaleWhoisIdentity | null> {
const normalized = ip.trim();
if (!normalized) return null;
const now = Date.now();
const cached = readCachedWhois(normalized, now);
if (cached !== undefined) return cached;
const cacheTtlMs = opts?.cacheTtlMs ?? 60_000;
const errorTtlMs = opts?.errorTtlMs ?? 5_000;
try {
const tailscaleBin = await getTailscaleBinary();
const { stdout } = await exec(tailscaleBin, ["whois", "--json", normalized], {
timeoutMs: opts?.timeoutMs ?? 5_000,
maxBuffer: 200_000,
});
const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
const identity = parseWhoisIdentity(parsed);
writeCachedWhois(normalized, identity, cacheTtlMs);
return identity;
} catch {
writeCachedWhois(normalized, null, errorTtlMs);
return null;
}
}

View File

@ -129,9 +129,10 @@ export async function checkGitUpdateStatus(params: {
).catch(() => null); ).catch(() => null);
const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null; const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
const dirtyRes = await runCommandWithTimeout(["git", "-C", root, "status", "--porcelain"], { const dirtyRes = await runCommandWithTimeout(
timeoutMs, ["git", "-C", root, "status", "--porcelain", "--", ":!dist/control-ui/"],
}).catch(() => null); { timeoutMs },
).catch(() => null);
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null; const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
const fetchOk = params.fetch const fetchOk = params.fetch

View File

@ -44,7 +44,7 @@ describe("runGatewayUpdate", () => {
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" }, [`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" }, [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: " M README.md" },
}); });
const result = await runGatewayUpdate({ const result = await runGatewayUpdate({
@ -69,7 +69,7 @@ describe("runGatewayUpdate", () => {
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" }, [`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain`]: { stdout: "" }, [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: { [`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
stdout: "origin/main", stdout: "origin/main",
}, },
@ -103,7 +103,7 @@ describe("runGatewayUpdate", () => {
const { runner, calls } = createRunner({ const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} status --porcelain`]: { stdout: "" }, [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" }, [`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { [`git -C ${tempDir} tag --list v* --sort=-v:refname`]: {
stdout: `${stableTag}\n${betaTag}\n`, stdout: `${stableTag}\n${betaTag}\n`,
@ -112,6 +112,7 @@ describe("runGatewayUpdate", () => {
"pnpm install": { stdout: "" }, "pnpm install": { stdout: "" },
"pnpm build": { stdout: "" }, "pnpm build": { stdout: "" },
"pnpm ui:build": { stdout: "" }, "pnpm ui:build": { stdout: "" },
[`git -C ${tempDir} checkout -- dist/control-ui/`]: { stdout: "" },
"pnpm clawdbot doctor --non-interactive": { stdout: "" }, "pnpm clawdbot doctor --non-interactive": { stdout: "" },
}); });

View File

@ -346,10 +346,14 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const channel: UpdateChannel = opts.channel ?? "dev"; const channel: UpdateChannel = opts.channel ?? "dev";
const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null; const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null;
const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH; const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH;
gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 10 : 9) : 8; gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9;
const statusCheck = await runStep( const statusCheck = await runStep(
step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot), step(
"clean check",
["git", "-C", gitRoot, "status", "--porcelain", "--", ":!dist/control-ui/"],
gitRoot,
),
); );
steps.push(statusCheck); steps.push(statusCheck);
const hasUncommittedChanges = const hasUncommittedChanges =
@ -654,6 +658,17 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
); );
steps.push(uiBuildStep); steps.push(uiBuildStep);
// Restore dist/control-ui/ to committed state to prevent dirty repo after update
// (ui:build regenerates assets with new hashes, which would block future updates)
const restoreUiStep = await runStep(
step(
"restore control-ui",
["git", "-C", gitRoot, "checkout", "--", "dist/control-ui/"],
gitRoot,
),
);
steps.push(restoreUiStep);
const doctorStep = await runStep( const doctorStep = await runStep(
step( step(
"clawdbot doctor", "clawdbot doctor",

View File

@ -11,6 +11,12 @@ export const DEFAULT_AGENT_ID = "main";
export const DEFAULT_MAIN_KEY = "main"; export const DEFAULT_MAIN_KEY = "main";
export const DEFAULT_ACCOUNT_ID = "default"; export const DEFAULT_ACCOUNT_ID = "default";
// Pre-compiled regex
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
function normalizeToken(value: string | undefined | null): string { function normalizeToken(value: string | undefined | null): string {
return (value ?? "").trim().toLowerCase(); return (value ?? "").trim().toLowerCase();
} }
@ -52,14 +58,14 @@ export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim(); const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID; if (!trimmed) return DEFAULT_AGENT_ID;
// Keep it path-safe + shell-friendly. // Keep it path-safe + shell-friendly.
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
// Best-effort fallback: collapse invalid characters to "-" // Best-effort fallback: collapse invalid characters to "-"
return ( return (
trimmed trimmed
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-") .replace(INVALID_CHARS_RE, "-")
.replace(/^-+/, "") .replace(LEADING_DASH_RE, "")
.replace(/-+$/, "") .replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID .slice(0, 64) || DEFAULT_AGENT_ID
); );
} }
@ -67,13 +73,13 @@ export function normalizeAgentId(value: string | undefined | null): string {
export function sanitizeAgentId(value: string | undefined | null): string { export function sanitizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim(); const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID; if (!trimmed) return DEFAULT_AGENT_ID;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
return ( return (
trimmed trimmed
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-") .replace(INVALID_CHARS_RE, "-")
.replace(/^-+/, "") .replace(LEADING_DASH_RE, "")
.replace(/-+$/, "") .replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID .slice(0, 64) || DEFAULT_AGENT_ID
); );
} }
@ -81,13 +87,13 @@ export function sanitizeAgentId(value: string | undefined | null): string {
export function normalizeAccountId(value: string | undefined | null): string { export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim(); const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID; if (!trimmed) return DEFAULT_ACCOUNT_ID;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
return ( return (
trimmed trimmed
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-") .replace(INVALID_CHARS_RE, "-")
.replace(/^-+/, "") .replace(LEADING_DASH_RE, "")
.replace(/-+$/, "") .replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_ACCOUNT_ID .slice(0, 64) || DEFAULT_ACCOUNT_ID
); );
} }

View File

@ -141,7 +141,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
}); });
markDispatchIdle(); markDispatchIdle();
if (!queuedFinal) { const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
if (!anyReplyDelivered) {
if (prepared.isRoomish) { if (prepared.isRoomish) {
clearHistoryEntriesIfEnabled({ clearHistoryEntriesIfEnabled({
historyMap: ctx.channelHistories, historyMap: ctx.channelHistories,

View File

@ -69,7 +69,7 @@ export class FilterableSelectList implements Component {
lines.push(filterLabel + inputText); lines.push(filterLabel + inputText);
// Separator // Separator
lines.push(chalk.dim("─".repeat(width))); lines.push(chalk.dim("─".repeat(Math.max(0, width))));
// Select list // Select list
const listLines = this.selectList.render(width); const listLines = this.selectList.render(width);

View File

@ -214,7 +214,8 @@ export class SearchableSelectList implements Component {
const maxValueWidth = Math.min(30, width - prefixWidth - 4); const maxValueWidth = Math.min(30, width - prefixWidth - 4);
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
const valueText = this.highlightMatch(truncatedValue, query); const valueText = this.highlightMatch(truncatedValue, query);
const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText))); const spacingWidth = Math.max(1, 32 - visibleWidth(valueText));
const spacing = " ".repeat(spacingWidth);
const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length; const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length;
const remainingWidth = width - descriptionStart - 2; const remainingWidth = width - descriptionStart - 2;
if (remainingWidth > 10) { if (remainingWidth > 10) {

View File

@ -103,7 +103,7 @@
bottom: 0; bottom: 0;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: stretch; flex-direction: column;
gap: 12px; gap: 12px;
margin-top: auto; /* Push to bottom of flex container */ margin-top: auto; /* Push to bottom of flex container */
padding: 12px 4px 4px; padding: 12px 4px 4px;
@ -111,6 +111,121 @@
z-index: 10; z-index: 10;
} }
/* Image attachments preview */
.chat-attachments {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: var(--panel);
border-radius: 8px;
border: 1px solid var(--border);
width: fit-content;
max-width: 100%;
align-self: flex-start; /* Don't stretch in flex column parent */
}
.chat-attachment {
position: relative;
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg);
}
.chat-attachment__img {
width: 100%;
height: 100%;
object-fit: contain;
}
.chat-attachment__remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 12px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 150ms ease-out;
}
.chat-attachment:hover .chat-attachment__remove {
opacity: 1;
}
.chat-attachment__remove:hover {
background: rgba(220, 38, 38, 0.9);
}
.chat-attachment__remove svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
}
/* Light theme attachment overrides */
:root[data-theme="light"] .chat-attachments {
background: #f8fafc;
border-color: rgba(16, 24, 40, 0.1);
}
:root[data-theme="light"] .chat-attachment {
border-color: rgba(16, 24, 40, 0.15);
background: #fff;
}
:root[data-theme="light"] .chat-attachment__remove {
background: rgba(0, 0, 0, 0.6);
}
/* Message images (sent images displayed in chat) */
.chat-message-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.chat-message-image {
max-width: 300px;
max-height: 200px;
border-radius: 8px;
object-fit: contain;
cursor: pointer;
transition: transform 150ms ease-out;
}
.chat-message-image:hover {
transform: scale(1.02);
}
/* User message images align right */
.chat-group.user .chat-message-images {
justify-content: flex-end;
}
/* Compose input row - horizontal layout */
.chat-compose__row {
display: flex;
align-items: stretch;
gap: 12px;
flex: 1;
}
:root[data-theme="light"] .chat-compose { :root[data-theme="light"] .chat-compose {
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%); background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
} }

View File

@ -1541,9 +1541,8 @@
/* Chat compose */ /* Chat compose */
.chat-compose { .chat-compose {
margin-top: 12px; margin-top: 12px;
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr) auto; flex-direction: column;
align-items: end;
gap: 10px; gap: 10px;
} }

View File

@ -8,11 +8,13 @@ import { normalizeBasePath } from "./navigation";
import type { GatewayHelloOk } from "./gateway"; import type { GatewayHelloOk } from "./gateway";
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import type { ClawdbotApp } from "./app"; import type { ClawdbotApp } from "./app";
import type { ChatAttachment, ChatQueueItem } from "./ui-types";
type ChatHost = { type ChatHost = {
connected: boolean; connected: boolean;
chatMessage: string; chatMessage: string;
chatQueue: Array<{ id: string; text: string; createdAt: number }>; chatAttachments: ChatAttachment[];
chatQueue: ChatQueueItem[];
chatRunId: string | null; chatRunId: string | null;
chatSending: boolean; chatSending: boolean;
sessionKey: string; sessionKey: string;
@ -45,15 +47,17 @@ export async function handleAbortChat(host: ChatHost) {
await abortChatRun(host as unknown as ClawdbotApp); await abortChatRun(host as unknown as ClawdbotApp);
} }
function enqueueChatMessage(host: ChatHost, text: string) { function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) return; const hasAttachments = Boolean(attachments && attachments.length > 0);
if (!trimmed && !hasAttachments) return;
host.chatQueue = [ host.chatQueue = [
...host.chatQueue, ...host.chatQueue,
{ {
id: generateUUID(), id: generateUUID(),
text: trimmed, text: trimmed,
createdAt: Date.now(), createdAt: Date.now(),
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
}, },
]; ];
} }
@ -61,19 +65,31 @@ function enqueueChatMessage(host: ChatHost, text: string) {
async function sendChatMessageNow( async function sendChatMessageNow(
host: ChatHost, host: ChatHost,
message: string, message: string,
opts?: { previousDraft?: string; restoreDraft?: boolean }, opts?: {
previousDraft?: string;
restoreDraft?: boolean;
attachments?: ChatAttachment[];
previousAttachments?: ChatAttachment[];
restoreAttachments?: boolean;
},
) { ) {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]); resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message); const ok = await sendChatMessage(host as unknown as ClawdbotApp, message, opts?.attachments);
if (!ok && opts?.previousDraft != null) { if (!ok && opts?.previousDraft != null) {
host.chatMessage = opts.previousDraft; host.chatMessage = opts.previousDraft;
} }
if (!ok && opts?.previousAttachments) {
host.chatAttachments = opts.previousAttachments;
}
if (ok) { if (ok) {
setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey); setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey);
} }
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) { if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft; host.chatMessage = opts.previousDraft;
} }
if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
host.chatAttachments = opts.previousAttachments;
}
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]); scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
if (ok && !host.chatRunId) { if (ok && !host.chatRunId) {
void flushChatQueue(host); void flushChatQueue(host);
@ -86,7 +102,7 @@ async function flushChatQueue(host: ChatHost) {
const [next, ...rest] = host.chatQueue; const [next, ...rest] = host.chatQueue;
if (!next) return; if (!next) return;
host.chatQueue = rest; host.chatQueue = rest;
const ok = await sendChatMessageNow(host, next.text); const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments });
if (!ok) { if (!ok) {
host.chatQueue = [next, ...host.chatQueue]; host.chatQueue = [next, ...host.chatQueue];
} }
@ -104,7 +120,12 @@ export async function handleSendChat(
if (!host.connected) return; if (!host.connected) return;
const previousDraft = host.chatMessage; const previousDraft = host.chatMessage;
const message = (messageOverride ?? host.chatMessage).trim(); const message = (messageOverride ?? host.chatMessage).trim();
if (!message) return; const attachments = host.chatAttachments ?? [];
const attachmentsToSend = messageOverride == null ? attachments : [];
const hasAttachments = attachmentsToSend.length > 0;
// Allow sending with just attachments (no message text required)
if (!message && !hasAttachments) return;
if (isChatStopCommand(message)) { if (isChatStopCommand(message)) {
await handleAbortChat(host); await handleAbortChat(host);
@ -113,16 +134,21 @@ export async function handleSendChat(
if (messageOverride == null) { if (messageOverride == null) {
host.chatMessage = ""; host.chatMessage = "";
// Clear attachments when sending
host.chatAttachments = [];
} }
if (isChatBusy(host)) { if (isChatBusy(host)) {
enqueueChatMessage(host, message); enqueueChatMessage(host, message, attachmentsToSend);
return; return;
} }
await sendChatMessageNow(host, message, { await sendChatMessageNow(host, message, {
previousDraft: messageOverride == null ? previousDraft : undefined, previousDraft: messageOverride == null ? previousDraft : undefined,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft), restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: messageOverride == null ? attachments : undefined,
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
}); });
} }

View File

@ -441,11 +441,13 @@ export function renderApp(state: AppViewState) {
: nothing} : nothing}
${state.tab === "chat" ${state.tab === "chat"
<<<<<<< HEAD
? renderChat({ ? renderChat({
sessionKey: state.sessionKey, sessionKey: state.sessionKey,
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = ""; state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null; state.chatStream = null;
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
state.chatRunId = null; state.chatRunId = null;
@ -496,6 +498,8 @@ export function renderApp(state: AppViewState) {
canAbort: Boolean(state.chatRunId), canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(), onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id), onQueueRemove: (id) => state.removeQueuedMessage(id),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onNewSession: () => onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }), state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing // Sidebar props for tool output viewing

View File

@ -19,7 +19,7 @@ import type {
SkillStatusReport, SkillStatusReport,
StatusSummary, StatusSummary,
} from "./types"; } from "./types";
import type { ChatQueueItem, CronFormState } from "./ui-types"; import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills"; import type { SkillMessage } from "./controllers/skills";
import type { import type {
@ -49,6 +49,7 @@ export type AppViewState = {
chatLoading: boolean; chatLoading: boolean;
chatSending: boolean; chatSending: boolean;
chatMessage: string; chatMessage: string;
chatAttachments: ChatAttachment[];
chatMessages: unknown[]; chatMessages: unknown[];
chatToolMessages: unknown[]; chatToolMessages: unknown[];
chatStream: string | null; chatStream: string | null;

View File

@ -24,7 +24,7 @@ import type {
StatusSummary, StatusSummary,
NostrProfile, NostrProfile,
} from "./types"; } from "./types";
import { type ChatQueueItem, type CronFormState } from "./ui-types"; import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type { import type {
@ -129,6 +129,7 @@ export class ClawdbotApp extends LitElement {
@state() chatAvatarUrl: string | null = null; @state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null; @state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = []; @state() chatQueue: ChatQueueItem[] = [];
@state() chatAttachments: ChatAttachment[] = [];
// Sidebar state for tool output viewing // Sidebar state for tool output viewing
@state() sidebarOpen = false; @state() sidebarOpen = false;
@state() sidebarContent: string | null = null; @state() sidebarContent: string | null = null;

View File

@ -13,6 +13,48 @@ import {
} from "./message-extract"; } from "./message-extract";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards"; import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
type ImageBlock = {
url: string;
alt?: string;
};
function extractImages(message: unknown): ImageBlock[] {
const m = message as Record<string, unknown>;
const content = m.content;
const images: ImageBlock[] = [];
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block !== "object" || block === null) continue;
const b = block as Record<string, unknown>;
if (b.type === "image") {
// Handle source object format (from sendChatMessage)
const source = b.source as Record<string, unknown> | undefined;
if (source?.type === "base64" && typeof source.data === "string") {
const data = source.data as string;
const mediaType = (source.media_type as string) || "image/png";
// If data is already a data URL, use it directly
const url = data.startsWith("data:")
? data
: `data:${mediaType};base64,${data}`;
images.push({ url });
} else if (typeof b.url === "string") {
images.push({ url: b.url });
}
} else if (b.type === "image_url") {
// OpenAI format
const imageUrl = b.image_url as Record<string, unknown> | undefined;
if (typeof imageUrl?.url === "string") {
images.push({ url: imageUrl.url });
}
}
}
}
return images;
}
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) { export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
return html` return html`
<div class="chat-group assistant"> <div class="chat-group assistant">
@ -163,6 +205,25 @@ function isAvatarUrl(value: string): boolean {
); );
} }
function renderMessageImages(images: ImageBlock[]) {
if (images.length === 0) return nothing;
return html`
<div class="chat-message-images">
${images.map(
(img) => html`
<img
src=${img.url}
alt=${img.alt ?? "Attached image"}
class="chat-message-image"
@click=${() => window.open(img.url, "_blank")}
/>
`,
)}
</div>
`;
}
function renderGroupedMessage( function renderGroupedMessage(
message: unknown, message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean }, opts: { isStreaming: boolean; showReasoning: boolean },
@ -179,6 +240,8 @@ function renderGroupedMessage(
const toolCards = extractToolCards(message); const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0; const hasToolCards = toolCards.length > 0;
const images = extractImages(message);
const hasImages = images.length > 0;
const extractedText = extractTextCached(message); const extractedText = extractTextCached(message);
const extractedThinking = const extractedThinking =
@ -207,11 +270,12 @@ function renderGroupedMessage(
)}`; )}`;
} }
if (!markdown && !hasToolCards) return nothing; if (!markdown && !hasToolCards && !hasImages) return nothing;
return html` return html`
<div class="${bubbleClasses}"> <div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} ${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${renderMessageImages(images)}
${reasoningMarkdown ${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML( ? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown), toSanitizedMarkdownHtml(reasoningMarkdown),

View File

@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
handleChatEvent,
type ChatEventPayload,
type ChatState,
} from "./chat";
function createState(overrides: Partial<ChatState> = {}): ChatState {
return {
client: null,
connected: true,
sessionKey: "main",
chatLoading: false,
chatMessages: [],
chatThinkingLevel: null,
chatSending: false,
chatMessage: "",
chatRunId: null,
chatStream: null,
chatStreamStartedAt: null,
lastError: null,
...overrides,
};
}
describe("handleChatEvent", () => {
it("returns null when payload is missing", () => {
const state = createState();
expect(handleChatEvent(state, undefined)).toBe(null);
});
it("returns null when sessionKey does not match", () => {
const state = createState({ sessionKey: "main" });
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "other",
state: "final",
};
expect(handleChatEvent(state, payload)).toBe(null);
});
it("returns null for delta from another run", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-user",
chatStream: "Hello",
});
const payload: ChatEventPayload = {
runId: "run-announce",
sessionKey: "main",
state: "delta",
message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
};
expect(handleChatEvent(state, payload)).toBe(null);
expect(state.chatRunId).toBe("run-user");
expect(state.chatStream).toBe("Hello");
});
it("returns 'final' for final from another run (e.g. sub-agent announce) without clearing state", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-user",
chatStream: "Working...",
chatStreamStartedAt: 123,
});
const payload: ChatEventPayload = {
runId: "run-announce",
sessionKey: "main",
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "Sub-agent findings" }],
},
};
expect(handleChatEvent(state, payload)).toBe("final");
expect(state.chatRunId).toBe("run-user");
expect(state.chatStream).toBe("Working...");
expect(state.chatStreamStartedAt).toBe(123);
});
it("processes final from own run and clears state", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "Reply",
chatStreamStartedAt: 100,
});
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "main",
state: "final",
};
expect(handleChatEvent(state, payload)).toBe("final");
expect(state.chatRunId).toBe(null);
expect(state.chatStream).toBe(null);
expect(state.chatStreamStartedAt).toBe(null);
});
});

View File

@ -1,6 +1,7 @@
import type { GatewayBrowserClient } from "../gateway";
import { extractText } from "../chat/message-extract"; import { extractText } from "../chat/message-extract";
import type { GatewayBrowserClient } from "../gateway";
import { generateUUID } from "../uuid"; import { generateUUID } from "../uuid";
import type { ChatAttachment } from "../ui-types";
export type ChatState = { export type ChatState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
@ -11,6 +12,7 @@ export type ChatState = {
chatThinkingLevel: string | null; chatThinkingLevel: string | null;
chatSending: boolean; chatSending: boolean;
chatMessage: string; chatMessage: string;
chatAttachments: ChatAttachment[];
chatRunId: string | null; chatRunId: string | null;
chatStream: string | null; chatStream: string | null;
chatStreamStartedAt: number | null; chatStreamStartedAt: number | null;
@ -43,17 +45,44 @@ export async function loadChatHistory(state: ChatState) {
} }
} }
export async function sendChatMessage(state: ChatState, message: string): Promise<boolean> { function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
if (!match) return null;
return { mimeType: match[1], content: match[2] };
}
export async function sendChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): Promise<boolean> {
if (!state.client || !state.connected) return false; if (!state.client || !state.connected) return false;
const msg = message.trim(); const msg = message.trim();
if (!msg) return false; const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) return false;
const now = Date.now(); const now = Date.now();
// Build user message content blocks
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
if (msg) {
contentBlocks.push({ type: "text", text: msg });
}
// Add image previews to the message for display
if (hasAttachments) {
for (const att of attachments) {
contentBlocks.push({
type: "image",
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
});
}
}
state.chatMessages = [ state.chatMessages = [
...state.chatMessages, ...state.chatMessages,
{ {
role: "user", role: "user",
content: [{ type: "text", text: msg }], content: contentBlocks,
timestamp: now, timestamp: now,
}, },
]; ];
@ -64,12 +93,29 @@ export async function sendChatMessage(state: ChatState, message: string): Promis
state.chatRunId = runId; state.chatRunId = runId;
state.chatStream = ""; state.chatStream = "";
state.chatStreamStartedAt = now; state.chatStreamStartedAt = now;
// Convert attachments to API format
const apiAttachments = hasAttachments
? attachments
.map((att) => {
const parsed = dataUrlToBase64(att.dataUrl);
if (!parsed) return null;
return {
type: "image",
mimeType: parsed.mimeType,
content: parsed.content,
};
})
.filter((a): a is NonNullable<typeof a> => a !== null)
: undefined;
try { try {
await state.client.request("chat.send", { await state.client.request("chat.send", {
sessionKey: state.sessionKey, sessionKey: state.sessionKey,
message: msg, message: msg,
deliver: false, deliver: false,
idempotencyKey: runId, idempotencyKey: runId,
attachments: apiAttachments,
}); });
return true; return true;
} catch (err) { } catch (err) {
@ -115,8 +161,17 @@ export function handleChatEvent(
) { ) {
if (!payload) return null; if (!payload) return null;
if (payload.sessionKey !== state.sessionKey) return null; if (payload.sessionKey !== state.sessionKey) return null;
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId)
// Final from another run (e.g. sub-agent announce): refresh history to show new message.
// See https://github.com/clawdbot/clawdbot/issues/1909
if (
payload.runId &&
state.chatRunId &&
payload.runId !== state.chatRunId
) {
if (payload.state === "final") return "final";
return null; return null;
}
if (payload.state === "delta") { if (payload.state === "delta") {
const next = extractText(payload.message); const next = extractText(payload.message);

View File

@ -1,7 +1,14 @@
export type ChatAttachment = {
id: string;
dataUrl: string;
mimeType: string;
};
export type ChatQueueItem = { export type ChatQueueItem = {
id: string; id: string;
text: string; text: string;
createdAt: number; createdAt: number;
attachments?: ChatAttachment[];
}; };
export const CRON_CHANNEL_LAST = "last"; export const CRON_CHANNEL_LAST = "last";

View File

@ -1,7 +1,7 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js"; import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types"; import type { SessionsListResult } from "../types";
import type { ChatQueueItem } from "../ui-types"; import type { ChatAttachment, ChatQueueItem } from "../ui-types";
import type { ChatItem, MessageGroup } from "../types/chat-types"; import type { ChatItem, MessageGroup } from "../types/chat-types";
import { icons } from "../icons"; import { icons } from "../icons";
import { import {
@ -53,6 +53,9 @@ export type ChatProps = {
splitRatio?: number; splitRatio?: number;
assistantName: string; assistantName: string;
assistantAvatar: string | null; assistantAvatar: string | null;
// Image attachments
attachments?: ChatAttachment[];
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
// Event handlers // Event handlers
onRefresh: () => void; onRefresh: () => void;
onToggleFocusMode: () => void; onToggleFocusMode: () => void;
@ -96,6 +99,82 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
return nothing; return nothing;
} }
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function handlePaste(
e: ClipboardEvent,
props: ChatProps,
) {
const items = e.clipboardData?.items;
if (!items || !props.onAttachmentsChange) return;
const imageItems: DataTransferItem[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
imageItems.push(item);
}
}
if (imageItems.length === 0) return;
e.preventDefault();
for (const item of imageItems) {
const file = item.getAsFile();
if (!file) continue;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
const newAttachment: ChatAttachment = {
id: generateAttachmentId(),
dataUrl,
mimeType: file.type,
};
const current = props.attachments ?? [];
props.onAttachmentsChange?.([...current, newAttachment]);
};
reader.readAsDataURL(file);
}
}
function renderAttachmentPreview(props: ChatProps) {
const attachments = props.attachments ?? [];
if (attachments.length === 0) return nothing;
return html`
<div class="chat-attachments">
${attachments.map(
(att) => html`
<div class="chat-attachment">
<img
src=${att.dataUrl}
alt="Attachment preview"
class="chat-attachment__img"
/>
<button
class="chat-attachment__remove"
type="button"
aria-label="Remove attachment"
@click=${() => {
const next = (props.attachments ?? []).filter(
(a) => a.id !== att.id,
);
props.onAttachmentsChange?.(next);
}}
>
${icons.x}
</button>
</div>
`,
)}
</div>
`;
}
export function renderChat(props: ChatProps) { export function renderChat(props: ChatProps) {
const canCompose = props.connected; const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null; const isBusy = props.sending || props.stream !== null;
@ -110,8 +189,11 @@ export function renderChat(props: ChatProps) {
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
}; };
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const composePlaceholder = props.connected const composePlaceholder = props.connected
? "Message (↩ to send, Shift+↩ for line breaks)" ? hasAttachments
? "Add a message or paste more images..."
: "Message (↩ to send, Shift+↩ for line breaks, paste images)"
: "Connect to the gateway to start chatting…"; : "Connect to the gateway to start chatting…";
const splitRatio = props.splitRatio ?? 0.6; const splitRatio = props.splitRatio ?? 0.6;
@ -218,16 +300,20 @@ export function renderChat(props: ChatProps) {
${props.queue.map( ${props.queue.map(
(item) => html` (item) => html`
<div class="chat-queue__item"> <div class="chat-queue__item">
<div class="chat-queue__text">${item.text}</div> <div class="chat-queue__text">
<ui-button ${item.text ||
size="sm" (item.attachments?.length
variant="ghost" ? `Image (${item.attachments.length})`
: "")}
</div>
<button
class="btn chat-queue__remove"
type="button" type="button"
aria-label="Remove queued message" aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)} @click=${() => props.onQueueRemove(item.id)}
> >
${icons.x} ${icons.x}
</ui-button> </button>
</div> </div>
`, `,
)} )}
@ -237,6 +323,7 @@ export function renderChat(props: ChatProps) {
: nothing} : nothing}
<div class="chat-compose"> <div class="chat-compose">
<<<<<<< HEAD
<label class="field chat-compose__field"> <label class="field chat-compose__field">
<span>Message</span> <span>Message</span>
<textarea <textarea
@ -258,18 +345,43 @@ export function renderChat(props: ChatProps) {
<div class="chat-compose__actions"> <div class="chat-compose__actions">
<ui-button <ui-button
variant="secondary" variant="secondary"
?disabled=${!props.connected || (!canAbort && props.sending)} ${renderAttachmentPreview(props)}
@click=${canAbort ? props.onAbort : props.onNewSession} <div class="chat-compose__row">
> <label class="field chat-compose__field">
${canAbort ? "Stop" : "New session"} <span>Message</span>
</ui-button> <textarea
<ui-button .value=${props.draft}
variant="primary" ?disabled=${!props.connected}
?disabled=${!props.connected} @keydown=${(e: KeyboardEvent) => {
@click=${props.onSend} if (e.key !== "Enter") return;
> if (e.isComposing || e.keyCode === 229) return;
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd"></kbd> if (e.shiftKey) return; // Allow Shift+Enter for line breaks
</ui-button> if (!props.connected) return;
e.preventDefault();
if (canCompose) props.onSend();
}}
@input=${(e: Event) =>
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${composePlaceholder}
></textarea>
</label>
<div class="chat-compose__actions">
<button
class="btn"
?disabled=${!props.connected || (!canAbort && props.sending)}
@click=${canAbort ? props.onAbort : props.onNewSession}
>
${canAbort ? "Stop" : "New session"}
</button>
<button
class="btn primary"
?disabled=${!props.connected}
@click=${props.onSend}
>
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd"></kbd>
</button>
</div>
</div> </div>
</div> </div>
</section> </section>