Merge branch 'main' into nanogpt
This commit is contained in:
commit
ba30020eca
174
.github/labeler.yml
vendored
Normal file
174
.github/labeler.yml
vendored
Normal 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
59
.github/workflows/auto-response.yml
vendored
Normal 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. We’re keeping the core lean on skills, so I’m 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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
23
.github/workflows/labeler.yml
vendored
Normal 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 }}
|
||||||
@ -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).
|
||||||
|
|||||||
38
CHANGELOG.md
38
CHANGELOG.md
@ -2,11 +2,47 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.25
|
||||||
|
Status: unreleased.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- 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
|
||||||
|
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
||||||
|
|
||||||
## 2026.1.24-3
|
## 2026.1.24-3
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie.
|
- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie.
|
||||||
- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie.
|
- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie.
|
||||||
|
- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse.
|
||||||
|
|
||||||
## 2026.1.24-2
|
## 2026.1.24-2
|
||||||
|
|
||||||
@ -34,7 +70,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- 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
|
||||||
|
|||||||
53
README.md
53
README.md
@ -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>
|
||||||
|
|||||||
@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601240
|
versionCode = 202601250
|
||||||
versionName = "2026.1.24"
|
versionName = "2026.1.25"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@ -19,9 +19,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.24</string>
|
<string>2026.1.25</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260124</string>
|
<string>20260125</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
|||||||
@ -17,8 +17,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.24</string>
|
<string>2026.1.25</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260124</string>
|
<string>20260125</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -81,8 +81,8 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdbot
|
CFBundleDisplayName: Clawdbot
|
||||||
CFBundleIconName: AppIcon
|
CFBundleIconName: AppIcon
|
||||||
CFBundleShortVersionString: "2026.1.24"
|
CFBundleShortVersionString: "2026.1.25"
|
||||||
CFBundleVersion: "20260124"
|
CFBundleVersion: "20260125"
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
@ -130,5 +130,5 @@ targets:
|
|||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: ClawdbotTests
|
CFBundleDisplayName: ClawdbotTests
|
||||||
CFBundleShortVersionString: "2026.1.24"
|
CFBundleShortVersionString: "2026.1.25"
|
||||||
CFBundleVersion: "20260124"
|
CFBundleVersion: "20260125"
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.24</string>
|
<string>2026.1.25</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601240</string>
|
<string>202601250</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Clawdbot</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -182,6 +182,7 @@ Clawdbot ships a default for `claude-cli`:
|
|||||||
|
|
||||||
- `command: "claude"`
|
- `command: "claude"`
|
||||||
- `args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"]`
|
- `args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"]`
|
||||||
|
- `resumeArgs: ["-p", "--output-format", "json", "--dangerously-skip-permissions", "--resume", "{sessionId}"]`
|
||||||
- `modelArg: "--model"`
|
- `modelArg: "--model"`
|
||||||
- `systemPromptArg: "--append-system-prompt"`
|
- `systemPromptArg: "--append-system-prompt"`
|
||||||
- `sessionArg: "--session-id"`
|
- `sessionArg: "--session-id"`
|
||||||
|
|||||||
239
docs/platforms/digitalocean.md
Normal file
239
docs/platforms/digitalocean.md
Normal 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
|
||||||
@ -182,7 +182,7 @@ cat > /data/clawdbot.json << 'EOF'
|
|||||||
"bind": "auto"
|
"bind": "auto"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"lastTouchedVersion": "2026.1.24"
|
"lastTouchedVersion": "2026.1.25"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
498
docs/platforms/gcp.md
Normal file
498
docs/platforms/gcp.md
Normal 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)
|
||||||
@ -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
|
||||||
|
|||||||
@ -30,17 +30,17 @@ Notes:
|
|||||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=2026.1.24-3 \
|
APP_VERSION=2026.1.25 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||||
scripts/package-mac-app.sh
|
scripts/package-mac-app.sh
|
||||||
|
|
||||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.zip
|
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.25.zip
|
||||||
|
|
||||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.dmg
|
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.25.dmg
|
||||||
|
|
||||||
# Recommended: build + notarize/staple zip + DMG
|
# Recommended: build + notarize/staple zip + DMG
|
||||||
# First, create a keychain profile once:
|
# First, create a keychain profile once:
|
||||||
@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.dmg
|
|||||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=2026.1.24-3 \
|
APP_VERSION=2026.1.25 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||||
scripts/package-mac-dist.sh
|
scripts/package-mac-dist.sh
|
||||||
|
|
||||||
# Optional: ship dSYM alongside the release
|
# Optional: ship dSYM alongside the release
|
||||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24-3.dSYM.zip
|
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.25.dSYM.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
## Appcast entry
|
## Appcast entry
|
||||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||||
```bash
|
```bash
|
||||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24-3.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.25.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||||
```
|
```
|
||||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||||
|
|
||||||
## Publish & verify
|
## Publish & verify
|
||||||
- Upload `Clawdbot-2026.1.24-3.zip` (and `Clawdbot-2026.1.24-3.dSYM.zip`) to the GitHub release for tag `v2026.1.24-3`.
|
- Upload `Clawdbot-2026.1.25.zip` (and `Clawdbot-2026.1.25.dSYM.zip`) to the GitHub release for tag `v2026.1.25`.
|
||||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||||
- Sanity checks:
|
- Sanity checks:
|
||||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||||
|
|||||||
354
docs/platforms/raspberry-pi.md
Normal file
354
docs/platforms/raspberry-pi.md
Normal 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
|
||||||
145
docs/providers/claude-max-api-proxy.md
Normal file
145
docs/providers/claude-max-api-proxy.md
Normal 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
|
||||||
@ -52,5 +52,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).
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
|
|||||||
|
|
||||||
## One-click deploy
|
## One-click deploy
|
||||||
|
|
||||||
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
||||||
|
|
||||||
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||||
|
|
||||||
1) **Version & metadata**
|
1) **Version & metadata**
|
||||||
- [ ] Bump `package.json` version (e.g., `2026.1.24`).
|
- [ ] Bump `package.json` version (e.g., `2026.1.25`).
|
||||||
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
||||||
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
|
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
|
||||||
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
|
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
|
||||||
|
|||||||
158
docs/render.mdx
Normal file
158
docs/render.mdx
Normal 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`
|
||||||
@ -14,6 +14,7 @@ deployments work at a high level.
|
|||||||
- **Railway** (one‑click + browser setup): [Railway](/railway)
|
- **Railway** (one‑click + 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
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/bluebubbles",
|
"name": "@clawdbot/bluebubbles",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot BlueBubbles channel plugin",
|
"description": "Clawdbot BlueBubbles channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/copilot-proxy",
|
"name": "@clawdbot/copilot-proxy",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/diagnostics-otel",
|
"name": "@clawdbot/diagnostics-otel",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/discord",
|
"name": "@clawdbot/discord",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Discord channel plugin",
|
"description": "Clawdbot Discord channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-antigravity-auth",
|
"name": "@clawdbot/google-antigravity-auth",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-gemini-cli-auth",
|
"name": "@clawdbot/google-gemini-cli-auth",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/googlechat",
|
"name": "@clawdbot/googlechat",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Google Chat channel plugin",
|
"description": "Clawdbot Google Chat channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -34,6 +34,6 @@
|
|||||||
"clawdbot": "workspace:*"
|
"clawdbot": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"clawdbot": ">=2026.1.24"
|
"clawdbot": ">=2026.1.25"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/imessage",
|
"name": "@clawdbot/imessage",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot iMessage channel plugin",
|
"description": "Clawdbot iMessage channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/line",
|
"name": "@clawdbot/line",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot LINE channel plugin",
|
"description": "Clawdbot LINE channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/llm-task",
|
"name": "@clawdbot/llm-task",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot JSON-only LLM task plugin",
|
"description": "Clawdbot JSON-only LLM task plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/lobster",
|
"name": "@clawdbot/lobster",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/matrix",
|
"name": "@clawdbot/matrix",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Matrix channel plugin",
|
"description": "Clawdbot Matrix channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/mattermost",
|
"name": "@clawdbot/mattermost",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Mattermost channel plugin",
|
"description": "Clawdbot Mattermost channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-core",
|
"name": "@clawdbot/memory-core",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot core memory search plugin",
|
"description": "Clawdbot core memory search plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -9,6 +9,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"clawdbot": ">=2026.1.24"
|
"clawdbot": ">=2026.1.25"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-lancedb",
|
"name": "@clawdbot/memory-lancedb",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/msteams",
|
"name": "@clawdbot/msteams",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nextcloud-talk",
|
"name": "@clawdbot/nextcloud-talk",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nextcloud Talk channel plugin",
|
"description": "Clawdbot Nextcloud Talk channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nostr",
|
"name": "@clawdbot/nostr",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/open-prose",
|
"name": "@clawdbot/open-prose",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/signal",
|
"name": "@clawdbot/signal",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Signal channel plugin",
|
"description": "Clawdbot Signal channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/slack",
|
"name": "@clawdbot/slack",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Slack channel plugin",
|
"description": "Clawdbot Slack channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/telegram",
|
"name": "@clawdbot/telegram",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Telegram channel plugin",
|
"description": "Clawdbot Telegram channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/tlon",
|
"name": "@clawdbot/tlon",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Tlon/Urbit channel plugin",
|
"description": "Clawdbot Tlon/Urbit channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.24
|
## 2026.1.25
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
|
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/voice-call",
|
"name": "@clawdbot/voice-call",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot voice-call plugin",
|
"description": "Clawdbot voice-call plugin",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/whatsapp",
|
"name": "@clawdbot/whatsapp",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot WhatsApp channel plugin",
|
"description": "Clawdbot WhatsApp channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalo",
|
"name": "@clawdbot/zalo",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo channel plugin",
|
"description": "Clawdbot Zalo channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalouser",
|
"name": "@clawdbot/zalouser",
|
||||||
"version": "2026.1.24",
|
"version": "2026.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "clawdbot",
|
||||||
"version": "2026.1.24-3",
|
"version": "2026.1.25",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@ -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
21
render.yaml
Normal 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
|
||||||
@ -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
107
scripts/sync-labels.ts
Normal 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]));
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -61,7 +61,7 @@ describe("runClaudeCliAgent", () => {
|
|||||||
expect(argv).toContain("hi");
|
expect(argv).toContain("hi");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses provided --session-id when a claude session id is provided", async () => {
|
it("uses --resume when a claude session id is provided", async () => {
|
||||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||||
stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }),
|
stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }),
|
||||||
stderr: "",
|
stderr: "",
|
||||||
@ -83,7 +83,7 @@ describe("runClaudeCliAgent", () => {
|
|||||||
|
|
||||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
|
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
|
||||||
const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[];
|
const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[];
|
||||||
expect(argv).toContain("--session-id");
|
expect(argv).toContain("--resume");
|
||||||
expect(argv).toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
|
expect(argv).toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
|
||||||
expect(argv).toContain("hi");
|
expect(argv).toContain("hi");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -28,6 +28,14 @@ const CLAUDE_MODEL_ALIASES: Record<string, string> = {
|
|||||||
const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
|
const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
|
||||||
command: "claude",
|
command: "claude",
|
||||||
args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"],
|
args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"],
|
||||||
|
resumeArgs: [
|
||||||
|
"-p",
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"--resume",
|
||||||
|
"{sessionId}",
|
||||||
|
],
|
||||||
output: "json",
|
output: "json",
|
||||||
input: "arg",
|
input: "arg",
|
||||||
modelArg: "--model",
|
modelArg: "--model",
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -179,6 +179,17 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
images: params.opts?.images,
|
images: params.opts?.images,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
// CLI backends don't emit streaming assistant events, so we need to
|
||||||
|
// emit one with the final text so server-chat can populate its buffer
|
||||||
|
// and send the response to TUI/WebSocket clients.
|
||||||
|
const cliText = result.payloads?.[0]?.text?.trim();
|
||||||
|
if (cliText) {
|
||||||
|
emitAgentEvent({
|
||||||
|
runId,
|
||||||
|
stream: "assistant",
|
||||||
|
data: { text: cliText },
|
||||||
|
});
|
||||||
|
}
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId,
|
runId,
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
@ -358,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:
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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|nanogpt-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|nanogpt-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>",
|
||||||
@ -75,6 +75,7 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
.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("--nanogpt-api-key <key>", "NanoGPT API key")
|
.option("--nanogpt-api-key <key>", "NanoGPT 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")
|
||||||
@ -125,6 +126,7 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
minimaxApiKey: opts.minimaxApiKey as string | undefined,
|
minimaxApiKey: opts.minimaxApiKey as string | undefined,
|
||||||
syntheticApiKey: opts.syntheticApiKey as string | undefined,
|
syntheticApiKey: opts.syntheticApiKey as string | undefined,
|
||||||
nanogptApiKey: opts.nanogptApiKey as string | undefined,
|
nanogptApiKey: opts.nanogptApiKey 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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
applyOpencodeZenConfig,
|
applyOpencodeZenConfig,
|
||||||
applyOpenrouterConfig,
|
applyOpenrouterConfig,
|
||||||
applySyntheticConfig,
|
applySyntheticConfig,
|
||||||
|
applyVeniceConfig,
|
||||||
applyVercelAiGatewayConfig,
|
applyVercelAiGatewayConfig,
|
||||||
applyZaiConfig,
|
applyZaiConfig,
|
||||||
setAnthropicApiKey,
|
setAnthropicApiKey,
|
||||||
@ -32,6 +33,7 @@ import {
|
|||||||
setOpencodeZenApiKey,
|
setOpencodeZenApiKey,
|
||||||
setOpenrouterApiKey,
|
setOpenrouterApiKey,
|
||||||
setSyntheticApiKey,
|
setSyntheticApiKey,
|
||||||
|
setVeniceApiKey,
|
||||||
setVercelAiGatewayApiKey,
|
setVercelAiGatewayApiKey,
|
||||||
setZaiApiKey,
|
setZaiApiKey,
|
||||||
} from "../../onboard-auth.js";
|
} from "../../onboard-auth.js";
|
||||||
@ -293,6 +295,25 @@ export async function applyNonInteractiveAuthChoice(params: {
|
|||||||
return applyNanoGptConfig(nextConfig);
|
return applyNanoGptConfig(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" ||
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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())),
|
||||||
|
|||||||
43
src/gateway/server-chat.agent-events.test.ts
Normal file
43
src/gateway/server-chat.agent-events.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
|
||||||
|
|
||||||
|
describe("agent event handler", () => {
|
||||||
|
it("emits chat delta for assistant text-only events", () => {
|
||||||
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||||
|
const broadcast = vi.fn();
|
||||||
|
const nodeSendToSession = vi.fn();
|
||||||
|
const agentRunSeq = new Map<string, number>();
|
||||||
|
const chatRunState = createChatRunState();
|
||||||
|
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
|
||||||
|
|
||||||
|
const handler = createAgentEventHandler({
|
||||||
|
broadcast,
|
||||||
|
nodeSendToSession,
|
||||||
|
agentRunSeq,
|
||||||
|
chatRunState,
|
||||||
|
resolveSessionKeyForRun: () => undefined,
|
||||||
|
clearAgentRunContext: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({
|
||||||
|
runId: "run-1",
|
||||||
|
seq: 1,
|
||||||
|
stream: "assistant",
|
||||||
|
ts: Date.now(),
|
||||||
|
data: { text: "Hello world" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat");
|
||||||
|
expect(chatCalls).toHaveLength(1);
|
||||||
|
const payload = chatCalls[0]?.[1] as {
|
||||||
|
state?: string;
|
||||||
|
message?: { content?: Array<{ text?: string }> };
|
||||||
|
};
|
||||||
|
expect(payload.state).toBe("delta");
|
||||||
|
expect(payload.message?.content?.[0]?.text).toBe("Hello world");
|
||||||
|
const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat");
|
||||||
|
expect(sessionChatCalls).toHaveLength(1);
|
||||||
|
nowSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
163
src/gateway/server-methods/agent.test.ts
Normal file
163
src/gateway/server-methods/agent.test.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { GatewayRequestContext } from "./types.js";
|
||||||
|
import { agentHandlers } from "./agent.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
loadSessionEntry: vi.fn(),
|
||||||
|
updateSessionStore: vi.fn(),
|
||||||
|
agentCommand: vi.fn(),
|
||||||
|
registerAgentRunContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../session-utils.js", () => ({
|
||||||
|
loadSessionEntry: mocks.loadSessionEntry,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../config/sessions.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||||
|
"../../config/sessions.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
updateSessionStore: mocks.updateSessionStore,
|
||||||
|
resolveAgentIdFromSessionKey: () => "main",
|
||||||
|
resolveExplicitAgentSessionKey: () => undefined,
|
||||||
|
resolveAgentMainSessionKey: () => "agent:main:main",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../commands/agent.js", () => ({
|
||||||
|
agentCommand: mocks.agentCommand,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../config/config.js", () => ({
|
||||||
|
loadConfig: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../agents/agent-scope.js", () => ({
|
||||||
|
listAgentIds: () => ["main"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../infra/agent-events.js", () => ({
|
||||||
|
registerAgentRunContext: mocks.registerAgentRunContext,
|
||||||
|
onAgentEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../sessions/send-policy.js", () => ({
|
||||||
|
resolveSendPolicy: () => "allow",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/delivery-context.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../utils/delivery-context.js")>(
|
||||||
|
"../../utils/delivery-context.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
normalizeSessionDeliveryFields: () => ({}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeContext = (): GatewayRequestContext =>
|
||||||
|
({
|
||||||
|
dedupe: new Map(),
|
||||||
|
addChatRun: vi.fn(),
|
||||||
|
logGateway: { info: vi.fn(), error: vi.fn() },
|
||||||
|
}) as unknown as GatewayRequestContext;
|
||||||
|
|
||||||
|
describe("gateway agent handler", () => {
|
||||||
|
it("preserves cliSessionIds from existing session entry", async () => {
|
||||||
|
const existingCliSessionIds = { "claude-cli": "abc-123-def" };
|
||||||
|
const existingClaudeCliSessionId = "abc-123-def";
|
||||||
|
|
||||||
|
mocks.loadSessionEntry.mockReturnValue({
|
||||||
|
cfg: {},
|
||||||
|
storePath: "/tmp/sessions.json",
|
||||||
|
entry: {
|
||||||
|
sessionId: "existing-session-id",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
cliSessionIds: existingCliSessionIds,
|
||||||
|
claudeCliSessionId: existingClaudeCliSessionId,
|
||||||
|
},
|
||||||
|
canonicalKey: "agent:main:main",
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedEntry: Record<string, unknown> | undefined;
|
||||||
|
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||||
|
const store: Record<string, unknown> = {};
|
||||||
|
await updater(store);
|
||||||
|
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.agentCommand.mockResolvedValue({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
meta: { durationMs: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const respond = vi.fn();
|
||||||
|
await agentHandlers.agent({
|
||||||
|
params: {
|
||||||
|
message: "test",
|
||||||
|
agentId: "main",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
idempotencyKey: "test-idem",
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
context: makeContext(),
|
||||||
|
req: { type: "req", id: "1", method: "agent" },
|
||||||
|
client: null,
|
||||||
|
isWebchatConnect: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.updateSessionStore).toHaveBeenCalled();
|
||||||
|
expect(capturedEntry).toBeDefined();
|
||||||
|
expect(capturedEntry?.cliSessionIds).toEqual(existingCliSessionIds);
|
||||||
|
expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing cliSessionIds gracefully", async () => {
|
||||||
|
mocks.loadSessionEntry.mockReturnValue({
|
||||||
|
cfg: {},
|
||||||
|
storePath: "/tmp/sessions.json",
|
||||||
|
entry: {
|
||||||
|
sessionId: "existing-session-id",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
// No cliSessionIds or claudeCliSessionId
|
||||||
|
},
|
||||||
|
canonicalKey: "agent:main:main",
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedEntry: Record<string, unknown> | undefined;
|
||||||
|
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||||
|
const store: Record<string, unknown> = {};
|
||||||
|
await updater(store);
|
||||||
|
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.agentCommand.mockResolvedValue({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
meta: { durationMs: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const respond = vi.fn();
|
||||||
|
await agentHandlers.agent({
|
||||||
|
params: {
|
||||||
|
message: "test",
|
||||||
|
agentId: "main",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
idempotencyKey: "test-idem-2",
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
context: makeContext(),
|
||||||
|
req: { type: "req", id: "2", method: "agent" },
|
||||||
|
client: null,
|
||||||
|
isWebchatConnect: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.updateSessionStore).toHaveBeenCalled();
|
||||||
|
expect(capturedEntry).toBeDefined();
|
||||||
|
// Should be undefined, not cause an error
|
||||||
|
expect(capturedEntry?.cliSessionIds).toBeUndefined();
|
||||||
|
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -251,6 +251,8 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
groupId: resolvedGroupId ?? entry?.groupId,
|
groupId: resolvedGroupId ?? entry?.groupId,
|
||||||
groupChannel: resolvedGroupChannel ?? entry?.groupChannel,
|
groupChannel: resolvedGroupChannel ?? entry?.groupChannel,
|
||||||
space: resolvedGroupSpace ?? entry?.space,
|
space: resolvedGroupSpace ?? entry?.space,
|
||||||
|
cliSessionIds: entry?.cliSessionIds,
|
||||||
|
claudeCliSessionId: entry?.claudeCliSessionId,
|
||||||
};
|
};
|
||||||
sessionEntry = nextEntry;
|
sessionEntry = nextEntry;
|
||||||
const sendPolicy = resolveSendPolicy({
|
const sendPolicy = resolveSendPolicy({
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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),
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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%);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1303,9 +1303,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -431,6 +431,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
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;
|
||||||
@ -477,6 +478,8 @@ export function renderApp(state: AppViewState) {
|
|||||||
},
|
},
|
||||||
onChatScroll: (event) => state.handleChatScroll(event),
|
onChatScroll: (event) => state.handleChatScroll(event),
|
||||||
onDraftChange: (next) => (state.chatMessage = next),
|
onDraftChange: (next) => (state.chatMessage = next),
|
||||||
|
attachments: state.chatAttachments,
|
||||||
|
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||||
onSend: () => state.handleSendChat(),
|
onSend: () => state.handleSendChat(),
|
||||||
canAbort: Boolean(state.chatRunId),
|
canAbort: Boolean(state.chatRunId),
|
||||||
onAbort: () => void state.handleAbortChat(),
|
onAbort: () => void state.handleAbortChat(),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user