Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee2cfa5ca1 | ||
|
|
0e1ebc7da1 | ||
|
|
0e3e6f6b70 | ||
|
|
337dd41ce9 | ||
|
|
88336ef4ea | ||
|
|
5ee03c82b4 | ||
|
|
111aeb2c4f | ||
|
|
23c2c638b7 | ||
|
|
6b8299eb33 | ||
|
|
9822a53649 | ||
|
|
81d392a2d7 | ||
|
|
dbcec3ffaf | ||
|
|
50c5231486 | ||
|
|
55d034358d | ||
|
|
374da34936 | ||
|
|
c578fca687 | ||
|
|
dd18765b50 | ||
|
|
3e06fe84dc | ||
|
|
640e19988f | ||
|
|
de33bc70e7 | ||
|
|
0c8ba6599b | ||
|
|
d1e9490f95 | ||
|
|
a9fc2ca0ef | ||
|
|
dd1b08b3e8 | ||
|
|
af1004ebbd | ||
|
|
f3516fb316 | ||
|
|
79d8267413 | ||
|
|
99bf65c539 | ||
|
|
6a4b5fa4b5 | ||
|
|
83511c0c09 | ||
|
|
1fec41b3df | ||
|
|
c532d161c4 | ||
|
|
989543c9c3 | ||
|
|
145adf540f | ||
|
|
953472bf25 | ||
|
|
d9384785a3 | ||
|
|
2f6b5ffdfe | ||
|
|
0e3c9e4a0e | ||
|
|
15311c138a | ||
|
|
dec71dbcf1 | ||
|
|
5a4482412d | ||
|
|
4cf829608c | ||
|
|
8de02e6074 | ||
|
|
d802844bd6 | ||
|
|
e97bcf4dae | ||
|
|
dad8e11f1e | ||
|
|
50fdd514ae | ||
|
|
ee36e12f81 | ||
|
|
1e5569d56a | ||
|
|
3ce1ee84ac | ||
|
|
1ea3ac0a1d | ||
|
|
66b6c9e0e5 | ||
|
|
b5e99dad1f | ||
|
|
6f5205d826 | ||
|
|
5f975a4eff | ||
|
|
aadfdbc59f | ||
|
|
d5c8172197 | ||
|
|
9e804f6f40 | ||
|
|
bedfc3642d | ||
|
|
46dcda1d0c | ||
|
|
950f8a04ea | ||
|
|
de44e0ad33 | ||
|
|
c639b386da | ||
|
|
fac0110e49 | ||
|
|
97971f3aef | ||
|
|
acb523de86 | ||
|
|
97531f174f | ||
|
|
ef125d5ba7 | ||
|
|
86950d3474 | ||
|
|
3a9582bc41 | ||
|
|
d198474415 | ||
|
|
ace8a1b44e | ||
|
|
a7be3a9649 | ||
|
|
0d543dd1ff | ||
|
|
404c373153 | ||
|
|
024691e4e7 | ||
|
|
a86d7a2f35 | ||
|
|
e7e34c442e | ||
|
|
9b9e8d4ae8 | ||
|
|
bf925e5758 | ||
|
|
c0c9df4ab7 | ||
|
|
6aa90f8b18 | ||
|
|
9af1c8a886 | ||
|
|
ed5ece4120 | ||
|
|
85d1835476 | ||
|
|
e85d2dff97 | ||
|
|
fac66d4dda | ||
|
|
2e99369113 | ||
|
|
835f9ee575 | ||
|
|
a136c6aa89 | ||
|
|
af96bac2dd | ||
|
|
b621d4550b | ||
|
|
c290217305 | ||
|
|
769b286cf2 | ||
|
|
690bb192e6 | ||
|
|
601a052216 | ||
|
|
bf3021d266 | ||
|
|
145b2e5f52 | ||
|
|
4b73dc95c4 | ||
|
|
e17cb408a5 | ||
|
|
3cf92152c3 | ||
|
|
c50cde2170 | ||
|
|
7e0bebd669 | ||
|
|
7c49326191 | ||
|
|
744d1329cb | ||
|
|
42e6ff4611 | ||
|
|
5f21bf735a | ||
|
|
ee380e9ab9 | ||
|
|
ab340c82fb | ||
|
|
57dd0505a3 | ||
|
|
d6f9f1c79a | ||
|
|
a08a772ffc | ||
|
|
2622b1936b | ||
|
|
c0457e0cc4 | ||
|
|
ee2f0a175a | ||
|
|
0e94f0c018 | ||
|
|
576485b0c9 | ||
|
|
57bf6d5eaf | ||
|
|
cef6b16d14 |
No files matched your search
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
**/node_modules/
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
src/canvas-host/a2ui/a2ui.bundle.js
|
||||
@@ -8,6 +8,10 @@
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||
- 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`
|
||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
@@ -62,7 +66,7 @@
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
## Shorthand Commands
|
||||
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
@@ -80,6 +84,7 @@
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
@@ -111,8 +116,7 @@
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
@@ -128,19 +132,3 @@
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
```bash
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdbot message send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
This is a Claude Code quirk, not a clawdbot bug.
|
||||
+34
-1
@@ -2,13 +2,46 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.19-1
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
||||
|
||||
### Changes
|
||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||
|
||||
### Fixes
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||
- Voice call: keep Twilio notify callbacks out of streaming paths, normalize Tailscale serve paths, and honor ASCII signature ordering. (#1213) — thanks @andrew-kurin.
|
||||
|
||||
## 2026.1.18-5
|
||||
|
||||
### Changes
|
||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
||||
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
||||
|
||||
### Fixes
|
||||
- Plugins: auto-select exclusive slots when enabling/installing plugins. (#1181) — thanks @sebslight.
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||
- macOS: suppress usage error text in the menubar cost view.
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
- TUI: highlight model search matches and stabilize search ordering.
|
||||
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
|
||||
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
|
||||
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
||||
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
||||
|
||||
## 2026.1.18-4
|
||||
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://deepwiki.com/clawdbot/clawdbot"><img src="https://img.shields.io/badge/DeepWiki-clawdbot-111111?style=for-the-badge" alt="DeepWiki"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
@@ -64,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
@@ -106,7 +107,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
@@ -128,7 +129,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||
|
||||
### Channels
|
||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
||||
|
||||
### Apps + nodes
|
||||
@@ -159,7 +160,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
@@ -474,25 +475,25 @@ Core contributors:
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
|
||||
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a>
|
||||
<a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/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/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
||||
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
|
||||
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/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/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/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/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
||||
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/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/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/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/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/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/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/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/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/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
|
||||
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/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/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/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/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/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/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
|
||||
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/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/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a>
|
||||
<a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
||||
<a href="https://github.com/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/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
||||
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/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/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
||||
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
||||
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
||||
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
|
||||
<a href="https://github.com/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/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/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/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/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/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/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/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/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/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/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/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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
||||
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/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/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
||||
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
@@ -6,7 +6,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,6 +12,9 @@ final class ConnectionModeCoordinator {
|
||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||
switch mode {
|
||||
case .unconfigured:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
GatewayProcessManager.shared.stop()
|
||||
@@ -20,6 +23,9 @@ final class ConnectionModeCoordinator {
|
||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||
|
||||
case .local:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||
@@ -50,6 +56,9 @@ final class ConnectionModeCoordinator {
|
||||
WebChatManager.shared.resetTunnels()
|
||||
|
||||
do {
|
||||
if let error = await NodeServiceManager.start() {
|
||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||
}
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
struct CostUsageHistoryMenuView: View {
|
||||
let summary: GatewayCostUsageSummary
|
||||
let width: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.chart
|
||||
self.footer
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
let todayKey = CostUsageMenuDateParser.format(Date())
|
||||
let todayEntry = self.summary.daily.first { $0.date == todayKey }
|
||||
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
|
||||
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
|
||||
|
||||
return HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Today")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(todayCost)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Last \(self.summary.days)d")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(totalCost)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var chart: some View {
|
||||
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
|
||||
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
|
||||
return (date, entry.totalCost)
|
||||
}
|
||||
|
||||
return Chart(entries, id: \.0) { entry in
|
||||
BarMark(
|
||||
x: .value("Day", entry.0),
|
||||
y: .value("Cost", entry.1))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .day, count: 7)) {
|
||||
AxisGridLine().foregroundStyle(.clear)
|
||||
AxisValueLabel(format: .dateTime.month().day())
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) {
|
||||
AxisGridLine()
|
||||
AxisValueLabel()
|
||||
}
|
||||
}
|
||||
.frame(height: 110)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
if self.summary.totals.missingCostEntries == 0 {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
return AnyView(
|
||||
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary))
|
||||
}
|
||||
}
|
||||
|
||||
private enum CostUsageMenuDateParser {
|
||||
static let formatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static func parse(_ value: String) -> Date? {
|
||||
self.formatter.date(from: value)
|
||||
}
|
||||
|
||||
static func format(_ date: Date) -> String {
|
||||
self.formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private weak var statusItem: NSStatusItem?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var nodesLoadTask: Task<Void, Never>?
|
||||
private var previewTasks: [Task<Void, Never>] = []
|
||||
private var isMenuOpen = false
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private var menuOpenWidth: CGFloat?
|
||||
@@ -26,6 +27,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private var cachedUsageErrorText: String?
|
||||
private var usageCacheUpdatedAt: Date?
|
||||
private let usageRefreshIntervalSeconds: TimeInterval = 30
|
||||
private var cachedCostSummary: GatewayCostUsageSummary?
|
||||
private var cachedCostErrorText: String?
|
||||
private var costCacheUpdatedAt: Date?
|
||||
private let costRefreshIntervalSeconds: TimeInterval = 45
|
||||
private let nodesStore = NodesStore.shared
|
||||
#if DEBUG
|
||||
private var testControlChannelConnected: Bool?
|
||||
@@ -63,6 +68,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await self.refreshUsageCache(force: forceRefresh)
|
||||
await self.refreshCostUsageCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
@@ -87,6 +93,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.menuOpenWidth = nil
|
||||
self.loadTask?.cancel()
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.cancelPreviewTasks()
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
@@ -107,6 +114,7 @@ extension MenuSessionsInjector {
|
||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||
|
||||
private func inject(into menu: NSMenu) {
|
||||
self.cancelPreviewTasks()
|
||||
// Remove any previous injected items.
|
||||
for item in menu.items where item.tag == self.tag {
|
||||
menu.removeItem(item)
|
||||
@@ -197,6 +205,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||
cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width)
|
||||
|
||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
||||
guard let self, let headerView else { return }
|
||||
@@ -341,6 +350,28 @@ extension MenuSessionsInjector {
|
||||
return cursor
|
||||
}
|
||||
|
||||
private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||
guard self.isControlChannelConnected else { return cursor }
|
||||
guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor }
|
||||
var cursor = cursor
|
||||
|
||||
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.tag = self.tag
|
||||
menu.insertItem(separator, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "")
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil)
|
||||
item.submenu = submenu
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
return cursor
|
||||
}
|
||||
|
||||
private var selectedUsageProviderId: String? {
|
||||
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
|
||||
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -390,6 +421,36 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
}
|
||||
|
||||
private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? {
|
||||
if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil {
|
||||
let menu = NSMenu()
|
||||
let item = NSMenuItem(title: error, action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
menu.addItem(item)
|
||||
return menu
|
||||
}
|
||||
|
||||
guard let summary = self.cachedCostSummary else { return nil }
|
||||
guard !summary.daily.isEmpty else { return nil }
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
|
||||
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
|
||||
let hosting = NSHostingView(rootView: AnyView(chartView))
|
||||
let controller = NSHostingController(rootView: AnyView(chartView))
|
||||
let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
|
||||
let chartItem = NSMenuItem()
|
||||
chartItem.view = hosting
|
||||
chartItem.isEnabled = false
|
||||
chartItem.representedObject = "costUsageChart"
|
||||
menu.addItem(chartItem)
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
@@ -454,15 +515,46 @@ extension MenuSessionsInjector {
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
let view = AnyView(SessionMenuPreviewView(
|
||||
sessionKey: sessionKey,
|
||||
width: width,
|
||||
maxItems: 10,
|
||||
maxLines: maxLines,
|
||||
title: title))
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
title: title,
|
||||
items: [],
|
||||
status: .loading))
|
||||
let hosting = NSHostingView(rootView: view)
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
item.view = hosting
|
||||
|
||||
let task = Task { [weak hosting] in
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let hosting else { return }
|
||||
let nextView = AnyView(SessionMenuPreviewView(
|
||||
width: width,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: snapshot.items,
|
||||
status: snapshot.status))
|
||||
hosting.rootView = nextView
|
||||
hosting.invalidateIntrinsicContentSize()
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame.size.height = size.height
|
||||
}
|
||||
}
|
||||
self.previewTasks.append(task)
|
||||
return item
|
||||
}
|
||||
|
||||
private func cancelPreviewTasks() {
|
||||
for task in self.previewTasks {
|
||||
task.cancel()
|
||||
}
|
||||
self.previewTasks.removeAll()
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||
let view = AnyView(
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
@@ -547,6 +639,31 @@ extension MenuSessionsInjector {
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func refreshCostUsageCache(force: Bool) async {
|
||||
if !force,
|
||||
let updated = self.costCacheUpdatedAt,
|
||||
Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = nil
|
||||
self.costCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
self.cachedCostSummary = try await CostUsageLoader.loadSummary()
|
||||
self.cachedCostErrorText = nil
|
||||
} catch {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = self.compactUsageError(error)
|
||||
}
|
||||
self.costCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func compactUsageError(_ error: Error) -> String {
|
||||
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if message.isEmpty { return "Usage unavailable" }
|
||||
|
||||
@@ -443,7 +443,6 @@ actor MacNodeRuntime {
|
||||
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
|
||||
let security = approvals.agent.security
|
||||
let ask = approvals.agent.ask
|
||||
let askFallback = approvals.agent.askFallback
|
||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -489,7 +488,7 @@ actor MacNodeRuntime {
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
|
||||
let decision = await ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: params.cwd,
|
||||
@@ -500,7 +499,7 @@ actor MacNodeRuntime {
|
||||
resolvedPath: resolution?.resolvedPath))
|
||||
|
||||
switch decision {
|
||||
case .deny?:
|
||||
case .deny:
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
@@ -513,41 +512,7 @@ actor MacNodeRuntime {
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
case nil:
|
||||
if askFallback == .full {
|
||||
approvedByAsk = true
|
||||
} else if askFallback == .allowlist {
|
||||
if allowlistMatch != nil || skillAllow {
|
||||
approvedByAsk = true
|
||||
} else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
} else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
case .allowAlways?:
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ??
|
||||
@@ -558,7 +523,7 @@ actor MacNodeRuntime {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
}
|
||||
case .allowOnce?:
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum NodeServiceManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "node.service")
|
||||
|
||||
static func start() async -> String? {
|
||||
let result = await self.runServiceCommandResult(
|
||||
["node", "start"],
|
||||
timeout: 20,
|
||||
quiet: false)
|
||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) {
|
||||
self.logger.error("node service start failed: \(error, privacy: .public)")
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func stop() async -> String? {
|
||||
let result = await self.runServiceCommandResult(
|
||||
["node", "stop"],
|
||||
timeout: 15,
|
||||
quiet: false)
|
||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) {
|
||||
self.logger.error("node service stop failed: \(error, privacy: .public)")
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeServiceManager {
|
||||
private struct CommandResult {
|
||||
let success: Bool
|
||||
let payload: Data?
|
||||
let message: String?
|
||||
let parsed: ParsedServiceJson?
|
||||
}
|
||||
|
||||
private struct ParsedServiceJson {
|
||||
let text: String
|
||||
let object: [String: Any]
|
||||
let ok: Bool?
|
||||
let result: String?
|
||||
let message: String?
|
||||
let error: String?
|
||||
let hints: [String]
|
||||
}
|
||||
|
||||
private static func runServiceCommandResult(
|
||||
_ args: [String],
|
||||
timeout: Double,
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "service",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Service management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr)
|
||||
let ok = parsed?.ok
|
||||
let message = parsed?.error ?? parsed?.message
|
||||
let payload = parsed?.text.data(using: .utf8)
|
||||
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
||||
let success = ok ?? response.success
|
||||
if success {
|
||||
return CommandResult(success: true, payload: payload, message: nil, parsed: parsed)
|
||||
}
|
||||
|
||||
if quiet {
|
||||
return CommandResult(success: false, payload: payload, message: message, parsed: parsed)
|
||||
}
|
||||
|
||||
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||
let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" }
|
||||
?? "Node service command failed (\(exit))"
|
||||
self.logger.error("\(fullMessage, privacy: .public)")
|
||||
return CommandResult(success: false, payload: payload, message: detail, parsed: parsed)
|
||||
}
|
||||
|
||||
private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? {
|
||||
if !result.success {
|
||||
return result.message ?? "Node service command failed"
|
||||
}
|
||||
guard let parsed = result.parsed else { return nil }
|
||||
if parsed.ok == false {
|
||||
return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints)
|
||||
}
|
||||
if treatNotLoadedAsError, parsed.result == "not-loaded" {
|
||||
let base = parsed.message ?? "Node service not loaded."
|
||||
return self.mergeHints(message: base, hints: parsed.hints)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func withJsonFlag(_ args: [String]) -> [String] {
|
||||
if args.contains("--json") { return args }
|
||||
return args + ["--json"]
|
||||
}
|
||||
|
||||
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
let ok = object["ok"] as? Bool
|
||||
let result = object["result"] as? String
|
||||
let message = object["message"] as? String
|
||||
let error = object["error"] as? String
|
||||
let hints = (object["hints"] as? [String]) ?? []
|
||||
return ParsedServiceJson(
|
||||
text: jsonText,
|
||||
object: object,
|
||||
ok: ok,
|
||||
result: result,
|
||||
message: message,
|
||||
error: error,
|
||||
hints: hints)
|
||||
}
|
||||
|
||||
private static func mergeHints(message: String?, hints: [String]) -> String? {
|
||||
let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil
|
||||
guard !hints.isEmpty else { return nonEmpty }
|
||||
let hintText = hints.prefix(2).joined(separator: " · ")
|
||||
if let nonEmpty {
|
||||
return "\(nonEmpty) (\(hintText))"
|
||||
}
|
||||
return hintText
|
||||
}
|
||||
|
||||
private static func summarize(_ text: String) -> String? {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,7 @@ extension OnboardingView {
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
buttonTitle: String,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
@@ -222,7 +223,7 @@ extension OnboardingView {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Open Settings → Skills", action: action)
|
||||
Button(buttonTitle, action: action)
|
||||
.buttonStyle(.link)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@@ -695,7 +695,8 @@ extension OnboardingView {
|
||||
self.featureActionRow(
|
||||
title: "Connect WhatsApp or Telegram",
|
||||
subtitle: "Open Settings → Channels to link channels and monitor status.",
|
||||
systemImage: "link")
|
||||
systemImage: "link",
|
||||
buttonTitle: "Open Settings → Channels")
|
||||
{
|
||||
self.openSettings(tab: .channels)
|
||||
}
|
||||
@@ -711,7 +712,8 @@ extension OnboardingView {
|
||||
self.featureActionRow(
|
||||
title: "Give your agent more powers",
|
||||
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||
systemImage: "sparkles")
|
||||
systemImage: "sparkles",
|
||||
buttonTitle: "Open Settings → Skills")
|
||||
{
|
||||
self.openSettings(tab: .skills)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ extension OnboardingView {
|
||||
title: "Action",
|
||||
subtitle: "Action subtitle",
|
||||
systemImage: "gearshape",
|
||||
buttonTitle: "Action",
|
||||
action: {})
|
||||
_ = view.gatewaySubtitle(for: gateway)
|
||||
_ = view.isSelectedGateway(gateway)
|
||||
|
||||
@@ -3,13 +3,13 @@ import ClawdbotKit
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
private struct SessionPreviewItem: Identifiable, Sendable {
|
||||
struct SessionPreviewItem: Identifiable, Sendable {
|
||||
let id: String
|
||||
let role: PreviewRole
|
||||
let text: String
|
||||
}
|
||||
|
||||
private enum PreviewRole: String, Sendable {
|
||||
enum PreviewRole: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case tool
|
||||
@@ -27,7 +27,7 @@ private enum PreviewRole: String, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private actor SessionPreviewCache {
|
||||
actor SessionPreviewCache {
|
||||
static let shared = SessionPreviewCache()
|
||||
|
||||
private struct CacheEntry {
|
||||
@@ -52,25 +52,33 @@ private actor SessionPreviewCache {
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionMenuPreviewView: View {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
|
||||
let sessionKey: String
|
||||
let width: CGFloat
|
||||
let maxItems: Int
|
||||
let maxLines: Int
|
||||
let title: String
|
||||
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
@State private var items: [SessionPreviewItem] = []
|
||||
@State private var status: LoadStatus = .loading
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
#if DEBUG
|
||||
extension SessionPreviewCache {
|
||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||
}
|
||||
|
||||
private enum LoadStatus: Equatable {
|
||||
func _testReset() {
|
||||
self.entries = [:]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct SessionMenuPreviewSnapshot: Sendable {
|
||||
let items: [SessionPreviewItem]
|
||||
let status: SessionMenuPreviewView.LoadStatus
|
||||
}
|
||||
|
||||
struct SessionMenuPreviewView: View {
|
||||
let width: CGFloat
|
||||
let maxLines: Int
|
||||
let title: String
|
||||
let items: [SessionPreviewItem]
|
||||
let status: LoadStatus
|
||||
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
enum LoadStatus: Equatable {
|
||||
case loading
|
||||
case ready
|
||||
case empty
|
||||
@@ -78,15 +86,17 @@ struct SessionMenuPreviewView: View {
|
||||
}
|
||||
|
||||
private var primaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
if self.isHighlighted {
|
||||
return Color(nsColor: .selectedMenuItemTextColor)
|
||||
}
|
||||
return Color(nsColor: .labelColor)
|
||||
}
|
||||
|
||||
private var secondaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
private var previewLimit: Int {
|
||||
min(max(self.maxItems * 3, 20), 120)
|
||||
if self.isHighlighted {
|
||||
return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85)
|
||||
}
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -100,21 +110,19 @@ struct SessionMenuPreviewView: View {
|
||||
|
||||
switch self.status {
|
||||
case .loading:
|
||||
Text("Loading preview…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
self.placeholder("Loading preview…")
|
||||
case .empty:
|
||||
Text("No recent messages")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
self.placeholder("No recent messages")
|
||||
case let .error(message):
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
self.placeholder(message)
|
||||
case .ready:
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.items) { item in
|
||||
self.previewRow(item)
|
||||
if self.items.isEmpty {
|
||||
self.placeholder("No recent messages")
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.items) { item in
|
||||
self.previewRow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,9 +131,6 @@ struct SessionMenuPreviewView: View {
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 11)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
.task(id: self.sessionKey) {
|
||||
await self.loadPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -157,55 +162,66 @@ struct SessionMenuPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPreview() async {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) {
|
||||
await MainActor.run {
|
||||
self.items = cached
|
||||
self.status = cached.isEmpty ? .empty : .ready
|
||||
}
|
||||
return
|
||||
}
|
||||
@ViewBuilder
|
||||
private func placeholder(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.primaryColor)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.status = .loading
|
||||
}
|
||||
|
||||
enum SessionMenuPreviewLoader {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
}
|
||||
|
||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||
return Self.snapshot(from: cached)
|
||||
}
|
||||
|
||||
do {
|
||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await AsyncTimeout.withTimeout(
|
||||
seconds: Self.previewTimeoutSeconds,
|
||||
seconds: self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() },
|
||||
operation: {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
sessionKey: sessionKey,
|
||||
limit: self.previewLimit(for: maxItems),
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
self.items = built
|
||||
self.status = built.isEmpty ? .empty : .ready
|
||||
}
|
||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||
return Self.snapshot(from: built)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||
} catch {
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
if let fallback {
|
||||
self.items = fallback
|
||||
self.status = fallback.isEmpty ? .empty : .ready
|
||||
} else {
|
||||
self.status = .error("Preview unavailable")
|
||||
}
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||
if let fallback {
|
||||
return Self.snapshot(from: fallback)
|
||||
}
|
||||
let errorDescription = String(describing: error)
|
||||
Self.logger.warning(
|
||||
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
|
||||
"Session preview failed session=\(sessionKey, privacy: .public) " +
|
||||
"error=\(errorDescription, privacy: .public)")
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||
}
|
||||
|
||||
private static func previewLimit(for maxItems: Int) -> Int {
|
||||
min(max(maxItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
private static func previewItems(
|
||||
from payload: ClawdbotChatHistoryPayload,
|
||||
maxItems: Int) -> [SessionPreviewItem]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
struct GatewayCostUsageTotals: Codable {
|
||||
let input: Int
|
||||
let output: Int
|
||||
let cacheRead: Int
|
||||
let cacheWrite: Int
|
||||
let totalTokens: Int
|
||||
let totalCost: Double
|
||||
let missingCostEntries: Int
|
||||
}
|
||||
|
||||
struct GatewayCostUsageDay: Codable {
|
||||
let date: String
|
||||
let input: Int
|
||||
let output: Int
|
||||
let cacheRead: Int
|
||||
let cacheWrite: Int
|
||||
let totalTokens: Int
|
||||
let totalCost: Double
|
||||
let missingCostEntries: Int
|
||||
}
|
||||
|
||||
struct GatewayCostUsageSummary: Codable {
|
||||
let updatedAt: Double
|
||||
let days: Int
|
||||
let daily: [GatewayCostUsageDay]
|
||||
let totals: GatewayCostUsageTotals
|
||||
}
|
||||
|
||||
enum CostUsageFormatting {
|
||||
static func formatUsd(_ value: Double?) -> String? {
|
||||
guard let value, value.isFinite else { return nil }
|
||||
if value >= 1 { return String(format: "$%.2f", value) }
|
||||
if value >= 0.01 { return String(format: "$%.2f", value) }
|
||||
return String(format: "$%.4f", value)
|
||||
}
|
||||
|
||||
static func formatTokenCount(_ value: Int?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let safe = max(0, value)
|
||||
if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) }
|
||||
if safe >= 1000 { return safe >= 10000
|
||||
? String(format: "%.0fk", Double(safe) / 1000.0)
|
||||
: String(format: "%.1fk", Double(safe) / 1000.0)
|
||||
}
|
||||
return String(safe)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum CostUsageLoader {
|
||||
static func loadSummary() async throws -> GatewayCostUsageSummary {
|
||||
let data = try await ControlChannel.shared.request(
|
||||
method: "usage.cost",
|
||||
params: nil,
|
||||
timeoutMs: 7000)
|
||||
return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -75,18 +75,6 @@ struct UsageRow: Identifiable {
|
||||
extension GatewayUsageSummary {
|
||||
func primaryRows() -> [UsageRow] {
|
||||
self.providers.compactMap { provider in
|
||||
if let error = provider.error, provider.windows.isEmpty {
|
||||
return UsageRow(
|
||||
id: provider.provider,
|
||||
providerId: provider.provider,
|
||||
displayName: provider.displayName,
|
||||
plan: provider.plan,
|
||||
windowLabel: nil,
|
||||
usedPercent: nil,
|
||||
resetAt: nil,
|
||||
error: error)
|
||||
}
|
||||
|
||||
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
|
||||
return nil
|
||||
}
|
||||
@@ -99,7 +87,7 @@ extension GatewayUsageSummary {
|
||||
windowLabel: window.label,
|
||||
usedPercent: window.usedPercent,
|
||||
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
|
||||
error: provider.error)
|
||||
error: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +37,12 @@ struct UsageMenuLabelView: View {
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
if !self.row.hasError {
|
||||
Text(self.row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
Text(self.row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(2)
|
||||
|
||||
if self.showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
@@ -54,15 +52,6 @@ struct UsageMenuLabelView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let error = self.row.error?.nonEmpty {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
|
||||
@@ -424,14 +424,18 @@ public struct PollParams: Codable, Sendable {
|
||||
|
||||
public struct AgentParams: Codable, Sendable {
|
||||
public let message: String
|
||||
public let agentid: String?
|
||||
public let to: String?
|
||||
public let replyto: String?
|
||||
public let sessionid: String?
|
||||
public let sessionkey: String?
|
||||
public let thinking: String?
|
||||
public let deliver: Bool?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let channel: String?
|
||||
public let replychannel: String?
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -441,14 +445,18 @@ public struct AgentParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
agentid: String?,
|
||||
to: String?,
|
||||
replyto: String?,
|
||||
sessionid: String?,
|
||||
sessionkey: String?,
|
||||
thinking: String?,
|
||||
deliver: Bool?,
|
||||
attachments: [AnyCodable]?,
|
||||
channel: String?,
|
||||
replychannel: String?,
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -457,14 +465,18 @@ public struct AgentParams: Codable, Sendable {
|
||||
spawnedby: String?
|
||||
) {
|
||||
self.message = message
|
||||
self.agentid = agentid
|
||||
self.to = to
|
||||
self.replyto = replyto
|
||||
self.sessionid = sessionid
|
||||
self.sessionkey = sessionkey
|
||||
self.thinking = thinking
|
||||
self.deliver = deliver
|
||||
self.attachments = attachments
|
||||
self.channel = channel
|
||||
self.replychannel = replychannel
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -474,14 +486,18 @@ public struct AgentParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case message
|
||||
case agentid = "agentId"
|
||||
case to
|
||||
case replyto = "replyTo"
|
||||
case sessionid = "sessionId"
|
||||
case sessionkey = "sessionKey"
|
||||
case thinking
|
||||
case deliver
|
||||
case attachments
|
||||
case channel
|
||||
case replychannel = "replyChannel"
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
struct SessionMenuPreviewTests {
|
||||
@Test func loaderReturnsCachedItems() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
||||
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .ready)
|
||||
#expect(snapshot.items.count == 1)
|
||||
#expect(snapshot.items.first?.text == "Hi")
|
||||
}
|
||||
|
||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
||||
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .empty)
|
||||
#expect(snapshot.items.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -287,7 +287,7 @@ ack reaction after the bot replies.
|
||||
|
||||
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
||||
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
|
||||
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation.
|
||||
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. The wizard accepts usernames and resolves them to ids when the bot can search members.
|
||||
- `dm.groupEnabled`: enable group DMs (default `false`).
|
||||
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
|
||||
- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.
|
||||
|
||||
@@ -244,7 +244,7 @@ Provider options:
|
||||
- `channels.imessage.service`: `imessage | sms | auto`.
|
||||
- `channels.imessage.region`: SMS region.
|
||||
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.imessage.allowFrom`: DM allowlist (handles or `chat_id:*`). `open` requires `"*"`.
|
||||
- `channels.imessage.allowFrom`: DM allowlist (handles, emails, E.164 numbers, or `chat_id:*`). `open` requires `"*"`. iMessage has no usernames; use handles or chat targets.
|
||||
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.imessage.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
|
||||
|
||||
@@ -70,7 +70,7 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
||||
- `clawdbot pairing list matrix`
|
||||
- `clawdbot pairing approve matrix <CODE>`
|
||||
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
||||
- `channels.matrix.dm.allowFrom` accepts user IDs or display names (resolved at startup when directory search is available).
|
||||
- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
|
||||
|
||||
## Rooms (groups)
|
||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
@@ -121,7 +121,7 @@ Provider options:
|
||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist. `open` requires `"*"`.
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
||||
- `channels.matrix.rooms`: per-room settings and allowlist.
|
||||
|
||||
@@ -76,7 +76,7 @@ Disable with:
|
||||
|
||||
**DM access**
|
||||
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow.
|
||||
|
||||
**Group access**
|
||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
@@ -413,7 +413,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||
- `channels.msteams.webhook.port` (default `3978`)
|
||||
- `channels.msteams.webhook.path` (default `/api/messages`)
|
||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs or UPNs).
|
||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||
|
||||
@@ -120,7 +120,7 @@ Provider options:
|
||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||
- `channels.signal.sendReadReceipts`: forward read receipts.
|
||||
- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`.
|
||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
|
||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
|
||||
@@ -378,7 +378,7 @@ For fine-grained control, use these tags in agent responses:
|
||||
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
|
||||
- Approve via: `clawdbot pairing approve slack <code>`.
|
||||
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
|
||||
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow).
|
||||
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow). The wizard accepts usernames and resolves them to ids during setup when tokens allow.
|
||||
|
||||
## Group policy
|
||||
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
|
||||
|
||||
@@ -305,7 +305,7 @@ Use the global setting when all Telegram bots/accounts should behave the same. U
|
||||
- `clawdbot pairing list telegram`
|
||||
- `clawdbot pairing approve telegram <CODE>`
|
||||
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
||||
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID.
|
||||
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID. The wizard accepts `@username` and resolves it to the numeric ID when possible.
|
||||
|
||||
#### Finding your Telegram user ID
|
||||
Safer (no third-party bot):
|
||||
|
||||
@@ -308,7 +308,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
## Config quick map
|
||||
- `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
||||
- `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
|
||||
- `channels.whatsapp.allowFrom` (DM allowlist).
|
||||
- `channels.whatsapp.allowFrom` (DM allowlist). WhatsApp uses E.164 phone numbers (no usernames).
|
||||
- `channels.whatsapp.mediaMaxMb` (inbound media save cap).
|
||||
- `channels.whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
|
||||
- `channels.whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
||||
|
||||
@@ -92,7 +92,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
||||
- `clawdbot pairing list zalo`
|
||||
- `clawdbot pairing approve zalo <CODE>`
|
||||
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||
- `channels.zalo.allowFrom` accepts numeric user IDs.
|
||||
- `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available).
|
||||
|
||||
## Long-polling vs webhook
|
||||
- Default: long-polling (no public URL required).
|
||||
@@ -147,7 +147,7 @@ Provider options:
|
||||
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
|
||||
- `channels.zalo.tokenFile`: read token from file path.
|
||||
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
|
||||
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
|
||||
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
|
||||
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
|
||||
|
||||
@@ -66,7 +66,7 @@ clawdbot directory groups list --channel zalouser --query "work"
|
||||
|
||||
## Access control (DMs)
|
||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||
`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available).
|
||||
`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available.
|
||||
|
||||
Approve via:
|
||||
- `clawdbot pairing list zalouser`
|
||||
|
||||
+3
-1
@@ -7,6 +7,7 @@ read_when:
|
||||
# `clawdbot agent`
|
||||
|
||||
Run an agent turn via the Gateway (use `--local` for embedded).
|
||||
Use `--agent <id>` to target a configured agent directly.
|
||||
|
||||
Related:
|
||||
- Agent send tool: [Agent send](/tools/agent-send)
|
||||
@@ -15,6 +16,7 @@ Related:
|
||||
|
||||
```bash
|
||||
clawdbot agent --to +15555550123 --message "status update" --deliver
|
||||
clawdbot agent --agent ops --message "Summarize logs"
|
||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
||||
```
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ clawdbot gateway
|
||||
Notes:
|
||||
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
||||
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
|
||||
- `SIGUSR1` triggers an in-process restart when authorized (enable `commands.restart` or use the gateway tool/config apply/update).
|
||||
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
|
||||
|
||||
### Options
|
||||
|
||||
@@ -89,6 +89,15 @@ clawdbot sandbox recreate --all
|
||||
clawdbot sandbox recreate --all
|
||||
```
|
||||
|
||||
### After changing setupCommand
|
||||
|
||||
```bash
|
||||
clawdbot sandbox recreate --all
|
||||
# or just one agent:
|
||||
clawdbot sandbox recreate --agent family
|
||||
```
|
||||
|
||||
|
||||
### For a specific agent only
|
||||
|
||||
```bash
|
||||
|
||||
@@ -293,6 +293,9 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio
|
||||
}
|
||||
```
|
||||
|
||||
Note: `setupCommand` lives under `sandbox.docker` and runs once on container creation.
|
||||
Per-agent `sandbox.docker.*` overrides are ignored when the resolved scope is `"shared"`.
|
||||
|
||||
**Benefits:**
|
||||
- **Security isolation**: Restrict tools for untrusted agents
|
||||
- **Resource control**: Sandbox specific agents while keeping others on host
|
||||
|
||||
@@ -9,7 +9,7 @@ read_when:
|
||||
|
||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||
|
||||
## Message envelopes (UTC)
|
||||
## Message envelopes (UTC by default)
|
||||
|
||||
Inbound messages are wrapped in an envelope like:
|
||||
|
||||
@@ -17,7 +17,46 @@ Inbound messages are wrapped in an envelope like:
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
The timestamp in the envelope is **always UTC**, with minutes precision.
|
||||
The timestamp in the envelope is **UTC by default**, with minutes precision.
|
||||
|
||||
You can override this with:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimestamp: "on", // "on" | "off"
|
||||
envelopeElapsed: "on" // "on" | "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||
|
||||
### Examples
|
||||
|
||||
**UTC (default):**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 2026-01-18T05:19Z] hello
|
||||
```
|
||||
|
||||
**Fixed timezone:**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello
|
||||
```
|
||||
|
||||
**Elapsed time:**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up
|
||||
```
|
||||
|
||||
## Tool payloads (raw provider data + normalized fields)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ read_when:
|
||||
## Where it shows up
|
||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
||||
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
|
||||
- `/usage cost` in chats: local cost summary aggregated from Clawdbot session logs.
|
||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
- macOS menu bar: “Usage” section under Context (only if available).
|
||||
|
||||
+44
-4
@@ -7,10 +7,10 @@ read_when:
|
||||
|
||||
# Date & Time
|
||||
|
||||
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||
We avoid rewriting provider timestamps so tools keep their native semantics.
|
||||
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics.
|
||||
|
||||
## Message envelopes (UTC)
|
||||
## Message envelopes (UTC by default)
|
||||
|
||||
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
|
||||
@@ -18,7 +18,47 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
This envelope timestamp is **always UTC**, regardless of the host timezone.
|
||||
This envelope timestamp is **UTC by default**, regardless of the host timezone.
|
||||
|
||||
You can override this behavior:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimestamp: "on", // "on" | "off"
|
||||
envelopeElapsed: "on" // "on" | "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `envelopeTimezone: "local"` uses the host timezone.
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||
|
||||
### Examples
|
||||
|
||||
**UTC (default):**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 2026-01-18T05:19Z] hello
|
||||
```
|
||||
|
||||
**User timezone:**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 2026-01-18 00:19 CST] hello
|
||||
```
|
||||
|
||||
**Elapsed time enabled:**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up
|
||||
```
|
||||
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
summary: Node + tsx "__name is not a function" crash notes and workarounds
|
||||
read_when:
|
||||
- Debugging Node-only dev scripts or watch mode failures
|
||||
- Investigating tsx/esbuild loader crashes in Clawdbot
|
||||
---
|
||||
|
||||
# Node + tsx "__name is not a function" crash
|
||||
|
||||
## Summary
|
||||
Running Clawdbot via Node with `tsx` fails at startup with:
|
||||
|
||||
```
|
||||
[clawdbot] Failed to start CLI: TypeError: __name is not a function
|
||||
at createSubsystemLogger (.../src/logging/subsystem.ts:203:25)
|
||||
at .../src/agents/auth-profiles/constants.ts:25:20
|
||||
```
|
||||
|
||||
This began after switching dev scripts from Bun to `tsx` (commit `2871657e`, 2026-01-06). The same runtime path worked with Bun.
|
||||
|
||||
## Environment
|
||||
- Node: v25.x (observed on v25.3.0)
|
||||
- tsx: 4.21.0
|
||||
- OS: macOS (repro also likely on other platforms that run Node 25)
|
||||
|
||||
## Repro (Node-only)
|
||||
```bash
|
||||
# in repo root
|
||||
node --version
|
||||
pnpm install
|
||||
node --import tsx src/entry.ts status
|
||||
```
|
||||
|
||||
## Minimal repro in repo
|
||||
```bash
|
||||
node --import tsx scripts/repro/tsx-name-repro.ts
|
||||
```
|
||||
|
||||
## Node version check
|
||||
- Node 25.3.0: fails
|
||||
- Node 22.22.0 (Homebrew `node@22`): fails
|
||||
- Node 24: not installed here yet; needs verification
|
||||
|
||||
## Notes / hypothesis
|
||||
- `tsx` uses esbuild to transform TS/ESM. esbuild’s `keepNames` emits a `__name` helper and wraps function definitions with `__name(...)`.
|
||||
- The crash indicates `__name` exists but is not a function at runtime, which implies the helper is missing or overwritten for this module in the Node 25 loader path.
|
||||
- Similar `__name` helper issues have been reported in other esbuild consumers when the helper is missing or rewritten.
|
||||
|
||||
## Regression history
|
||||
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
|
||||
- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
|
||||
|
||||
## Workarounds
|
||||
- Use Bun for dev scripts (current temporary revert).
|
||||
- Use Node + tsc watch, then run compiled output:
|
||||
```bash
|
||||
pnpm exec tsc --watch --preserveWatchOutput
|
||||
node --watch dist/entry.js status
|
||||
```
|
||||
- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25.
|
||||
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
|
||||
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25–specific.
|
||||
|
||||
## References
|
||||
- https://opennext.js.org/cloudflare/howtos/keep_names
|
||||
- https://esbuild.github.io/api/#keep-names
|
||||
- https://github.com/evanw/esbuild/issues/1031
|
||||
|
||||
## Next steps
|
||||
- Repro on Node 22/24 to confirm Node 25 regression.
|
||||
- Test `tsx` nightly or pin to earlier version if a known regression exists.
|
||||
- If reproduces on Node LTS, file a minimal repro upstream with the `__name` stack trace.
|
||||
@@ -17,6 +17,19 @@ If the file is missing, Clawdbot uses safe-ish defaults (embedded Pi agent + per
|
||||
|
||||
> **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations!
|
||||
|
||||
## Strict config validation
|
||||
|
||||
Clawdbot only accepts configurations that fully match the schema.
|
||||
Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start** for safety.
|
||||
|
||||
When validation fails:
|
||||
- The Gateway does not boot.
|
||||
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
|
||||
- Run `clawdbot doctor` to see the exact issues.
|
||||
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
||||
|
||||
Doctor never writes changes unless you explicitly opt into `--fix`/`--yes`.
|
||||
|
||||
## Schema + UI hints
|
||||
|
||||
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
|
||||
@@ -1990,6 +2003,9 @@ cross-session isolation. Use `scope: "session"` for per-session isolation.
|
||||
Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
||||
`false` → `scope: "shared"`).
|
||||
|
||||
`setupCommand` runs **once** after the container is created (inside the container via `sh -lc`).
|
||||
For package installs, ensure network egress, a writable root FS, and a root user.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
|
||||
@@ -34,7 +34,7 @@ pnpm gateway:watch
|
||||
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
|
||||
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
|
||||
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
|
||||
- **SIGUSR1** triggers an in-process restart (no external supervisor required). This is what the `gateway` agent tool uses.
|
||||
- **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts).
|
||||
- Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token <value>` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`.
|
||||
- The wizard now generates a token by default, even on loopback.
|
||||
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
||||
|
||||
@@ -116,6 +116,20 @@ Override with `agents.defaults.sandbox.docker.network`.
|
||||
Docker installs and the containerized gateway live here:
|
||||
[Docker](/install/docker)
|
||||
|
||||
## setupCommand (one-time container setup)
|
||||
`setupCommand` runs **once** after the sandbox container is created (not on every run).
|
||||
It executes inside the container via `sh -lc`.
|
||||
|
||||
Paths:
|
||||
- Global: `agents.defaults.sandbox.docker.setupCommand`
|
||||
- Per-agent: `agents.list[].sandbox.docker.setupCommand`
|
||||
|
||||
|
||||
Common pitfalls:
|
||||
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
|
||||
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
|
||||
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
|
||||
|
||||
## Tool policy + escape hatches
|
||||
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
||||
globally or per-agent, sandboxing doesn’t bring it back.
|
||||
|
||||
@@ -5,13 +5,6 @@ read_when:
|
||||
---
|
||||
# Security 🔒
|
||||
|
||||
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
|
||||
|
||||
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
|
||||
- who can talk to your bot
|
||||
- where the bot is allowed to act
|
||||
- what the bot can touch
|
||||
|
||||
## Quick check: `clawdbot security audit`
|
||||
|
||||
Run this regularly (especially after changing config or exposing network surfaces):
|
||||
@@ -29,6 +22,13 @@ It flags common footguns (Gateway auth exposure, browser control exposure, eleva
|
||||
- Turn `logging.redactSensitive="off"` back to `"tools"`.
|
||||
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
|
||||
|
||||
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
|
||||
|
||||
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
|
||||
- who can talk to your bot
|
||||
- where the bot is allowed to act
|
||||
- what the bot can touch
|
||||
|
||||
### What the audit checks (high level)
|
||||
|
||||
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
||||
|
||||
@@ -326,6 +326,22 @@ Clawdbot keeps conversation history in memory.
|
||||
|
||||
## Common troubleshooting
|
||||
|
||||
### “Gateway won’t start — configuration invalid”
|
||||
|
||||
Clawdbot now refuses to start when the config contains unknown keys, malformed values, or invalid types.
|
||||
This is intentional for safety.
|
||||
|
||||
Fix it with Doctor:
|
||||
```bash
|
||||
clawdbot doctor
|
||||
clawdbot doctor --fix
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `clawdbot doctor` reports every invalid entry.
|
||||
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
|
||||
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid.
|
||||
|
||||
### “All models failed” — what should I check first?
|
||||
|
||||
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
||||
|
||||
@@ -254,6 +254,14 @@ precedence, and troubleshooting.
|
||||
|
||||
### Enable sandboxing
|
||||
|
||||
If you plan to install packages in `setupCommand`, note:
|
||||
- Default `docker.network` is `"none"` (no egress).
|
||||
- `readOnlyRoot: true` blocks package installs.
|
||||
- `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`).
|
||||
Clawdbot auto-recreates containers when `setupCommand` (or docker config) changes
|
||||
unless the container was **recently used** (within ~5 minutes). Hot containers
|
||||
log a warning with the exact `clawdbot sandbox recreate ...` command.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
|
||||
@@ -12,6 +12,7 @@ Clawdbot ships two installer scripts (served from `clawd.bot`):
|
||||
|
||||
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
|
||||
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
|
||||
- `https://clawd.bot/install.ps1` — Windows PowerShell installer (npm by default; optional git install)
|
||||
|
||||
To see the current flags/behavior, run:
|
||||
|
||||
@@ -19,6 +20,12 @@ To see the current flags/behavior, run:
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
Windows (PowerShell) help:
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((iwr -useb https://clawd.bot/install.ps1))) -?
|
||||
```
|
||||
|
||||
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
||||
|
||||
## install.sh (recommended)
|
||||
@@ -73,3 +80,37 @@ Help:
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
## install.ps1 (Windows PowerShell)
|
||||
|
||||
What it does (high level):
|
||||
|
||||
- Ensure Node.js **22+** (winget/Chocolatey/Scoop or manual).
|
||||
- Choose install method:
|
||||
- `npm` (default): `npm install -g clawdbot@latest`
|
||||
- `git`: clone/build a source checkout and install a wrapper script
|
||||
- Runs `clawdbot doctor --non-interactive` on upgrades and git installs (best effort).
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
iwr -useb https://clawd.bot/install.ps1 | iex
|
||||
```
|
||||
|
||||
```powershell
|
||||
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git
|
||||
```
|
||||
|
||||
```powershell
|
||||
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git -GitDir "C:\\clawdbot"
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `CLAWDBOT_INSTALL_METHOD=git|npm`
|
||||
- `CLAWDBOT_GIT_DIR=...`
|
||||
|
||||
Git requirement:
|
||||
|
||||
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
||||
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||
@@ -18,6 +18,9 @@ This allows you to run multiple agents with different security profiles:
|
||||
- Family/work agents with restricted tools
|
||||
- Public-facing agents in sandboxes
|
||||
|
||||
`setupCommand` belongs under `sandbox.docker` (global or per-agent) and runs once
|
||||
when the container is created.
|
||||
|
||||
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
|
||||
|
||||
```
|
||||
|
||||
@@ -5,9 +5,7 @@ read_when:
|
||||
---
|
||||
# Clawdbot macOS IPC architecture
|
||||
|
||||
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
||||
|
||||
**Planned model:** add a local Unix socket between the **node service** and the **macOS app**. The app owns `system.run` (UI/TCC context); the node service forwards exec requests over IPC.
|
||||
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. There is no `clawdbot-mac` CLI; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
|
||||
|
||||
## Goals
|
||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||
@@ -15,11 +13,11 @@ read_when:
|
||||
- Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick.
|
||||
|
||||
## How it works
|
||||
### Gateway + node bridge (current)
|
||||
### Gateway + node bridge
|
||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||
|
||||
### Node service + app IPC (planned)
|
||||
### Node service + app IPC
|
||||
- A headless node service connects to the Gateway bridge.
|
||||
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
||||
- The app performs the exec in UI context, prompts if needed, and returns output.
|
||||
@@ -38,10 +36,6 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
- Security: bridge hosts require an allowed TeamID; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention).
|
||||
- See: [PeekabooBridge usage](/platforms/mac/peekaboo) for details.
|
||||
|
||||
### Mach/XPC
|
||||
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
||||
- Planned IPC keeps Unix sockets (no XPC helper).
|
||||
|
||||
## Operational flows
|
||||
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
||||
- Kills existing instances
|
||||
@@ -54,4 +48,4 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
||||
- All communication remains local-only; no network sockets are exposed.
|
||||
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
||||
- Planned IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
|
||||
- IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
|
||||
@@ -17,19 +17,17 @@ capabilities to the agent as a node.
|
||||
Speech Recognition, Automation/AppleScript).
|
||||
- Runs or connects to the Gateway (local or remote).
|
||||
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
|
||||
- Starts the local node host service in **remote** mode (launchd), and stops it in **local** mode.
|
||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
||||
|
||||
Planned:
|
||||
- Run a headless **node service** locally (launchd).
|
||||
- Keep `system.run` in the app (UI/TCC context), with the node service forwarding via IPC.
|
||||
|
||||
## Local vs remote mode
|
||||
|
||||
- **Local** (default): the app attaches to a running local Gateway if present;
|
||||
otherwise it enables the launchd service via `clawdbot daemon`.
|
||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||
a local process.
|
||||
The app starts the local **node host service** so the remote Gateway can reach this Mac.
|
||||
The app does not spawn the Gateway as a child process.
|
||||
|
||||
## Launchd control
|
||||
@@ -58,9 +56,9 @@ The macOS app presents itself as a node. Common commands:
|
||||
|
||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||
|
||||
Planned split:
|
||||
- Node service advertises the node surface to the Gateway.
|
||||
- macOS app performs `system.run` in UI context over IPC.
|
||||
Node service + app IPC:
|
||||
- When the headless node service is running (remote mode), it connects to the Gateway bridge.
|
||||
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
|
||||
|
||||
Diagram (SCI):
|
||||
```
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
summary: "Strict config validation + doctor-only migrations"
|
||||
read_when:
|
||||
- Designing or implementing config validation behavior
|
||||
- Working on config migrations or doctor workflows
|
||||
- Handling plugin config schemas or plugin load gating
|
||||
---
|
||||
# Strict config validation (doctor-only migrations)
|
||||
|
||||
## Goals
|
||||
- **Reject unknown config keys everywhere** (root + nested).
|
||||
- **Reject plugin config without a schema**; don’t load that plugin.
|
||||
- **Remove legacy auto-migration on load**; migrations run via doctor only.
|
||||
- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands.
|
||||
|
||||
## Non-goals
|
||||
- Backward compatibility on load (legacy keys do not auto-migrate).
|
||||
- Silent drops of unrecognized keys.
|
||||
|
||||
## Strict validation rules
|
||||
- Config must match the schema exactly at every level.
|
||||
- Unknown keys are validation errors (no passthrough at root or nested).
|
||||
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
|
||||
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
|
||||
|
||||
## Plugin schema enforcement
|
||||
- Each plugin provides a strict schema for its config (no passthrough).
|
||||
- Plugin load flow:
|
||||
1) Resolve plugin schema by plugin id.
|
||||
2) Validate config against the schema.
|
||||
3) If missing schema or invalid config: block plugin load, record error.
|
||||
- Error message includes:
|
||||
- Plugin id
|
||||
- Reason (missing schema / invalid config)
|
||||
- Path(s) that failed validation
|
||||
|
||||
## Doctor flow
|
||||
- Doctor runs **every time** config is loaded (dry-run by default).
|
||||
- If config invalid:
|
||||
- Print a summary + actionable errors.
|
||||
- Instruct: `clawdbot doctor --fix`.
|
||||
- `clawdbot doctor --fix`:
|
||||
- Applies migrations.
|
||||
- Removes unknown keys.
|
||||
- Writes updated config.
|
||||
|
||||
## Command gating (when config is invalid)
|
||||
Allowed (diagnostic-only):
|
||||
- `clawdbot doctor`
|
||||
- `clawdbot logs`
|
||||
- `clawdbot health`
|
||||
- `clawdbot help`
|
||||
- `clawdbot status`
|
||||
- `clawdbot service`
|
||||
|
||||
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
|
||||
|
||||
## Error UX format
|
||||
- Single summary header.
|
||||
- Grouped sections:
|
||||
- Unknown keys (full paths)
|
||||
- Legacy keys / migrations needed
|
||||
- Plugin load failures (plugin id + reason + path)
|
||||
|
||||
## Implementation touchpoints
|
||||
- `src/config/zod-schema.ts`: remove root passthrough; strict objects everywhere.
|
||||
- `src/config/zod-schema.providers.ts`: ensure strict channel schemas.
|
||||
- `src/config/validation.ts`: fail on unknown keys; do not apply legacy migrations.
|
||||
- `src/config/io.ts`: remove legacy auto-migrations; always run doctor dry-run.
|
||||
- `src/config/legacy*.ts`: move usage to doctor only.
|
||||
- `src/plugins/*`: add schema registry + gating.
|
||||
- CLI command gating in `src/cli`.
|
||||
|
||||
## Tests
|
||||
- Unknown key rejection (root + nested).
|
||||
- Plugin missing schema → plugin load blocked with clear error.
|
||||
- Invalid config → gateway startup blocked except diagnostic commands.
|
||||
- Doctor dry-run auto; `doctor --fix` writes corrected config.
|
||||
@@ -15,6 +15,11 @@ Use these when a task is clearly tied to a script; otherwise prefer the CLI.
|
||||
- Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`).
|
||||
- Assume scripts are host‑specific; read them before running on a new machine.
|
||||
|
||||
## Git hooks
|
||||
|
||||
- `scripts/setup-git-hooks.js`: best-effort setup for `core.hooksPath` when inside a git repo.
|
||||
- `scripts/format-staged.js`: pre-commit formatter for staged `src/` and `test/` files.
|
||||
|
||||
## Auth monitoring scripts
|
||||
|
||||
Auth monitoring scripts are documented here:
|
||||
|
||||
@@ -45,6 +45,7 @@ Use these in chat:
|
||||
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
|
||||
- Persists per session (stored as `responseUsage`).
|
||||
- OAuth auth **hides cost** (tokens only).
|
||||
- `/usage cost` → shows a local cost summary from Clawdbot session logs.
|
||||
|
||||
Other surfaces:
|
||||
|
||||
|
||||
@@ -14,13 +14,15 @@ runtime on the current machine.
|
||||
- Required: `--message <text>`
|
||||
- Session selection:
|
||||
- `--to <dest>` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or**
|
||||
- `--session-id <id>` reuses an existing session by id
|
||||
- `--session-id <id>` reuses an existing session by id, **or**
|
||||
- `--agent <id>` targets a configured agent directly (uses that agent's `main` session key)
|
||||
- Runs the same embedded agent runtime as normal inbound replies.
|
||||
- Thinking/verbose flags persist into the session store.
|
||||
- Output:
|
||||
- default: prints reply text (plus `MEDIA:<url>` lines)
|
||||
- `--json`: prints structured payload + metadata
|
||||
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`).
|
||||
- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session.
|
||||
|
||||
If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
|
||||
|
||||
@@ -28,16 +30,21 @@ If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
|
||||
|
||||
```bash
|
||||
clawdbot agent --to +15555550123 --message "status update"
|
||||
clawdbot agent --agent ops --message "Summarize logs"
|
||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
|
||||
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
|
||||
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
- `--local`: run locally (requires model provider API keys in your shell)
|
||||
- `--deliver`: send the reply to the chosen channel (requires `--to`)
|
||||
- `--channel`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
|
||||
- `--deliver`: send the reply to the chosen channel
|
||||
- `--channel`: delivery channel (`whatsapp|telegram|discord|slack|signal|imessage`, default: `whatsapp`)
|
||||
- `--reply-to`: delivery target override
|
||||
- `--reply-channel`: delivery channel override
|
||||
- `--reply-account`: delivery account id override
|
||||
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
|
||||
- `--verbose <on|full|off>`: persist verbose level
|
||||
- `--timeout <seconds>`: override agent timeout
|
||||
|
||||
@@ -40,6 +40,28 @@ Notes:
|
||||
- `tools.exec.security` (default: `deny`)
|
||||
- `tools.exec.ask` (default: `on-miss`)
|
||||
- `tools.exec.node` (default: unset)
|
||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
exec: {
|
||||
pathPrepend: ["~/bin", "/opt/oss/bin"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PATH handling
|
||||
|
||||
- `host=gateway`: uses the Gateway process `PATH`. Daemons install a minimal `PATH`:
|
||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
|
||||
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
||||
if the exec call already sets `env.PATH`.
|
||||
|
||||
Per-agent node binding (use the agent list index in config):
|
||||
|
||||
|
||||
+1
-1
@@ -356,7 +356,7 @@ Notes:
|
||||
Restart or apply updates to the running Gateway process (in-place).
|
||||
|
||||
Core actions:
|
||||
- `restart` (sends `SIGUSR1` to the current process; `clawdbot gateway` restart in-place)
|
||||
- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `clawdbot gateway` restart in-place)
|
||||
- `config.get` / `config.schema`
|
||||
- `config.apply` (validate + write config + restart + wake)
|
||||
- `update.run` (run update + restart + wake)
|
||||
|
||||
@@ -111,6 +111,8 @@ Note on sandboxing:
|
||||
- `requires.bins` is checked on the **host** at skill load time.
|
||||
- If an agent is sandboxed, the binary must also exist **inside the container**.
|
||||
Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image).
|
||||
`setupCommand` runs once after the container is created.
|
||||
Package installs also require network egress, a writable root FS, and a root user in the sandbox.
|
||||
Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI
|
||||
in the sandbox container to run there.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Text + native (when enabled):
|
||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full` (per-response usage footer)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
- `/stop`
|
||||
- `/restart`
|
||||
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
|
||||
@@ -91,7 +91,7 @@ Text-only:
|
||||
Notes:
|
||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
||||
- For full provider usage breakdown, use `clawdbot status --usage`.
|
||||
- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost).
|
||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { bluebubblesPlugin } from "./src/channel.js";
|
||||
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
|
||||
@@ -8,6 +9,7 @@ const plugin = {
|
||||
id: "bluebubbles",
|
||||
name: "BlueBubbles",
|
||||
description: "BlueBubbles channel plugin (macOS app)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setBlueBubblesRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: bluebubblesPlugin });
|
||||
|
||||
@@ -836,10 +836,20 @@ async function processMessage(
|
||||
const fromLabel = message.isGroup
|
||||
? `group:${peerId}`
|
||||
: message.senderName || `user:${message.senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "BlueBubbles",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
let chatGuidForActions = chatGuid;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
|
||||
const DEFAULT_API_KEY = "n/a";
|
||||
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
||||
@@ -61,6 +63,7 @@ const copilotProxyPlugin = {
|
||||
id: "copilot-proxy",
|
||||
name: "Copilot Proxy",
|
||||
description: "Local Copilot Proxy (VS Code LM) provider plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "copilot-proxy",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { discordPlugin } from "./src/channel.js";
|
||||
import { setDiscordRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
description: "Discord channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setDiscordRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: discordPlugin });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
@@ -360,6 +361,7 @@ const antigravityPlugin = {
|
||||
id: "google-antigravity-auth",
|
||||
name: "Google Antigravity Auth",
|
||||
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "google-antigravity",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { loginGeminiCliOAuth } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "google-gemini-cli";
|
||||
@@ -14,6 +16,7 @@ const geminiCliPlugin = {
|
||||
id: "google-gemini-cli-auth",
|
||||
name: "Google Gemini CLI Auth",
|
||||
description: "OAuth flow for Gemini CLI (Google Code Assist)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { imessagePlugin } from "./src/channel.js";
|
||||
import { setIMessageRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "imessage",
|
||||
name: "iMessage",
|
||||
description: "iMessage channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setIMessageRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: imessagePlugin });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
import { setMatrixRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
description: "Matrix channel plugin (matrix-js-sdk)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMatrixRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: matrixPlugin });
|
||||
|
||||
@@ -466,25 +466,34 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
isThreadRoot: event.isThreadRoot,
|
||||
});
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
});
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
timestamp: event.getTs() ?? undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: textWithId,
|
||||
});
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: bodyText,
|
||||
@@ -517,13 +526,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
OriginatingTo: `room:${roomId}`,
|
||||
});
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
logger.warn(
|
||||
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
|
||||
import type { CoreConfig, DmPolicy } from "./types.js";
|
||||
@@ -49,40 +50,86 @@ async function promptMatrixAllowFrom(params: {
|
||||
}): Promise<CoreConfig> {
|
||||
const { cfg, prompter } = params;
|
||||
const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Matrix allowFrom (user id)",
|
||||
placeholder: "@user:server",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
|
||||
if (!raw.includes(":")) return "Matrix user IDs should include a server (:@server)";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
const canResolve = Boolean(account.configured);
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...cfg.channels?.matrix,
|
||||
enabled: true,
|
||||
dm: {
|
||||
...cfg.channels?.matrix?.dm,
|
||||
policy: "allowlist",
|
||||
allowFrom: unique,
|
||||
const parseInput = (raw: string) =>
|
||||
raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
||||
|
||||
while (true) {
|
||||
const entry = await prompter.text({
|
||||
message: "Matrix allowFrom (username or user id)",
|
||||
placeholder: "@user:server",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInput(String(entry));
|
||||
const resolvedIds: string[] = [];
|
||||
let unresolved: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (isFullUserId(part)) {
|
||||
resolvedIds.push(part);
|
||||
continue;
|
||||
}
|
||||
if (!canResolve) {
|
||||
unresolved.push(part);
|
||||
continue;
|
||||
}
|
||||
const results = await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
query: part,
|
||||
limit: 5,
|
||||
}).catch(() => []);
|
||||
const match = results.find((result) => result.id);
|
||||
if (match?.id) {
|
||||
resolvedIds.push(match.id);
|
||||
if (results.length > 1) {
|
||||
await prompter.note(
|
||||
`Multiple matches for "${part}", using ${match.id}.`,
|
||||
"Matrix allowlist",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
unresolved.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
await prompter.note(
|
||||
`Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`,
|
||||
"Matrix allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unique = [
|
||||
...new Set([
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
...resolvedIds,
|
||||
]),
|
||||
];
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...cfg.channels?.matrix,
|
||||
enabled: true,
|
||||
dm: {
|
||||
...cfg.channels?.matrix?.dm,
|
||||
policy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
|
||||
@@ -121,6 +168,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.matrix.dm.allowFrom",
|
||||
getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
|
||||
promptAllowFrom: promptMatrixAllowFrom,
|
||||
};
|
||||
|
||||
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
const memoryCorePlugin = {
|
||||
id: "memory-core",
|
||||
name: "Memory (Core)",
|
||||
description: "File-backed memory search tools and CLI",
|
||||
kind: "memory",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
api.registerTool(
|
||||
(ctx) => {
|
||||
|
||||
@@ -24,6 +24,16 @@ const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||
"text-embedding-3-large": 3072,
|
||||
};
|
||||
|
||||
function assertAllowedKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowed: string[],
|
||||
label: string,
|
||||
) {
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
if (unknown.length === 0) return;
|
||||
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
||||
}
|
||||
|
||||
export function vectorDimsForModel(model: string): number {
|
||||
const dims = EMBEDDING_DIMENSIONS[model];
|
||||
if (!dims) {
|
||||
@@ -54,11 +64,13 @@ export const memoryConfigSchema = {
|
||||
throw new Error("memory config required");
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
assertAllowedKeys(cfg, ["embedding", "dbPath", "autoCapture", "autoRecall"], "memory config");
|
||||
|
||||
const embedding = cfg.embedding as Record<string, unknown> | undefined;
|
||||
if (!embedding || typeof embedding.apiKey !== "string") {
|
||||
throw new Error("embedding.apiKey is required");
|
||||
}
|
||||
assertAllowedKeys(embedding, ["apiKey", "model"], "embedding config");
|
||||
|
||||
const model = resolveEmbeddingModel(embedding);
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { msteamsPlugin } from "./src/channel.js";
|
||||
import { setMSTeamsRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "msteams",
|
||||
name: "Microsoft Teams",
|
||||
description: "Microsoft Teams channel plugin (Bot Framework)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMSTeamsRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: msteamsPlugin });
|
||||
|
||||
@@ -406,10 +406,20 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: envelopeFrom,
|
||||
timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
let combinedBody = body;
|
||||
@@ -421,15 +431,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: conversationType,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: conversationType,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
@@ -455,11 +466,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { resolveMSTeamsCredentials } from "./token.js";
|
||||
import {
|
||||
parseMSTeamsTeamEntry,
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
|
||||
const channel = "msteams" as const;
|
||||
@@ -38,6 +39,97 @@ function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
};
|
||||
}
|
||||
|
||||
function setMSTeamsAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function looksLikeGuid(value: string): boolean {
|
||||
return /^[0-9a-fA-F-]{16,}$/.test(value);
|
||||
}
|
||||
|
||||
async function promptMSTeamsAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const existing = params.cfg.channels?.msteams?.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist MS Teams DMs by display name, UPN/email, or user id.",
|
||||
"We resolve names to user IDs via Microsoft Graph when credentials allow.",
|
||||
"Examples:",
|
||||
"- alex@example.com",
|
||||
"- Alex Johnson",
|
||||
"- 00000000-0000-0000-0000-000000000000",
|
||||
].join("\n"),
|
||||
"MS Teams allowlist",
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const entry = await params.prompter.text({
|
||||
message: "MS Teams allowFrom (usernames or ids)",
|
||||
placeholder: "alex@example.com, Alex Johnson",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length === 0) {
|
||||
await params.prompter.note("Enter at least one user.", "MS Teams allowlist");
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = await resolveMSTeamsUserAllowlist({
|
||||
cfg: params.cfg,
|
||||
entries: parts,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!resolved) {
|
||||
const ids = parts.filter((part) => looksLikeGuid(part));
|
||||
if (ids.length !== parts.length) {
|
||||
await params.prompter.note(
|
||||
"Graph lookup unavailable. Use user IDs only.",
|
||||
"MS Teams allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const unique = [
|
||||
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
|
||||
];
|
||||
return setMSTeamsAllowFrom(params.cfg, unique);
|
||||
}
|
||||
|
||||
const unresolved = resolved.filter((item) => !item.resolved || !item.id);
|
||||
if (unresolved.length > 0) {
|
||||
await params.prompter.note(
|
||||
`Could not resolve: ${unresolved.map((item) => item.input).join(", ")}`,
|
||||
"MS Teams allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = resolved.map((item) => item.id as string);
|
||||
const unique = [
|
||||
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
|
||||
];
|
||||
return setMSTeamsAllowFrom(params.cfg, unique);
|
||||
}
|
||||
}
|
||||
|
||||
async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
@@ -106,6 +198,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.msteams.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptMSTeamsAllowFrom,
|
||||
};
|
||||
|
||||
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { loginQwenPortalOAuth } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "qwen-portal";
|
||||
@@ -30,6 +32,7 @@ const qwenPortalPlugin = {
|
||||
id: "qwen-portal-auth",
|
||||
name: "Qwen OAuth",
|
||||
description: "OAuth flow for Qwen (free-tier) models",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { signalPlugin } from "./src/channel.js";
|
||||
import { setSignalRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "signal",
|
||||
name: "Signal",
|
||||
description: "Signal channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setSignalRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: signalPlugin });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { slackPlugin } from "./src/channel.js";
|
||||
import { setSlackRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description: "Slack channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setSlackRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: slackPlugin });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { telegramPlugin } from "./src/channel.js";
|
||||
import { setTelegramRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
description: "Telegram channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setTelegramRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: telegramPlugin });
|
||||
|
||||
@@ -35,30 +35,36 @@ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
|
||||
// Provider-Specific Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const TelnyxConfigSchema = z.object({
|
||||
export const TelnyxConfigSchema = z
|
||||
.object({
|
||||
/** Telnyx API v2 key */
|
||||
apiKey: z.string().min(1).optional(),
|
||||
/** Telnyx connection ID (from Call Control app) */
|
||||
connectionId: z.string().min(1).optional(),
|
||||
/** Public key for webhook signature verification */
|
||||
publicKey: z.string().min(1).optional(),
|
||||
});
|
||||
})
|
||||
.strict();
|
||||
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
|
||||
|
||||
export const TwilioConfigSchema = z.object({
|
||||
export const TwilioConfigSchema = z
|
||||
.object({
|
||||
/** Twilio Account SID */
|
||||
accountSid: z.string().min(1).optional(),
|
||||
/** Twilio Auth Token */
|
||||
authToken: z.string().min(1).optional(),
|
||||
});
|
||||
})
|
||||
.strict();
|
||||
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
|
||||
|
||||
export const PlivoConfigSchema = z.object({
|
||||
export const PlivoConfigSchema = z
|
||||
.object({
|
||||
/** Plivo Auth ID (starts with MA/SA) */
|
||||
authId: z.string().min(1).optional(),
|
||||
/** Plivo Auth Token */
|
||||
authToken: z.string().min(1).optional(),
|
||||
});
|
||||
})
|
||||
.strict();
|
||||
export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -72,6 +78,7 @@ export const SttConfigSchema = z
|
||||
/** Whisper model to use */
|
||||
model: z.string().min(1).default("whisper-1"),
|
||||
})
|
||||
.strict()
|
||||
.default({ provider: "openai", model: "whisper-1" });
|
||||
export type SttConfig = z.infer<typeof SttConfigSchema>;
|
||||
|
||||
@@ -97,6 +104,7 @@ export const TtsConfigSchema = z
|
||||
*/
|
||||
instructions: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
|
||||
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
|
||||
|
||||
@@ -113,6 +121,7 @@ export const VoiceCallServeConfigSchema = z
|
||||
/** Webhook path */
|
||||
path: z.string().min(1).default("/voice/webhook"),
|
||||
})
|
||||
.strict()
|
||||
.default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
|
||||
export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>;
|
||||
|
||||
@@ -128,6 +137,7 @@ export const VoiceCallTailscaleConfigSchema = z
|
||||
/** Path for Tailscale serve/funnel (should usually match serve.path) */
|
||||
path: z.string().min(1).default("/voice/webhook"),
|
||||
})
|
||||
.strict()
|
||||
.default({ mode: "off", path: "/voice/webhook" });
|
||||
export type VoiceCallTailscaleConfig = z.infer<
|
||||
typeof VoiceCallTailscaleConfigSchema
|
||||
@@ -161,6 +171,7 @@ export const VoiceCallTunnelConfigSchema = z
|
||||
*/
|
||||
allowNgrokFreeTier: z.boolean().default(true),
|
||||
})
|
||||
.strict()
|
||||
.default({ provider: "none", allowNgrokFreeTier: true });
|
||||
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
|
||||
|
||||
@@ -183,6 +194,7 @@ export const OutboundConfigSchema = z
|
||||
/** Seconds to wait after TTS before auto-hangup in notify mode */
|
||||
notifyHangupDelaySec: z.number().int().nonnegative().default(3),
|
||||
})
|
||||
.strict()
|
||||
.default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
|
||||
export type OutboundConfig = z.infer<typeof OutboundConfigSchema>;
|
||||
|
||||
@@ -207,6 +219,7 @@ export const VoiceCallStreamingConfigSchema = z
|
||||
/** WebSocket path for media stream connections */
|
||||
streamPath: z.string().min(1).default("/voice/stream"),
|
||||
})
|
||||
.strict()
|
||||
.default({
|
||||
enabled: false,
|
||||
sttProvider: "openai-realtime",
|
||||
@@ -223,7 +236,8 @@ export type VoiceCallStreamingConfig = z.infer<
|
||||
// Main Voice Call Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const VoiceCallConfigSchema = z.object({
|
||||
export const VoiceCallConfigSchema = z
|
||||
.object({
|
||||
/** Enable voice call functionality */
|
||||
enabled: z.boolean().default(false),
|
||||
|
||||
@@ -307,7 +321,8 @@ export const VoiceCallConfigSchema = z.object({
|
||||
|
||||
/** Timeout for response generation in ms (default 30s) */
|
||||
responseTimeoutMs: z.number().int().positive().default(30000),
|
||||
});
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
|
||||
|
||||
|
||||
@@ -64,17 +64,24 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
||||
private readonly twimlStorage = new Map<string, string>();
|
||||
/** Track notify-mode calls to avoid streaming on follow-up callbacks. */
|
||||
private readonly notifyCalls = new Set<string>();
|
||||
|
||||
/**
|
||||
* Delete stored TwiML for a given `callId`.
|
||||
*
|
||||
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
|
||||
* webhook request (notify mode). Subsequent webhooks should not reuse it.
|
||||
* webhook request (notify mode). The notify guard is cleared separately.
|
||||
*/
|
||||
private deleteStoredTwiml(callId: string): void {
|
||||
this.twimlStorage.delete(callId);
|
||||
}
|
||||
|
||||
private clearNotifyState(callId: string): void {
|
||||
this.twimlStorage.delete(callId);
|
||||
this.notifyCalls.delete(callId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
|
||||
*
|
||||
@@ -87,7 +94,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
|
||||
if (!callIdMatch) return;
|
||||
|
||||
this.deleteStoredTwiml(callIdMatch[1]);
|
||||
this.clearNotifyState(callIdMatch[1]);
|
||||
}
|
||||
|
||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||
@@ -137,7 +144,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
*/
|
||||
private async apiRequest<T = unknown>(
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
params: Record<string, string | string[]>,
|
||||
options?: { allowNotFound?: boolean },
|
||||
): Promise<T> {
|
||||
return await twilioApiRequest<T>({
|
||||
@@ -257,12 +264,12 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
case "no-answer":
|
||||
case "failed":
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
this.clearNotifyState(callIdOverride);
|
||||
}
|
||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||
case "canceled":
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
this.clearNotifyState(callIdOverride);
|
||||
}
|
||||
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
||||
default:
|
||||
@@ -286,6 +293,9 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
if (!ctx) return TwilioProvider.EMPTY_TWIML;
|
||||
|
||||
const params = new URLSearchParams(ctx.rawBody);
|
||||
const type =
|
||||
typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
|
||||
const isStatusCallback = type === "status";
|
||||
const callStatus = params.get("CallStatus");
|
||||
const direction = params.get("Direction");
|
||||
const callIdFromQuery =
|
||||
@@ -297,13 +307,21 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
// Handle initial TwiML request (when Twilio first initiates the call)
|
||||
// Check if we have stored TwiML for this call (notify mode)
|
||||
if (callIdFromQuery) {
|
||||
if (callIdFromQuery && !isStatusCallback) {
|
||||
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
||||
if (storedTwiml) {
|
||||
// Clean up after serving (one-time use)
|
||||
this.deleteStoredTwiml(callIdFromQuery);
|
||||
return storedTwiml;
|
||||
}
|
||||
if (this.notifyCalls.has(callIdFromQuery)) {
|
||||
return TwilioProvider.EMPTY_TWIML;
|
||||
}
|
||||
}
|
||||
|
||||
// Status callbacks should not receive TwiML.
|
||||
if (isStatusCallback) {
|
||||
return TwilioProvider.EMPTY_TWIML;
|
||||
}
|
||||
|
||||
// Handle subsequent webhook requests (status callbacks, etc.)
|
||||
@@ -385,16 +403,18 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
// We now serve it from the webhook endpoint instead of sending inline
|
||||
if (input.inlineTwiml) {
|
||||
this.twimlStorage.set(input.callId, input.inlineTwiml);
|
||||
// Keep notify-mode calls out of streaming TwiML for any follow-up callbacks.
|
||||
this.notifyCalls.add(input.callId);
|
||||
}
|
||||
|
||||
// Build request params - always use URL-based TwiML.
|
||||
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
|
||||
const params: Record<string, string> = {
|
||||
const params: Record<string, string | string[]> = {
|
||||
To: input.to,
|
||||
From: input.from,
|
||||
Url: url.toString(), // TwiML serving endpoint
|
||||
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
|
||||
StatusCallbackEvent: "initiated ringing answered completed",
|
||||
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
|
||||
Timeout: "30",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { twilioApiRequest } from "./api.js";
|
||||
|
||||
describe("twilioApiRequest", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("encodes array params as repeated form fields", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => "",
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
await twilioApiRequest({
|
||||
baseUrl: "https://api.example.com",
|
||||
accountSid: "AC123",
|
||||
authToken: "token",
|
||||
endpoint: "/Calls.json",
|
||||
body: {
|
||||
To: "+15555550123",
|
||||
StatusCallbackEvent: ["initiated", "completed"],
|
||||
},
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
const body = init?.body as URLSearchParams | undefined;
|
||||
|
||||
expect(body?.getAll("StatusCallbackEvent")).toEqual([
|
||||
"initiated",
|
||||
"completed",
|
||||
]);
|
||||
expect(body?.get("To")).toBe("+15555550123");
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,34 @@ export async function twilioApiRequest<T = unknown>(params: {
|
||||
accountSid: string;
|
||||
authToken: string;
|
||||
endpoint: string;
|
||||
body: Record<string, string>;
|
||||
body: URLSearchParams | Record<string, string | string[]>;
|
||||
allowNotFound?: boolean;
|
||||
}): Promise<T> {
|
||||
const bodyParams =
|
||||
params.body instanceof URLSearchParams
|
||||
? params.body
|
||||
: Object.entries(params.body).reduce<URLSearchParams>(
|
||||
(acc, [key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
acc.append(key, entry);
|
||||
}
|
||||
} else if (typeof value === "string") {
|
||||
acc.append(key, value);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
new URLSearchParams(),
|
||||
);
|
||||
|
||||
const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams(params.body),
|
||||
// URL-encoded body required by Twilio, with repeated keys for arrays.
|
||||
body: bodyParams,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -26,4 +44,3 @@ export async function twilioApiRequest<T = unknown>(params: {
|
||||
const text = await response.text();
|
||||
return text ? (JSON.parse(text) as T) : (undefined as T);
|
||||
}
|
||||
|
||||
@@ -230,12 +230,14 @@ export async function startTailscaleTunnel(config: {
|
||||
throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
|
||||
}
|
||||
|
||||
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
|
||||
// --set-path already mounts the path; keep the target base URL to avoid /path/path.
|
||||
const localUrl = `http://127.0.0.1:${config.port}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"tailscale",
|
||||
[config.mode, "--bg", "--yes", "--set-path", config.path, localUrl],
|
||||
[config.mode, "--bg", "--yes", "--set-path", path, localUrl],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
@@ -247,7 +249,7 @@ export async function startTailscaleTunnel(config: {
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
const publicUrl = `https://${dnsName}${config.path}`;
|
||||
const publicUrl = `https://${dnsName}${path}`;
|
||||
console.log(
|
||||
`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`,
|
||||
);
|
||||
@@ -256,7 +258,7 @@ export async function startTailscaleTunnel(config: {
|
||||
publicUrl,
|
||||
provider: `tailscale-${config.mode}`,
|
||||
stop: async () => {
|
||||
await stopTailscaleTunnel(config.mode, config.path);
|
||||
await stopTailscaleTunnel(config.mode, path);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -79,7 +79,7 @@ function twilioSignature(params: {
|
||||
let dataToSign = params.url;
|
||||
const sortedParams = Array.from(
|
||||
new URLSearchParams(params.postBody).entries(),
|
||||
).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
|
||||
for (const [key, value] of sortedParams) {
|
||||
dataToSign += key + value;
|
||||
@@ -205,4 +205,33 @@ describe("verifyTwilioWebhook", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("sorts params using ASCII order", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const publicUrl = "https://example.com/voice/webhook";
|
||||
const postBody = "Z=first&a=second";
|
||||
|
||||
const signature = twilioSignature({
|
||||
authToken,
|
||||
url: publicUrl,
|
||||
postBody,
|
||||
});
|
||||
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-twilio-signature": signature,
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://local/voice/webhook",
|
||||
method: "POST",
|
||||
},
|
||||
authToken,
|
||||
{ publicUrl },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -23,9 +23,9 @@ export function validateTwilioSignature(
|
||||
// Build the string to sign: URL + sorted params (key+value pairs)
|
||||
let dataToSign = url;
|
||||
|
||||
// Sort params alphabetically and append key+value
|
||||
// Sort params by ASCII order (Twilio spec) and append key+value.
|
||||
const sortedParams = Array.from(params.entries()).sort((a, b) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
|
||||
);
|
||||
|
||||
for (const [key, value] of sortedParams) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { whatsappPlugin } from "./src/channel.js";
|
||||
import { setWhatsAppRuntime } from "./src/runtime.js";
|
||||
@@ -7,6 +8,7 @@ const plugin = {
|
||||
id: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
description: "WhatsApp channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setWhatsAppRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: whatsappPlugin });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
||||
import { handleZaloWebhookRequest } from "./src/monitor.js";
|
||||
@@ -8,6 +9,7 @@ const plugin = {
|
||||
id: "zalo",
|
||||
name: "Zalo",
|
||||
description: "Zalo channel plugin (Bot API)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setZaloRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
|
||||
|
||||
@@ -530,10 +530,20 @@ async function processMessageWithPipeline(params: {
|
||||
}
|
||||
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Zalo",
|
||||
from: fromLabel,
|
||||
timestamp: date ? date * 1000 : undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
@@ -560,9 +570,6 @@ async function processMessageWithPipeline(params: {
|
||||
OriginatingTo: `zalo:${chatId}`,
|
||||
});
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
|
||||
@@ -207,6 +207,17 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.zalo.allowFrom",
|
||||
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
|
||||
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as ClawdbotConfig, policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const id =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultZaloAccountId(cfg as ClawdbotConfig);
|
||||
return promptZaloAllowFrom({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
prompter,
|
||||
accountId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { zalouserPlugin } from "./src/channel.js";
|
||||
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
|
||||
@@ -8,6 +9,7 @@ const plugin = {
|
||||
id: "zalouser",
|
||||
name: "Zalo Personal",
|
||||
description: "Zalo personal account messaging via zca-cli",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setZalouserRuntime(api.runtime);
|
||||
// Register channel plugin (for onboarding & gateway)
|
||||
|
||||
@@ -274,10 +274,20 @@ async function processMessage(
|
||||
});
|
||||
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
@@ -301,9 +311,6 @@ async function processMessage(
|
||||
OriginatingTo: `zalouser:${chatId}`,
|
||||
});
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
checkZcaAuthenticated,
|
||||
} from "./accounts.js";
|
||||
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
|
||||
import type { ZcaGroup } from "./types.js";
|
||||
import type { ZcaFriend, ZcaGroup } from "./types.js";
|
||||
|
||||
const channel = "zalouser" as const;
|
||||
|
||||
@@ -67,25 +67,73 @@ async function promptZalouserAllowFrom(params: {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Zalouser allowFrom (user id)",
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
const parseInput = (raw: string) =>
|
||||
raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const resolveUserId = async (input: string): Promise<string | null> => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^\d+$/.test(trimmed)) return trimmed;
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) return null;
|
||||
const result = await runZca(["friend", "find", trimmed], {
|
||||
profile: resolved.profile,
|
||||
timeout: 15000,
|
||||
});
|
||||
if (!result.ok) return null;
|
||||
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
|
||||
const rows = Array.isArray(parsed) ? parsed : [];
|
||||
const match = rows[0];
|
||||
if (!match?.userId) return null;
|
||||
if (rows.length > 1) {
|
||||
await prompter.note(
|
||||
`Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`,
|
||||
"Zalo Personal allowlist",
|
||||
);
|
||||
}
|
||||
return String(match.userId);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const entry = await prompter.text({
|
||||
message: "Zalouser allowFrom (username or user id)",
|
||||
placeholder: "Alice, 123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInput(String(entry));
|
||||
const results = await Promise.all(parts.map((part) => resolveUserId(part)));
|
||||
const unresolved = parts.filter((_, idx) => !results[idx]);
|
||||
if (unresolved.length > 0) {
|
||||
await prompter.note(
|
||||
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`,
|
||||
"Zalo Personal allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
...(results.filter(Boolean) as string[]),
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
@@ -93,32 +141,19 @@ async function promptZalouserAllowFrom(params: {
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
function setZalouserGroupPolicy(
|
||||
@@ -237,6 +272,17 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
allowFromKey: "channels.zalouser.allowFrom",
|
||||
getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
|
||||
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as ClawdbotConfig, policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const id =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultZalouserAccountId(cfg as ClawdbotConfig);
|
||||
return promptZalouserAllowFrom({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
prompter,
|
||||
accountId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
[ -z "$ROOT" ] && exit 0
|
||||
node "$ROOT/scripts/format-staged.js"
|
||||
+22
-18
@@ -55,6 +55,9 @@
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"scripts/postinstall.js",
|
||||
"scripts/format-staged.js",
|
||||
"scripts/setup-git-hooks.js",
|
||||
"git-hooks/**",
|
||||
"dist/terminal/**",
|
||||
"dist/routing/**",
|
||||
"dist/utils/**",
|
||||
@@ -65,27 +68,27 @@
|
||||
"dist/whatsapp/**"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun src/entry.ts",
|
||||
"dev": "node scripts/run-node.mjs",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"prepack": "pnpm build",
|
||||
"docs:list": "bun scripts/docs-list.ts",
|
||||
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
|
||||
"docs:list": "node scripts/docs-list.js",
|
||||
"docs:bin": "node scripts/build-docs-list.mjs",
|
||||
"docs:dev": "cd docs && mint dev",
|
||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||
"build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts && bun scripts/copy-hook-metadata.ts && bun scripts/write-build-info.ts",
|
||||
"plugins:sync": "bun scripts/sync-plugin-versions.ts",
|
||||
"release:check": "bun scripts/release-check.ts",
|
||||
"build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
|
||||
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
||||
"release:check": "node --import tsx scripts/release-check.ts",
|
||||
"ui:install": "node scripts/ui.js install",
|
||||
"ui:dev": "node scripts/ui.js dev",
|
||||
"ui:build": "node scripts/ui.js build",
|
||||
"start": "bun src/entry.ts",
|
||||
"clawdbot": "bun src/entry.ts",
|
||||
"gateway:watch": "bun --watch src/entry.ts gateway --force",
|
||||
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway",
|
||||
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway --reset",
|
||||
"tui": "bun src/entry.ts tui",
|
||||
"tui:dev": "CLAWDBOT_PROFILE=dev bun src/entry.ts tui",
|
||||
"clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json",
|
||||
"start": "node scripts/run-node.mjs",
|
||||
"clawdbot": "node scripts/run-node.mjs",
|
||||
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
|
||||
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
|
||||
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
|
||||
"tui": "node scripts/run-node.mjs tui",
|
||||
"tui:dev": "CLAWDBOT_PROFILE=dev node scripts/run-node.mjs tui",
|
||||
"clawdbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
|
||||
"ios:gen": "cd apps/ios && xcodegen generate",
|
||||
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
|
||||
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
|
||||
@@ -108,7 +111,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "pnpm --dir ui test",
|
||||
"test:force": "bun scripts/test-force.ts",
|
||||
"test:force": "node --import tsx scripts/test-force.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
|
||||
@@ -126,11 +129,11 @@
|
||||
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
|
||||
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
|
||||
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
||||
"protocol:gen": "bun scripts/protocol-gen.ts",
|
||||
"protocol:gen:swift": "bun scripts/protocol-gen-swift.ts",
|
||||
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
|
||||
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
|
||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -161,6 +164,7 @@
|
||||
"chalk": "^5.6.2",
|
||||
"chokidar": "^5.0.0",
|
||||
"chromium-bidi": "12.0.1",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^14.0.2",
|
||||
"croner": "^9.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
|
||||
Generated
+11
-8
@@ -76,6 +76,9 @@ importers:
|
||||
chromium-bidi:
|
||||
specifier: 12.0.1
|
||||
version: 12.0.1(devtools-protocol@0.0.1561482)
|
||||
cli-highlight:
|
||||
specifier: ^2.1.11
|
||||
version: 2.1.11
|
||||
commander:
|
||||
specifier: ^14.0.2
|
||||
version: 14.0.2
|
||||
@@ -265,7 +268,13 @@ importers:
|
||||
specifier: 40.0.0
|
||||
version: 40.0.0
|
||||
|
||||
extensions/memory:
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/memory-lancedb:
|
||||
dependencies:
|
||||
'@lancedb/lancedb':
|
||||
specifier: ^0.15.0
|
||||
@@ -277,12 +286,6 @@ importers:
|
||||
specifier: ^4.104.0
|
||||
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
|
||||
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/msteams:
|
||||
dependencies:
|
||||
'@microsoft/agents-hosting':
|
||||
@@ -6625,7 +6628,7 @@ snapshots:
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
'@types/node': 25.0.9
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node@10.17.60': {}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const binDir = path.join(root, "bin");
|
||||
const scriptPath = path.join(root, "scripts", "docs-list.js");
|
||||
const binPath = path.join(binDir, "docs-list");
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const wrapper = `#!/usr/bin/env node\nimport { spawnSync } from "node:child_process";\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst here = path.dirname(fileURLToPath(import.meta.url));\nconst script = path.join(here, "..", "scripts", "docs-list.js");\n\nconst result = spawnSync(process.execPath, [script], { stdio: "inherit" });\nprocess.exit(result.status ?? 1);\n`;
|
||||
|
||||
fs.writeFileSync(binPath, wrapper, { mode: 0o755 });
|
||||
@@ -49,6 +49,16 @@ for file in "${files[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Prevent staging node_modules even if a path is forced.
|
||||
for file in "${files[@]}"; do
|
||||
case "$file" in
|
||||
*node_modules* | */node_modules | */node_modules/* | node_modules)
|
||||
printf 'Error: node_modules paths are not allowed: %s\n' "$file" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
last_commit_error=''
|
||||
|
||||
run_git_commit() {
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
#!/usr/bin/env bun
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
process.stdout.on('error', (error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EPIPE') {
|
||||
if (error?.code === 'EPIPE') {
|
||||
process.exit(0);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const DOCS_DIR = join(process.cwd(), 'docs');
|
||||
if (!existsSync(DOCS_DIR)) {
|
||||
console.error('docs:list: missing docs directory. Run from repo root.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!statSync(DOCS_DIR).isDirectory()) {
|
||||
console.error('docs:list: docs path is not a directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const EXCLUDED_DIRS = new Set(['archive', 'research']);
|
||||
|
||||
function compactStrings(values: unknown[]): string[] {
|
||||
const result: string[] = [];
|
||||
/**
|
||||
* @param {unknown[]} values
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function compactStrings(values) {
|
||||
const result = [];
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
@@ -28,9 +40,14 @@ function compactStrings(values: unknown[]): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function walkMarkdownFiles(dir: string, base: string = dir): string[] {
|
||||
/**
|
||||
* @param {string} dir
|
||||
* @param {string} base
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function walkMarkdownFiles(dir, base = dir) {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) {
|
||||
continue;
|
||||
@@ -48,11 +65,11 @@ function walkMarkdownFiles(dir: string, base: string = dir): string[] {
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function extractMetadata(fullPath: string): {
|
||||
summary: string | null;
|
||||
readWhen: string[];
|
||||
error?: string;
|
||||
} {
|
||||
/**
|
||||
* @param {string} fullPath
|
||||
* @returns {{ summary: string | null; readWhen: string[]; error?: string }}
|
||||
*/
|
||||
function extractMetadata(fullPath) {
|
||||
const content = readFileSync(fullPath, 'utf8');
|
||||
|
||||
if (!content.startsWith('---')) {
|
||||
@@ -67,9 +84,9 @@ function extractMetadata(fullPath: string): {
|
||||
const frontMatter = content.slice(3, endIndex).trim();
|
||||
const lines = frontMatter.split('\n');
|
||||
|
||||
let summaryLine: string | null = null;
|
||||
const readWhen: string[] = [];
|
||||
let collectingField: 'read_when' | null = null;
|
||||
let summaryLine = null;
|
||||
const readWhen = [];
|
||||
let collectingField = null;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
@@ -85,7 +102,7 @@ function extractMetadata(fullPath: string): {
|
||||
const inline = line.slice('read_when:'.length).trim();
|
||||
if (inline.startsWith('[') && inline.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(inline.replace(/'/g, '"')) as unknown;
|
||||
const parsed = JSON.parse(inline.replace(/'/g, '"'));
|
||||
if (Array.isArray(parsed)) {
|
||||
readWhen.push(...compactStrings(parsed));
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const OXFMT_EXTENSIONS = new Set([
|
||||
".cjs",
|
||||
".js",
|
||||
".json",
|
||||
".jsonc",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
]);
|
||||
|
||||
function getRepoRoot() {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(here, "..");
|
||||
}
|
||||
|
||||
function runGitCommand(args, options = {}) {
|
||||
return spawnSync("git", args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf-8",
|
||||
stdio: options.stdio ?? "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
function splitNullDelimited(value) {
|
||||
if (!value) return [];
|
||||
const text = String(value);
|
||||
return text.split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeGitPath(filePath) {
|
||||
return filePath.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function filterOxfmtTargets(paths) {
|
||||
return paths
|
||||
.map(normalizeGitPath)
|
||||
.filter((filePath) =>
|
||||
(filePath.startsWith("src/") || filePath.startsWith("test/")) &&
|
||||
OXFMT_EXTENSIONS.has(path.posix.extname(filePath)),
|
||||
);
|
||||
}
|
||||
|
||||
function findPartiallyStagedFiles(stagedFiles, unstagedFiles) {
|
||||
const unstaged = new Set(unstagedFiles.map(normalizeGitPath));
|
||||
return stagedFiles.filter((filePath) => unstaged.has(normalizeGitPath(filePath)));
|
||||
}
|
||||
|
||||
function filterOutPartialTargets(targets, partialTargets) {
|
||||
if (partialTargets.length === 0) return targets;
|
||||
const partial = new Set(partialTargets.map(normalizeGitPath));
|
||||
return targets.filter((filePath) => !partial.has(normalizeGitPath(filePath)));
|
||||
}
|
||||
|
||||
function resolveOxfmtCommand(repoRoot) {
|
||||
const binName = process.platform === "win32" ? "oxfmt.cmd" : "oxfmt";
|
||||
const local = path.join(repoRoot, "node_modules", ".bin", binName);
|
||||
if (fs.existsSync(local)) {
|
||||
return { command: local, args: [] };
|
||||
}
|
||||
|
||||
const result = spawnSync("oxfmt", ["--version"], { stdio: "ignore" });
|
||||
if (result.status === 0) {
|
||||
return { command: "oxfmt", args: [] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGitPaths(args, repoRoot) {
|
||||
const result = runGitCommand(args, { cwd: repoRoot });
|
||||
if (result.status !== 0) return [];
|
||||
return splitNullDelimited(result.stdout ?? "");
|
||||
}
|
||||
|
||||
function formatFiles(repoRoot, oxfmt, files) {
|
||||
const result = spawnSync(oxfmt.command, ["--write", ...oxfmt.args, ...files], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function stageFiles(repoRoot, files) {
|
||||
if (files.length === 0) return true;
|
||||
const result = runGitCommand(["add", "--", ...files], { cwd: repoRoot, stdio: "inherit" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const repoRoot = getRepoRoot();
|
||||
const staged = getGitPaths([
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--diff-filter=ACMR",
|
||||
], repoRoot);
|
||||
const targets = filterOxfmtTargets(staged);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const unstaged = getGitPaths(["diff", "--name-only", "-z"], repoRoot);
|
||||
const partial = findPartiallyStagedFiles(targets, unstaged);
|
||||
if (partial.length > 0) {
|
||||
process.stderr.write("[pre-commit] Skipping partially staged files:\n");
|
||||
for (const filePath of partial) {
|
||||
process.stderr.write(`- ${filePath}\n`);
|
||||
}
|
||||
process.stderr.write("Stage full files to format them automatically.\n");
|
||||
}
|
||||
|
||||
const filteredTargets = filterOutPartialTargets(targets, partial);
|
||||
if (filteredTargets.length === 0) return;
|
||||
|
||||
const oxfmt = resolveOxfmtCommand(repoRoot);
|
||||
if (!oxfmt) {
|
||||
process.stderr.write("[pre-commit] oxfmt not found; skipping format.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formatFiles(repoRoot, oxfmt, filteredTargets)) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stageFiles(repoRoot, filteredTargets)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
filterOxfmtTargets,
|
||||
filterOutPartialTargets,
|
||||
findPartiallyStagedFiles,
|
||||
getRepoRoot,
|
||||
normalizeGitPath,
|
||||
resolveOxfmtCommand,
|
||||
splitNullDelimited,
|
||||
};
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main();
|
||||
}
|
||||
Loaded 100 of 428 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user