Compare commits

...

49 Commits

Author SHA1 Message Date
Peter Steinberger
a36dc285d6 fix: guard console settings recursion (#1555) (thanks @travisp) 2026-01-24 03:12:19 +00:00
Travis
704a3ba51c Logging: guard console settings recursion 2026-01-24 03:06:34 +00:00
Peter Steinberger
b6591c3f69 fix: add log hint for agent failure (#1550) (thanks @sweepies) 2026-01-24 02:56:38 +00:00
google-labs-jules[bot]
e6fdbae79b Fix formatting of 'Agent failed before reply' error messages
- Remove hardcoded period after error message to avoid double periods
- Move 'Check gateway logs for details' to a new line for better readability
2026-01-24 02:54:42 +00:00
Peter Steinberger
a4e57d3ac4 fix: align service path tests with platform delimiters 2026-01-24 02:34:54 +00:00
Peter Steinberger
1d862cf5c2 fix: add readability fallback extraction 2026-01-24 02:15:13 +00:00
Peter Steinberger
0840029982 fix: stabilize embedded runner queueing 2026-01-24 02:05:41 +00:00
Peter Steinberger
309fcc5321 fix: publish llm-task docs and harden tool 2026-01-24 01:44:51 +00:00
Peter Steinberger
00ae21bed2 fix: inline auth probe errors in status table 2026-01-24 01:37:08 +00:00
Peter Steinberger
00fd57b8f5 fix: honor wildcard tool allowlists 2026-01-24 01:30:44 +00:00
Peter Steinberger
aabe0bed30 fix: clean wrapped banner tagline 2026-01-24 01:26:17 +00:00
Peter Steinberger
350131b4d7 fix: improve web image optimization 2026-01-24 01:18:58 +00:00
Vignesh
95d45c0aa7 feat: add optional llm-task JSON-only tool (#1498)
* feat(llm-task): add optional JSON-only LLM task tool

* fix(llm-task): fix invalid package.json

* fix(llm-task): fix invalid plugin manifest JSON

* fix(llm-task): fix index.ts import quoting

* fix(llm-task): load embedded runner from src or bundled dist
2026-01-24 01:18:47 +00:00
Peter Steinberger
cb06e133ca docs: update bedrock discovery changelog ref (#1553) (thanks @fal3) 2026-01-24 01:18:33 +00:00
Peter Steinberger
4e77483051 fix: refine bedrock discovery defaults (#1543) (thanks @fal3) 2026-01-24 01:18:33 +00:00
Peter Steinberger
81535d512a fix: clarify auth order exclusions 2026-01-24 01:18:03 +00:00
Alex Fallah
8effb557d5 feat: add dynamic Bedrock model discovery
Add automatic discovery of AWS Bedrock models using ListFoundationModels API.
When AWS credentials are detected, models that support streaming and text output
are automatically discovered and made available.

- Add @aws-sdk/client-bedrock dependency
- Add discoverBedrockModels() with caching (default 1 hour)
- Add resolveImplicitBedrockProvider() for auto-registration
- Add BedrockDiscoveryConfig for optional filtering by provider/region
- Filter to active, streaming, text-output models only
- Update docs/bedrock.md with auto-discovery documentation
2026-01-24 01:15:06 +00:00
Peter Steinberger
c66b1fd18b docs: add changelog entry for sidebar fix (#1515) (thanks @pookNast) 2026-01-24 01:00:19 +00:00
pookNast
c04f8ba1ea fix(ui): Make sidebar sticky while scrolling content (#1515)
The left navigation sidebar now stays fixed when scrolling through
long content pages like /skills. Changed .shell from min-height to
fixed height with overflow: hidden, allowing nav and content to
scroll independently within their grid cells.

Co-authored-by: pookNast <pook@nast.local>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:58:37 +00:00
Peter Steinberger
c1b7f6b6ba Merge pull request #1544 from wca4a/feature/add-tlon-plugin
Add Tlon/Urbit channel plugin
2026-01-24 00:57:44 +00:00
Peter Steinberger
e4708b3b99 test: relax tailscale binary expectations 2026-01-24 00:49:04 +00:00
Peter Steinberger
f938f6617b docs: extend cron vs heartbeat guide 2026-01-24 00:42:56 +00:00
justyannicc
e882f7d207 docs: add cron vs heartbeat decision guide
- New docs/automation/cron-vs-heartbeat.md with complete guidance
- Cross-links from heartbeat.md and cron-jobs.md
- Updated AGENTS.md template with practical guidance
- Added navigation entry in docs.json
2026-01-24 00:42:56 +00:00
AJ (@techfren)
e38fd8603f docs: remove misplaced Google Docs Editor from showcase (#1547)
- Was incorrectly placed in Voice & Phone section
- Not a Clawdbot project (Claude Code skill)
- No valid link available
2026-01-24 00:42:48 +00:00
Travis
89283aa788 Plugins: move clawdbot to devDependencies + add zod 2026-01-24 00:42:33 +00:00
Peter Steinberger
f7dc27f2d0 fix: move probe errors below table 2026-01-24 00:32:49 +00:00
google-labs-jules[bot]
ed560e466f fix(doctor): align sandbox image check with main logic
Updated `dockerImageExists` in `src/commands/doctor-sandbox.ts` to mirror the logic in `src/agents/sandbox/docker.ts`. It now re-throws errors unless they are explicitly "No such image" errors.
2026-01-24 00:30:24 +00:00
google-labs-jules[bot]
b5f1dc9d95 chore(tests): remove reproduction test
Removed the test file `src/agents/sandbox/docker.test.ts` as requested in code review.
2026-01-24 00:30:24 +00:00
google-labs-jules[bot]
f58ad7625f fix(sandbox): simplify docker image check
Simplify the stderr check in `dockerImageExists` to only look for "No such image", as requested in code review.
2026-01-24 00:30:24 +00:00
google-labs-jules[bot]
49c6d8019f fix(sandbox): improve docker image existence check error handling
Previously, `dockerImageExists` assumed any error from `docker image inspect` meant the image did not exist. This masked other errors like socket permission issues.

This change:
- Modifies `dockerImageExists` to inspect stderr when the exit code is non-zero.
- Returns `false` only if the error explicitly indicates "No such image" or "No such object".
- Throws an error with the stderr content for all other failures.
- Adds a reproduction test in `src/agents/sandbox/docker.test.ts`.
2026-01-24 00:30:24 +00:00
Peter Steinberger
86db180a17 docs: clarify PR merge preference 2026-01-24 00:30:11 +00:00
Peter Steinberger
c69111a4e6 Merge branch 'main' into feature/add-tlon-plugin 2026-01-24 00:27:24 +00:00
Peter Steinberger
31e59cd583 fix: hide probe logs without verbose 2026-01-24 00:27:05 +00:00
Peter Steinberger
d2bfcd70e7 fix: stabilize tests and sync protocol models 2026-01-24 00:25:58 +00:00
Peter Steinberger
12d22e1c89 chore: update clawtributors 2026-01-24 00:25:39 +00:00
Peter Steinberger
75cb78a5b1 chore: drop tlon node_modules 2026-01-24 00:25:39 +00:00
Peter Steinberger
791b568f78 feat: add tlon channel plugin 2026-01-24 00:25:39 +00:00
william arzt
d46642319b Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network.

Features:
- DM and group chat support
- SSE-based real-time message monitoring
- Auto-discovery of group channels
- Thread replies and reactions
- Integration with Urbit's HTTP API

This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms.

Implementation includes:
- Plugin registration via ClawdbotPluginApi
- Outbound delivery with sendText and sendMedia
- Gateway adapter for inbound message handling
- Urbit SSE client for event streaming
- Core bridge for Clawdbot runtime integration

Co-authored-by: William Arzt <william@arzt.co>
2026-01-24 00:25:38 +00:00
Peter Steinberger
a96d37ca69 docs: clarify plugin dependency rules 2026-01-24 00:23:21 +00:00
Peter Steinberger
f8046268bc chore: drop tlon node_modules 2026-01-24 00:18:58 +00:00
Peter Steinberger
9cdd0c28be feat: add tlon channel plugin 2026-01-24 00:17:58 +00:00
Peter Steinberger
05b0b82937 fix: guard tailscale sudo fallback (#1551) (thanks @sweepies) 2026-01-24 00:17:20 +00:00
google-labs-jules[bot]
908d9331af feat: use sudo fallback for tailscale configuration commands
To avoid permission denied errors when modifying Tailscale configuration (serve/funnel),
we now attempt the command directly first. If it fails, we catch the error and retry
with `sudo -n`. This preserves existing behavior for users where it works, but
attempts to escalate privileges (non-interactively) if needed.

- Added `execWithSudoFallback` helper in `src/infra/tailscale.ts`.
- Updated `ensureFunnel`, `enableTailscaleServe`, `disableTailscaleServe`,
  `enableTailscaleFunnel`, and `disableTailscaleFunnel` to use the fallback helper.
- Added tests in `src/infra/tailscale.test.ts` to verify fallback behavior.
2026-01-24 00:17:20 +00:00
google-labs-jules[bot]
29f0463f65 feat: use sudo for tailscale configuration commands
To avoid permission denied errors when modifying Tailscale configuration (serve/funnel),
we now prepend `sudo -n` to these commands. This ensures that if the user has appropriate
sudo privileges (specifically passwordless for these commands or generally), the operation
succeeds. If sudo fails (e.g. requires password non-interactively), it will throw an error
which is caught and logged as a warning, preserving existing behavior but attempting to escalate privileges first.

- Updated `ensureFunnel` to use `sudo -n` for the enabling step.
- Updated `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, `disableTailscaleFunnel` to use `sudo -n`.
2026-01-24 00:17:20 +00:00
google-labs-jules[bot]
66f353fe7a feat: use sudo for tailscale configuration commands
To avoid permission denied errors when modifying Tailscale configuration (serve/funnel),
we now prepend `sudo -n` to these commands. This ensures that if the user has appropriate
sudo privileges (specifically passwordless for these commands or generally), the operation
succeeds. If sudo fails (e.g. requires password non-interactively), it will throw an error
which is caught and logged as a warning, preserving existing behavior but attempting to escalate privileges first.

- Updated `ensureFunnel` to use `sudo -n` for the enabling step.
- Updated `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, `disableTailscaleFunnel` to use `sudo -n`.
- Added tests in `src/infra/tailscale.test.ts` to verify `sudo` usage.
2026-01-24 00:17:20 +00:00
Robby
511a0c22b7 fix(sessions): reset token counts to 0 on /new (#1523)
- Set inputTokens, outputTokens, totalTokens to 0 in sessions.reset
- Clear TUI sessionInfo tokens immediately before async reset
- Prevents stale token display after session reset

Fixes #1523
2026-01-24 00:15:42 +00:00
Peter Steinberger
da3f2b4898 fix: table auth probe output 2026-01-24 00:11:04 +00:00
Peter Steinberger
438e782f81 fix: silence probe timeouts 2026-01-24 00:11:04 +00:00
william arzt
24de8cecf6 Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network.

Features:
- DM and group chat support
- SSE-based real-time message monitoring
- Auto-discovery of group channels
- Thread replies and reactions
- Integration with Urbit's HTTP API

This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms.

Implementation includes:
- Plugin registration via ClawdbotPluginApi
- Outbound delivery with sendText and sendMedia
- Gateway adapter for inbound message handling
- Urbit SSE client for event streaming
- Core bridge for Clawdbot runtime integration

Co-authored-by: William Arzt <william@arzt.co>
2026-01-23 15:17:07 -05:00
101 changed files with 5740 additions and 372 deletions

View File

@@ -7,6 +7,7 @@
- Tests: colocated `*.test.ts`.
- 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.
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
- 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/`
@@ -76,6 +77,7 @@
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless its truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.

View File

@@ -2,21 +2,40 @@
Docs: https://docs.clawd.bot
## 2026.1.23
## 2026.1.23 (Unreleased)
### Changes
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
### Fixes
- Logging: guard console settings resolution to avoid recursion on config warnings. (#1555) Thanks @travisp.
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- CLI: hide auth probe timeout warnings from embedded runs.
- CLI: render auth probe results as a table in `clawdbot models status`.
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
- CLI: move auth probe errors below the table to reduce wrapping.
- CLI: prevent ANSI color bleed when table cells wrap.
- CLI: explain when auth profiles are excluded by auth.order in probe details.
- CLI: drop the em dash when the banner tagline wraps to a second line.
- CLI: inline auth probe errors in status rows to reduce wrapping.
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
- Daemon: use platform PATH delimiters when building minimal service paths.
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.

View File

@@ -478,28 +478,29 @@ Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/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/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a>
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
<a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
<a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a>
<a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
<a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a>
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/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/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/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/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a>
<a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a>
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a>
<a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/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/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/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/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
<a href="https://github.com/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/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a>
<a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
<a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a>
<a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a>
<a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/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/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a>
<a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
<a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a>
<a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
<a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
<a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
<a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>

View File

@@ -1973,25 +1973,25 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let cwd: String?
public let host: String?
public let security: String?
public let ask: String?
public let agentid: String?
public let resolvedpath: String?
public let sessionkey: String?
public let cwd: AnyCodable?
public let host: AnyCodable?
public let security: AnyCodable?
public let ask: AnyCodable?
public let agentid: AnyCodable?
public let resolvedpath: AnyCodable?
public let sessionkey: AnyCodable?
public let timeoutms: Int?
public init(
id: String?,
command: String,
cwd: String?,
host: String?,
security: String?,
ask: String?,
agentid: String?,
resolvedpath: String?,
sessionkey: String?,
cwd: AnyCodable?,
host: AnyCodable?,
security: AnyCodable?,
ask: AnyCodable?,
agentid: AnyCodable?,
resolvedpath: AnyCodable?,
sessionkey: AnyCodable?,
timeoutms: Int?
) {
self.id = id

View File

@@ -1973,25 +1973,25 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let cwd: String?
public let host: String?
public let security: String?
public let ask: String?
public let agentid: String?
public let resolvedpath: String?
public let sessionkey: String?
public let cwd: AnyCodable?
public let host: AnyCodable?
public let security: AnyCodable?
public let ask: AnyCodable?
public let agentid: AnyCodable?
public let resolvedpath: AnyCodable?
public let sessionkey: AnyCodable?
public let timeoutms: Int?
public init(
id: String?,
command: String,
cwd: String?,
host: String?,
security: String?,
ask: String?,
agentid: String?,
resolvedpath: String?,
sessionkey: String?,
cwd: AnyCodable?,
host: AnyCodable?,
security: AnyCodable?,
ask: AnyCodable?,
agentid: AnyCodable?,
resolvedpath: AnyCodable?,
sessionkey: AnyCodable?,
timeoutms: Int?
) {
self.id = id

View File

@@ -3,9 +3,12 @@ summary: "Cron jobs + wakeups for the Gateway scheduler"
read_when:
- Scheduling background jobs or wakeups
- Wiring automation that should run with or alongside heartbeats
- Deciding between heartbeat and cron for scheduled tasks
---
# Cron jobs (Gateway scheduler)
> **Cron vs Heartbeat?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each.
Cron is the Gateways built-in scheduler. It persists jobs, wakes the agent at
the right time, and can optionally deliver output back to a chat.

View File

@@ -0,0 +1,274 @@
---
summary: "Guidance for choosing between heartbeat and cron jobs for automation"
read_when:
- Deciding how to schedule recurring tasks
- Setting up background monitoring or notifications
- Optimizing token usage for periodic checks
---
# Cron vs Heartbeat: When to Use Each
Both heartbeats and cron jobs let you run tasks on a schedule. This guide helps you choose the right mechanism for your use case.
## Quick Decision Guide
| Use Case | Recommended | Why |
|----------|-------------|-----|
| Check inbox every 30 min | Heartbeat | Batches with other checks, context-aware |
| Send daily report at 9am sharp | Cron (isolated) | Exact timing needed |
| Monitor calendar for upcoming events | Heartbeat | Natural fit for periodic awareness |
| Run weekly deep analysis | Cron (isolated) | Standalone task, can use different model |
| Remind me in 20 minutes | Cron (main, `--at`) | One-shot with precise timing |
| Background project health check | Heartbeat | Piggybacks on existing cycle |
## Heartbeat: Periodic Awareness
Heartbeats run in the **main session** at a regular interval (default: 30 min). They're designed for the agent to check on things and surface anything important.
### When to use heartbeat
- **Multiple periodic checks**: Instead of 5 separate cron jobs checking inbox, calendar, weather, notifications, and project status, a single heartbeat can batch all of these.
- **Context-aware decisions**: The agent has full main-session context, so it can make smart decisions about what's urgent vs. what can wait.
- **Conversational continuity**: Heartbeat runs share the same session, so the agent remembers recent conversations and can follow up naturally.
- **Low-overhead monitoring**: One heartbeat replaces many small polling tasks.
### Heartbeat advantages
- **Batches multiple checks**: One agent turn can review inbox, calendar, and notifications together.
- **Reduces API calls**: A single heartbeat is cheaper than 5 isolated cron jobs.
- **Context-aware**: The agent knows what you've been working on and can prioritize accordingly.
- **Smart suppression**: If nothing needs attention, the agent replies `HEARTBEAT_OK` and no message is delivered.
- **Natural timing**: Drifts slightly based on queue load, which is fine for most monitoring.
### Heartbeat example: HEARTBEAT.md checklist
```md
# Heartbeat checklist
- Check email for urgent messages
- Review calendar for events in next 2 hours
- If a background task finished, summarize results
- If idle for 8+ hours, send a brief check-in
```
The agent reads this on each heartbeat and handles all items in one turn.
### Configuring heartbeat
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m", // interval
target: "last", // where to deliver alerts
activeHours: { start: "08:00", end: "22:00" } // optional
}
}
}
}
```
See [Heartbeat](/gateway/heartbeat) for full configuration.
## Cron: Precise Scheduling
Cron jobs run at **exact times** and can run in isolated sessions without affecting main context.
### When to use cron
- **Exact timing required**: "Send this at 9:00 AM every Monday" (not "sometime around 9").
- **Standalone tasks**: Tasks that don't need conversational context.
- **Different model/thinking**: Heavy analysis that warrants a more powerful model.
- **One-shot reminders**: "Remind me in 20 minutes" with `--at`.
- **Noisy/frequent tasks**: Tasks that would clutter main session history.
- **External triggers**: Tasks that should run independently of whether the agent is otherwise active.
### Cron advantages
- **Exact timing**: 5-field cron expressions with timezone support.
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
- **Model overrides**: Use a cheaper or more powerful model per job.
- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable).
- **No agent context needed**: Runs even if main session is idle or compacted.
- **One-shot support**: `--at` for precise future timestamps.
### Cron example: Daily morning briefing
```bash
clawdbot cron add \
--name "Morning briefing" \
--cron "0 7 * * *" \
--tz "America/New_York" \
--session isolated \
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
--model opus \
--deliver \
--channel whatsapp \
--to "+15551234567"
```
This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp.
### Cron example: One-shot reminder
```bash
clawdbot cron add \
--name "Meeting reminder" \
--at "20m" \
--session main \
--system-event "Reminder: standup meeting starts in 10 minutes." \
--wake now \
--delete-after-run
```
See [Cron jobs](/automation/cron-jobs) for full CLI reference.
## Decision Flowchart
```
Does the task need to run at an EXACT time?
YES -> Use cron
NO -> Continue...
Does the task need isolation from main session?
YES -> Use cron (isolated)
NO -> Continue...
Can this task be batched with other periodic checks?
YES -> Use heartbeat (add to HEARTBEAT.md)
NO -> Use cron
Is this a one-shot reminder?
YES -> Use cron with --at
NO -> Continue...
Does it need a different model or thinking level?
YES -> Use cron (isolated) with --model/--thinking
NO -> Use heartbeat
```
## Combining Both
The most efficient setup uses **both**:
1. **Heartbeat** handles routine monitoring (inbox, calendar, notifications) in one batched turn every 30 minutes.
2. **Cron** handles precise schedules (daily reports, weekly reviews) and one-shot reminders.
### Example: Efficient automation setup
**HEARTBEAT.md** (checked every 30 min):
```md
# Heartbeat checklist
- Scan inbox for urgent emails
- Check calendar for events in next 2h
- Review any pending tasks
- Light check-in if quiet for 8+ hours
```
**Cron jobs** (precise timing):
```bash
# Daily morning briefing at 7am
clawdbot cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
# Weekly project review on Mondays at 9am
clawdbot cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
# One-shot reminder
clawdbot cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now
```
## Lobster: Deterministic workflows with approvals
Lobster is the workflow runtime for **multi-step tool pipelines** that need deterministic execution and explicit approvals.
Use it when the task is more than a single agent turn, and you want a resumable workflow with human checkpoints.
### When Lobster fits
- **Multi-step automation**: You need a fixed pipeline of tool calls, not a one-off prompt.
- **Approval gates**: Side effects should pause until you approve, then resume.
- **Resumable runs**: Continue a paused workflow without re-running earlier steps.
### How it pairs with heartbeat and cron
- **Heartbeat/cron** decide *when* a run happens.
- **Lobster** defines *what steps* happen once the run starts.
For scheduled workflows, use cron or heartbeat to trigger an agent turn that calls Lobster.
For ad-hoc workflows, call Lobster directly.
### Operational notes (from the code)
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
- If you pass `lobsterPath`, it must be an **absolute path**.
See [Lobster](/tools/lobster) for full usage and examples.
## Main Session vs Isolated Session
Both heartbeat and cron can interact with the main session, but differently:
| | Heartbeat | Cron (main) | Cron (isolated) |
|---|---|---|---|
| Session | Main | Main (via system event) | `cron:<jobId>` |
| History | Shared | Shared | Fresh each run |
| Context | Full | Full | None (starts clean) |
| Model | Main session model | Main session model | Can override |
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main |
### When to use main session cron
Use `--session main` with `--system-event` when you want:
- The reminder/event to appear in main session context
- The agent to handle it during the next heartbeat with full context
- No separate isolated run
```bash
clawdbot cron add \
--name "Check project" \
--every "4h" \
--session main \
--system-event "Time for a project health check" \
--wake now
```
### When to use isolated cron
Use `--session isolated` when you want:
- A clean slate without prior context
- Different model or thinking settings
- Output delivered directly to a channel (summary still posts to main by default)
- History that doesn't clutter main session
```bash
clawdbot cron add \
--name "Deep analysis" \
--cron "0 6 * * 0" \
--session isolated \
--message "Weekly codebase analysis..." \
--model opus \
--thinking high \
--deliver
```
## Cost Considerations
| Mechanism | Cost Profile |
|-----------|--------------|
| Heartbeat | One turn every N minutes; scales with HEARTBEAT.md size |
| Cron (main) | Adds event to next heartbeat (no isolated turn) |
| Cron (isolated) | Full agent turn per job; can use cheaper model |
**Tips**:
- Keep `HEARTBEAT.md` small to minimize token overhead.
- Batch similar checks into heartbeat instead of multiple cron jobs.
- Use `target: "none"` on heartbeat if you only want internal processing.
- Use isolated cron with a cheaper model for routine tasks.
## Related
- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration
- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference
- [Wake](/cli/wake) - manual wake command

View File

@@ -17,6 +17,37 @@ not an API key.
- Auth: AWS credentials (env vars, shared config, or instance role)
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
## Automatic model discovery
If AWS credentials are detected, Clawdbot can automatically discover Bedrock
models that support **streaming** and **text output**. Discovery uses
`bedrock:ListFoundationModels` and is cached (default: 1 hour).
Config options live under `models.bedrockDiscovery`:
```json5
{
models: {
bedrockDiscovery: {
enabled: true,
region: "us-east-1",
providerFilter: ["anthropic", "amazon"],
refreshInterval: 3600,
defaultContextWindow: 32000,
defaultMaxTokens: 4096
}
}
}
```
Notes:
- `enabled` defaults to `true` when AWS credentials are present.
- `region` defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`, then `us-east-1`.
- `providerFilter` matches Bedrock provider names (for example `anthropic`).
- `refreshInterval` is seconds; set to `0` to disable caching.
- `defaultContextWindow` (default: `32000`) and `defaultMaxTokens` (default: `4096`)
are used for discovered models (override if you know your model limits).
## Setup (manual)
1) Ensure AWS credentials are available on the **gateway host**:
@@ -67,6 +98,7 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
- Automatic discovery needs the `bedrock:ListFoundationModels` permission.
- If you use profiles, set `AWS_PROFILE` on the gateway host.
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the

View File

@@ -23,6 +23,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.

133
docs/channels/tlon.md Normal file
View File

@@ -0,0 +1,133 @@
---
summary: "Tlon/Urbit support status, capabilities, and configuration"
read_when:
- Working on Tlon/Urbit channel features
---
# Tlon (plugin)
Tlon is a decentralized messenger built on Urbit. Clawdbot connects to your Urbit ship and can
respond to DMs and group chat messages. Group replies require an @ mention by default and can
be further restricted via allowlists.
Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback
(URL appended to caption). Reactions, polls, and native media uploads are not supported.
## Plugin required
Tlon ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/tlon
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/tlon
```
Details: [Plugins](/plugin)
## Setup
1) Install the Tlon plugin.
2) Gather your ship URL and login code.
3) Configure `channels.tlon`.
4) Restart the gateway.
5) DM the bot or mention it in a group channel.
Minimal config (single account):
```json5
{
channels: {
tlon: {
enabled: true,
ship: "~sampel-palnet",
url: "https://your-ship-host",
code: "lidlut-tabwed-pillex-ridrup"
}
}
}
```
## Group channels
Auto-discovery is enabled by default. You can also pin channels manually:
```json5
{
channels: {
tlon: {
groupChannels: [
"chat/~host-ship/general",
"chat/~host-ship/support"
]
}
}
}
```
Disable auto-discovery:
```json5
{
channels: {
tlon: {
autoDiscoverChannels: false
}
}
}
```
## Access control
DM allowlist (empty = allow all):
```json5
{
channels: {
tlon: {
dmAllowlist: ["~zod", "~nec"]
}
}
}
```
Group authorization (restricted by default):
```json5
{
channels: {
tlon: {
defaultAuthorizedShips: ["~zod"],
authorization: {
channelRules: {
"chat/~host-ship/general": {
mode: "restricted",
allowedShips: ["~zod", "~nec"]
},
"chat/~host-ship/announcements": {
mode: "open"
}
}
}
}
}
}
```
## Delivery targets (CLI/cron)
Use these with `clawdbot message send` or cron delivery:
- DM: `~sampel-palnet` or `dm/~sampel-palnet`
- Group: `chat/~host-ship/channel` or `group:~host-ship/channel`
## Notes
- Group replies require a mention (e.g. `~your-bot-ship`) to respond.
- Thread replies: if the inbound message is in a thread, Clawdbot replies in-thread.
- Media: `sendMedia` falls back to text + URL (no native upload).

View File

@@ -349,6 +349,14 @@
"source": "/cron-jobs",
"destination": "/automation/cron-jobs"
},
{
"source": "/cron-vs-heartbeat",
"destination": "/automation/cron-vs-heartbeat"
},
{
"source": "/cron-vs-heartbeat/",
"destination": "/automation/cron-vs-heartbeat"
},
{
"source": "/dashboard",
"destination": "/web/dashboard"
@@ -984,6 +992,7 @@
"automation/webhook",
"automation/gmail-pubsub",
"automation/cron-jobs",
"automation/cron-vs-heartbeat",
"automation/poll"
]
},
@@ -991,6 +1000,8 @@
"group": "Tools & Skills",
"pages": [
"tools",
"tools/lobster",
"tools/llm-task",
"plugin",
"plugins/voice-call",
"plugins/zalouser",

View File

@@ -1970,6 +1970,7 @@ Example (provider/model-specific allowlist):
```
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
Matching is case-insensitive and supports `*` wildcards (`"*"` means all tools).
This is applied even when the Docker sandbox is **off**.
Example (disable browser/canvas everywhere):

View File

@@ -2,9 +2,12 @@
summary: "Heartbeat polling messages and notification rules"
read_when:
- Adjusting heartbeat cadence or messaging
- Deciding between heartbeat and cron for scheduled tasks
---
# Heartbeat (Gateway)
> **Heartbeat vs Cron?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each.
Heartbeat runs **periodic agent turns** in the main session so the model can
surface anything that needs attention without spamming you.

View File

@@ -145,6 +145,16 @@ Example:
}
```
Clawdbot can also merge **external channel catalogs** (for example, an MPM
registry export). Drop a JSON file at one of:
- `~/.clawdbot/mpm/plugins.json`
- `~/.clawdbot/mpm/catalog.json`
- `~/.clawdbot/plugins/catalog.json`
Or point `CLAWDBOT_PLUGIN_CATALOG_PATHS` (or `CLAWDBOT_MPM_CATALOG_PATHS`) at
one or more JSON files (comma/semicolon/`PATH`-delimited). Each file should
contain `{ "entries": [ { "name": "@scope/pkg", "clawdbot": { "channel": {...}, "install": {...} } } ] }`.
## Plugin IDs
Default plugin ids:

View File

@@ -112,6 +112,23 @@ Default heartbeat prompt:
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
### Heartbeat vs Cron: When to Use Each
**Use heartbeat when:**
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
- You need conversational context from recent messages
- Timing can drift slightly (every ~30 min is fine, not exact)
- You want to reduce API calls by combining periodic checks
**Use cron when:**
- Exact timing matters ("9:00 AM sharp every Monday")
- Task needs isolation from main session history
- You want a different model or thinking level for the task
- One-shot reminders ("remind me in 20 minutes")
- Output should deliver directly to a channel without main session involvement
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
**Things to check (rotate through these, 2-4 times per day):**
- **Emails** - Any urgent unread messages?
- **Calendar** - Upcoming events in next 24-48h?

View File

@@ -102,6 +102,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Exec tool](/tools/exec)
- [Elevated mode](/tools/elevated)
- [Cron jobs](/automation/cron-jobs)
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat)
- [Thinking + verbose](/tools/thinking)
- [Models](/concepts/models)
- [Sub-agents](/tools/subagents)

View File

@@ -327,14 +327,8 @@ Full setup walkthrough (28m) by VelvetShark.
<Card title="OpenRouter Transcription" icon="microphone" href="https://clawdhub.com/obviyus/openrouter-transcribe">
**@obviyus** • `transcription` `multilingual` `skill`
Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on ClawdHub.
</Card>
<Card title="Google Docs Editor" icon="file-word">
**Community**`docs` `editing` `skill`
Rich-text Google Docs editing skill. Built rapidly with Claude Code.
Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on ClawdHub.
</Card>
</CardGroup>

View File

@@ -22,6 +22,10 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
}
```
Notes:
- Matching is case-insensitive.
- `*` wildcards are supported (`"*"` means all tools).
## Tool profiles (base allowlist)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
@@ -156,6 +160,7 @@ alongside tools (for example, the voice-call plugin).
Optional plugin tools:
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation).
## Tool inventory

114
docs/tools/llm-task.md Normal file
View File

@@ -0,0 +1,114 @@
---
summary: "JSON-only LLM tasks for workflows (optional plugin tool)"
read_when:
- You want a JSON-only LLM step inside workflows
- You need schema-validated LLM output for automation
---
# LLM Task
`llm-task` is an **optional plugin tool** that runs a JSON-only LLM task and
returns structured output (optionally validated against JSON Schema).
This is ideal for workflow engines like Lobster: you can add a single LLM step
without writing custom Clawdbot code for each workflow.
## Enable the plugin
1) Enable the plugin:
```json
{
"plugins": {
"entries": {
"llm-task": { "enabled": true }
}
}
}
```
2) Allowlist the tool (it is registered with `optional: true`):
```json
{
"agents": {
"list": [
{
"id": "main",
"tools": { "allow": ["llm-task"] }
}
]
}
}
```
## Config (optional)
```json
{
"plugins": {
"entries": {
"llm-task": {
"enabled": true,
"config": {
"defaultProvider": "openai-codex",
"defaultModel": "gpt-5.2",
"defaultAuthProfileId": "main",
"allowedModels": ["openai-codex/gpt-5.2"],
"maxTokens": 800,
"timeoutMs": 30000
}
}
}
}
}
```
`allowedModels` is an allowlist of `provider/model` strings. If set, any request
outside the list is rejected.
## Tool parameters
- `prompt` (string, required)
- `input` (any, optional)
- `schema` (object, optional JSON Schema)
- `provider` (string, optional)
- `model` (string, optional)
- `authProfileId` (string, optional)
- `temperature` (number, optional)
- `maxTokens` (number, optional)
- `timeoutMs` (number, optional)
## Output
Returns `details.json` containing the parsed JSON (and validates against
`schema` when provided).
## Example: Lobster workflow step
```lobster
clawd.invoke --tool llm-task --action json --args-json '{
"prompt": "Given the input email, return intent and draft.",
"input": {
"subject": "Hello",
"body": "Can you help?"
},
"schema": {
"type": "object",
"properties": {
"intent": { "type": "string" },
"draft": { "type": "string" }
},
"required": ["intent", "draft"],
"additionalProperties": false
}
}'
```
## Safety notes
- The tool is **JSON-only** and instructs the model to output only JSON (no
code fences, no commentary).
- No tools are exposed to the model for this run.
- Treat output as untrusted unless you validate with `schema`.
- Put approvals before any side-effecting step (send, post, exec).

View File

@@ -65,6 +65,52 @@ gog.gmail.search --query 'newer_than:1d' \
| clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'
```
## JSON-only LLM steps (llm-task)
For workflows that need a **structured LLM step**, enable the optional
`llm-task` plugin tool and call it from Lobster. This keeps the workflow
deterministic while still letting you classify/summarize/draft with a model.
Enable the tool:
```json
{
"plugins": {
"entries": {
"llm-task": { "enabled": true }
}
},
"agents": {
"list": [
{
"id": "main",
"tools": { "allow": ["llm-task"] }
}
]
}
}
```
Use it in a pipeline:
```lobster
clawd.invoke --tool llm-task --action json --args-json '{
"prompt": "Given the input email, return intent and draft.",
"input": { "subject": "Hello", "body": "Can you help?" },
"schema": {
"type": "object",
"properties": {
"intent": { "type": "string" },
"draft": { "type": "string" }
},
"required": ["intent", "draft"],
"additionalProperties": false
}
}'
```
See [LLM Task](/tools/llm-task) for details and configuration options.
## Workflow files (.lobster)
Lobster can run YAML/JSON workflow files with `name`, `args`, `steps`, `env`, `condition`, and `approval` fields. In Clawdbot tool calls, set `pipeline` to the file path.

View File

@@ -0,0 +1,97 @@
# LLM Task (plugin)
Adds an **optional** agent tool `llm-task` for running **JSON-only** LLM tasks
(drafting, summarizing, classifying) with optional JSON Schema validation.
Designed to be called from workflow engines (for example, Lobster via
`clawd.invoke --each`) without adding new Clawdbot code per workflow.
## Enable
1) Enable the plugin:
```json
{
"plugins": {
"entries": {
"llm-task": { "enabled": true }
}
}
}
```
2) Allowlist the tool (it is registered with `optional: true`):
```json
{
"agents": {
"list": [
{
"id": "main",
"tools": { "allow": ["llm-task"] }
}
]
}
}
```
## Config (optional)
```json
{
"plugins": {
"entries": {
"llm-task": {
"enabled": true,
"config": {
"defaultProvider": "openai-codex",
"defaultModel": "gpt-5.2",
"defaultAuthProfileId": "main",
"allowedModels": ["openai-codex/gpt-5.2"],
"maxTokens": 800,
"timeoutMs": 30000
}
}
}
}
}
```
`allowedModels` is an allowlist of `provider/model` strings. If set, any request
outside the list is rejected.
## Tool API
### Parameters
- `prompt` (string, required)
- `input` (any, optional)
- `schema` (object, optional JSON Schema)
- `provider` (string, optional)
- `model` (string, optional)
- `authProfileId` (string, optional)
- `temperature` (number, optional)
- `maxTokens` (number, optional)
- `timeoutMs` (number, optional)
### Output
Returns `details.json` containing the parsed JSON (and validates against
`schema` when provided).
## Notes
- The tool is **JSON-only** and instructs the model to output only JSON
(no code fences, no commentary).
- No tools are exposed to the model for this run.
- Side effects should be handled outside this tool (for example, approvals in
Lobster) before calling tools that send messages/emails.
## Bundled extension note
This extension depends on Clawdbot internal modules (the embedded agent runner).
It is intended to ship as a **bundled** Clawdbot extension (like `lobster`) and
be enabled via `plugins.entries` + tool allowlists.
It is **not** currently designed to be copied into
`~/.clawdbot/extensions` as a standalone plugin directory.

View File

@@ -0,0 +1,21 @@
{
"id": "llm-task",
"name": "LLM Task",
"description": "Generic JSON-only LLM tool for structured tasks callable from workflows.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaultProvider": { "type": "string" },
"defaultModel": { "type": "string" },
"defaultAuthProfileId": { "type": "string" },
"allowedModels": {
"type": "array",
"items": { "type": "string" },
"description": "Allowlist of provider/model keys like openai-codex/gpt-5.2."
},
"maxTokens": { "type": "number" },
"timeoutMs": { "type": "number" }
}
}
}

View File

@@ -0,0 +1,7 @@
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
import { createLlmTaskTool } from "./src/llm-task-tool.js";
export default function register(api: ClawdbotPluginApi) {
api.registerTool(createLlmTaskTool(api), { optional: true });
}

View File

@@ -0,0 +1,11 @@
{
"name": "@clawdbot/llm-task",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot JSON-only LLM task plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
return {
runEmbeddedPiAgent: vi.fn(async () => ({
meta: { startedAt: Date.now() },
payloads: [{ text: "{}" }],
})),
};
});
import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js";
import { createLlmTaskTool } from "./llm-task-tool.js";
function fakeApi(overrides: any = {}) {
return {
id: "llm-task",
name: "llm-task",
source: "test",
config: { agents: { defaults: { workspace: "/tmp", model: { primary: "openai-codex/gpt-5.2" } } } },
pluginConfig: {},
runtime: { version: "test" },
logger: { debug() {}, info() {}, warn() {}, error() {} },
registerTool() {},
...overrides,
};
}
describe("llm-task tool (json-only)", () => {
beforeEach(() => vi.clearAllMocks());
it("returns parsed json", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
const res = await tool.execute("id", { prompt: "return foo" });
expect((res as any).details.json).toEqual({ foo: "bar" });
});
it("strips fenced json", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: "```json\n{\"ok\":true}\n```" }],
});
const tool = createLlmTaskTool(fakeApi() as any);
const res = await tool.execute("id", { prompt: "return ok" });
expect((res as any).details.json).toEqual({ ok: true });
});
it("validates schema", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
const schema = {
type: "object",
properties: { foo: { type: "string" } },
required: ["foo"],
additionalProperties: false,
};
const res = await tool.execute("id", { prompt: "return foo", schema });
expect((res as any).details.json).toEqual({ foo: "bar" });
});
it("throws on invalid json", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: "not-json" }] });
const tool = createLlmTaskTool(fakeApi() as any);
await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i);
});
it("throws on schema mismatch", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ foo: 1 }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] };
await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i);
});
it("passes provider/model overrides to embedded runner", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ ok: true }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" });
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
expect(call.provider).toBe("anthropic");
expect(call.model).toBe("claude-4-sonnet");
});
it("enforces allowedModels", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ ok: true }) }],
});
const tool = createLlmTaskTool(fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }) as any);
await expect(tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" })).rejects.toThrow(
/not allowed/i,
);
});
it("disables tools for embedded run", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ ok: true }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
await tool.execute("id", { prompt: "x" });
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
expect(call.disableTools).toBe(true);
});
});

View File

@@ -0,0 +1,218 @@
import os from "node:os";
import path from "node:path";
import fs from "node:fs/promises";
import Ajv from "ajv";
import { Type } from "@sinclair/typebox";
// NOTE: This extension is intended to be bundled with Clawdbot.
// When running from source (tests/dev), Clawdbot internals live under src/.
// When running from a built install, internals live under dist/ (no src/ tree).
// So we resolve internal imports dynamically with src-first, dist-fallback.
import type { ClawdbotPluginApi } from "../../../src/plugins/types.js";
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
// Source checkout (tests/dev)
try {
const mod = await import("../../../src/agents/pi-embedded-runner.js");
if (typeof (mod as any).runEmbeddedPiAgent === "function") return (mod as any).runEmbeddedPiAgent;
} catch {
// ignore
}
// Bundled install (built)
const mod = await import("../../../agents/pi-embedded-runner.js");
if (typeof (mod as any).runEmbeddedPiAgent !== "function") {
throw new Error("Internal error: runEmbeddedPiAgent not available");
}
return (mod as any).runEmbeddedPiAgent;
}
function stripCodeFences(s: string): string {
const trimmed = s.trim();
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (m) return (m[1] ?? "").trim();
return trimmed;
}
function collectText(payloads: Array<{ text?: string; isError?: boolean }> | undefined): string {
const texts = (payloads ?? [])
.filter((p) => !p.isError && typeof p.text === "string")
.map((p) => p.text ?? "");
return texts.join("\n").trim();
}
function toModelKey(provider?: string, model?: string): string | undefined {
const p = provider?.trim();
const m = model?.trim();
if (!p || !m) return undefined;
return `${p}/${m}`;
}
type PluginCfg = {
defaultProvider?: string;
defaultModel?: string;
defaultAuthProfileId?: string;
allowedModels?: string[];
maxTokens?: number;
timeoutMs?: number;
};
export function createLlmTaskTool(api: ClawdbotPluginApi) {
return {
name: "llm-task",
description:
"Run a generic JSON-only LLM task and return schema-validated JSON. Designed for orchestration from Lobster workflows via clawd.invoke.",
parameters: Type.Object({
prompt: Type.String({ description: "Task instruction for the LLM." }),
input: Type.Optional(Type.Unknown({ description: "Optional input payload for the task." })),
schema: Type.Optional(Type.Unknown({ description: "Optional JSON Schema to validate the returned JSON." })),
provider: Type.Optional(Type.String({ description: "Provider override (e.g. openai-codex, anthropic)." })),
model: Type.Optional(Type.String({ description: "Model id override." })),
authProfileId: Type.Optional(Type.String({ description: "Auth profile override." })),
temperature: Type.Optional(Type.Number({ description: "Best-effort temperature override." })),
maxTokens: Type.Optional(Type.Number({ description: "Best-effort maxTokens override." })),
timeoutMs: Type.Optional(Type.Number({ description: "Timeout for the LLM run." })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const prompt = String(params.prompt ?? "");
if (!prompt.trim()) throw new Error("prompt required");
const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg;
const primary = api.config?.agents?.defaults?.model?.primary;
const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined;
const primaryModel = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
const provider =
(typeof params.provider === "string" && params.provider.trim()) ||
(typeof pluginCfg.defaultProvider === "string" && pluginCfg.defaultProvider.trim()) ||
primaryProvider ||
undefined;
const model =
(typeof params.model === "string" && params.model.trim()) ||
(typeof pluginCfg.defaultModel === "string" && pluginCfg.defaultModel.trim()) ||
primaryModel ||
undefined;
const authProfileId =
(typeof (params as any).authProfileId === "string" && (params as any).authProfileId.trim()) ||
(typeof pluginCfg.defaultAuthProfileId === "string" && pluginCfg.defaultAuthProfileId.trim()) ||
undefined;
const modelKey = toModelKey(provider, model);
if (!provider || !model || !modelKey) {
throw new Error(
`provider/model could not be resolved (provider=${String(provider ?? "")}, model=${String(model ?? "")})`,
);
}
const allowed = Array.isArray(pluginCfg.allowedModels) ? pluginCfg.allowedModels : undefined;
if (allowed && allowed.length > 0 && !allowed.includes(modelKey)) {
throw new Error(
`Model not allowed by llm-task plugin config: ${modelKey}. Allowed models: ${allowed.join(", ")}`,
);
}
const timeoutMs =
(typeof params.timeoutMs === "number" && params.timeoutMs > 0 ? params.timeoutMs : undefined) ||
(typeof pluginCfg.timeoutMs === "number" && pluginCfg.timeoutMs > 0 ? pluginCfg.timeoutMs : undefined) ||
30_000;
const streamParams = {
temperature: typeof params.temperature === "number" ? params.temperature : undefined,
maxTokens:
typeof params.maxTokens === "number"
? params.maxTokens
: typeof pluginCfg.maxTokens === "number"
? pluginCfg.maxTokens
: undefined,
};
const input = (params as any).input as unknown;
let inputJson: string;
try {
inputJson = JSON.stringify(input ?? null, null, 2);
} catch {
throw new Error("input must be JSON-serializable");
}
const system = [
"You are a JSON-only function.",
"Return ONLY a valid JSON value.",
"Do not wrap in markdown fences.",
"Do not include commentary.",
"Do not call tools.",
].join(" ");
const fullPrompt = `${system}\n\nTASK:\n${prompt}\n\nINPUT_JSON:\n${inputJson}\n`;
let tmpDir: string | null = null;
try {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-llm-task-"));
const sessionId = `llm-task-${Date.now()}`;
const sessionFile = path.join(tmpDir, "session.json");
const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent();
const result = await runEmbeddedPiAgent({
sessionId,
sessionFile,
workspaceDir: api.config?.agents?.defaults?.workspace ?? process.cwd(),
config: api.config,
prompt: fullPrompt,
timeoutMs,
runId: `llm-task-${Date.now()}`,
provider,
model,
authProfileId,
authProfileIdSource: authProfileId ? "user" : "auto",
streamParams,
disableTools: true,
});
const text = collectText((result as any).payloads);
if (!text) throw new Error("LLM returned empty output");
const raw = stripCodeFences(text);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("LLM returned invalid JSON");
}
const schema = (params as any).schema as unknown;
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(schema as any);
const ok = validate(parsed);
if (!ok) {
const msg =
validate.errors?.map((e) => `${e.instancePath || "<root>"} ${e.message || "invalid"}`).join("; ") ??
"invalid";
throw new Error(`LLM JSON did not match schema: ${msg}`);
}
}
return {
content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }],
details: { json: parsed, provider, model },
};
} finally {
if (tmpDir) {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
// ignore
}
}
}
},
};
}

View File

@@ -25,9 +25,12 @@
},
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"clawdbot": "workspace:*",
"markdown-it": "14.1.0",
"matrix-bot-sdk": "0.8.0",
"music-metadata": "^11.10.6"
"music-metadata": "^11.10.6",
"zod": "^4.3.5"
},
"devDependencies": {
"clawdbot": "workspace:*"
}
}

View File

@@ -0,0 +1,5 @@
# Tlon (Clawdbot plugin)
Tlon/Urbit channel plugin for Clawdbot. Supports DMs, group mentions, and thread replies.
Docs: https://docs.clawd.bot/channels/tlon

View File

@@ -0,0 +1,11 @@
{
"id": "tlon",
"channels": [
"tlon"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

18
extensions/tlon/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { tlonPlugin } from "./src/channel.js";
import { setTlonRuntime } from "./src/runtime.js";
const plugin = {
id: "tlon",
name: "Tlon",
description: "Tlon/Urbit channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setTlonRuntime(api.runtime);
api.registerChannel({ plugin: tlonPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,30 @@
{
"name": "@clawdbot/tlon",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Tlon/Urbit channel plugin",
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "tlon",
"label": "Tlon",
"selectionLabel": "Tlon (Urbit)",
"docsPath": "/channels/tlon",
"docsLabel": "tlon",
"blurb": "decentralized messaging on Urbit; install the plugin to enable.",
"order": 90,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/tlon",
"localPath": "extensions/tlon",
"defaultChoice": "npm"
}
},
"dependencies": {
"@urbit/aura": "^2.0.0",
"@urbit/http-api": "^3.0.0"
}
}

View File

@@ -0,0 +1,379 @@
import type {
ChannelOutboundAdapter,
ChannelPlugin,
ChannelSetupInput,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "clawdbot/plugin-sdk";
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js";
import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonChannelConfigSchema } from "./config-schema.js";
import { tlonOnboardingAdapter } from "./onboarding.js";
const TLON_CHANNEL_ID = "tlon" as const;
type TlonSetupInput = ChannelSetupInput & {
ship?: string;
url?: string;
code?: string;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
};
function applyTlonSetupConfig(params: {
cfg: ClawdbotConfig;
accountId: string;
input: TlonSetupInput;
}): ClawdbotConfig {
const { cfg, accountId, input } = params;
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "tlon",
accountId,
name: input.name,
});
const base = namedConfig.channels?.tlon ?? {};
const payload = {
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
};
if (useDefault) {
return {
...namedConfig,
channels: {
...namedConfig.channels,
tlon: {
...base,
enabled: true,
...payload,
},
},
};
}
return {
...namedConfig,
channels: {
...namedConfig.channels,
tlon: {
...base,
enabled: base.enabled ?? true,
accounts: {
...(base as { accounts?: Record<string, unknown> }).accounts,
[accountId]: {
...((base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
accountId
] ?? {}),
enabled: true,
...payload,
},
},
},
},
};
}
const tlonOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
textChunkLimit: 10000,
resolveTarget: ({ to }) => {
const parsed = parseTlonTarget(to ?? "");
if (!parsed) {
return {
ok: false,
error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`),
};
}
if (parsed.kind === "dm") {
return { ok: true, to: parsed.ship };
}
return { ok: true, to: parsed.nest };
},
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
const account = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined);
if (!account.configured || !account.ship || !account.url || !account.code) {
throw new Error("Tlon account not configured");
}
const parsed = parseTlonTarget(to);
if (!parsed) {
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
}
ensureUrbitConnectPatched();
const api = await Urbit.authenticate({
ship: account.ship.replace(/^~/, ""),
url: account.url,
code: account.code,
verbose: false,
});
try {
const fromShip = normalizeShip(account.ship);
if (parsed.kind === "dm") {
return await sendDm({
api,
fromShip,
toShip: parsed.ship,
text,
});
}
const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
return await sendGroupMessage({
api,
fromShip,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
text,
replyToId: replyId,
});
} finally {
try {
await api.delete();
} catch {
// ignore cleanup errors
}
}
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
const mergedText = buildMediaText(text, mediaUrl);
return await tlonOutbound.sendText({
cfg,
to,
text: mergedText,
accountId,
replyToId,
threadId,
});
},
};
export const tlonPlugin: ChannelPlugin = {
id: TLON_CHANNEL_ID,
meta: {
id: TLON_CHANNEL_ID,
label: "Tlon",
selectionLabel: "Tlon (Urbit)",
docsPath: "/channels/tlon",
docsLabel: "tlon",
blurb: "Decentralized messaging on Urbit",
aliases: ["urbit"],
order: 90,
},
capabilities: {
chatTypes: ["direct", "group", "thread"],
media: false,
reply: true,
threads: true,
},
onboarding: tlonOnboardingAdapter,
reload: { configPrefixes: ["channels.tlon"] },
configSchema: tlonChannelConfigSchema,
config: {
listAccountIds: (cfg) => listTlonAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) => resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined),
defaultAccountId: () => "default",
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const useDefault = !accountId || accountId === "default";
if (useDefault) {
return {
...cfg,
channels: {
...cfg.channels,
tlon: {
...(cfg.channels?.tlon ?? {}),
enabled,
},
},
} as ClawdbotConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
tlon: {
...(cfg.channels?.tlon ?? {}),
accounts: {
...(cfg.channels?.tlon?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.tlon?.accounts?.[accountId] ?? {}),
enabled,
},
},
},
},
} as ClawdbotConfig;
},
deleteAccount: ({ cfg, accountId }) => {
const useDefault = !accountId || accountId === "default";
if (useDefault) {
const { ship, code, url, name, ...rest } = cfg.channels?.tlon ?? {};
return {
...cfg,
channels: {
...cfg.channels,
tlon: rest,
},
} as ClawdbotConfig;
}
const { [accountId]: removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {};
return {
...cfg,
channels: {
...cfg.channels,
tlon: {
...(cfg.channels?.tlon ?? {}),
accounts: remainingAccounts,
},
},
} as ClawdbotConfig;
},
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
ship: account.ship,
url: account.url,
}),
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "tlon",
accountId,
name,
}),
validateInput: ({ cfg, accountId, input }) => {
const setupInput = input as TlonSetupInput;
const resolved = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined);
const ship = setupInput.ship?.trim() || resolved.ship;
const url = setupInput.url?.trim() || resolved.url;
const code = setupInput.code?.trim() || resolved.code;
if (!ship) return "Tlon requires --ship.";
if (!url) return "Tlon requires --url.";
if (!code) return "Tlon requires --code.";
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) =>
applyTlonSetupConfig({
cfg: cfg as ClawdbotConfig,
accountId,
input: input as TlonSetupInput,
}),
},
messaging: {
normalizeTarget: (target) => {
const parsed = parseTlonTarget(target);
if (!parsed) return target.trim();
if (parsed.kind === "dm") return parsed.ship;
return parsed.nest;
},
targetResolver: {
looksLikeId: (target) => Boolean(parseTlonTarget(target)),
hint: formatTargetHint(),
},
},
outbound: tlonOutbound,
status: {
defaultRuntime: {
accountId: "default",
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) => {
return accounts.flatMap((account) => {
if (!account.configured) {
return [
{
channel: TLON_CHANNEL_ID,
accountId: account.accountId,
kind: "config",
message: "Account not configured (missing ship, code, or url)",
},
];
}
return [];
});
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
ship: snapshot.ship ?? null,
url: snapshot.url ?? null,
}),
probeAccount: async ({ account }) => {
if (!account.configured || !account.ship || !account.url || !account.code) {
return { ok: false, error: "Not configured" };
}
try {
ensureUrbitConnectPatched();
const api = await Urbit.authenticate({
ship: account.ship.replace(/^~/, ""),
url: account.url,
code: account.code,
verbose: false,
});
try {
await api.getOurName();
return { ok: true };
} finally {
await api.delete();
}
} catch (error: any) {
return { ok: false, error: error?.message ?? String(error) };
}
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
ship: account.ship,
url: account.url,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
ship: account.ship,
url: account.url,
});
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
return monitorTlonProvider({
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: account.accountId,
});
},
},
};

View File

@@ -0,0 +1,43 @@
import { z } from "zod";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
const ShipSchema = z.string().min(1);
const ChannelNestSchema = z.string().min(1);
export const TlonChannelRuleSchema = z.object({
mode: z.enum(["restricted", "open"]).optional(),
allowedShips: z.array(ShipSchema).optional(),
});
export const TlonAuthorizationSchema = z.object({
channelRules: z.record(TlonChannelRuleSchema).optional(),
});
export const TlonAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
ship: ShipSchema.optional(),
url: z.string().optional(),
code: z.string().optional(),
groupChannels: z.array(ChannelNestSchema).optional(),
dmAllowlist: z.array(ShipSchema).optional(),
autoDiscoverChannels: z.boolean().optional(),
showModelSignature: z.boolean().optional(),
});
export const TlonConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
ship: ShipSchema.optional(),
url: z.string().optional(),
code: z.string().optional(),
groupChannels: z.array(ChannelNestSchema).optional(),
dmAllowlist: z.array(ShipSchema).optional(),
autoDiscoverChannels: z.boolean().optional(),
showModelSignature: z.boolean().optional(),
authorization: TlonAuthorizationSchema.optional(),
defaultAuthorizedShips: z.array(ShipSchema).optional(),
accounts: z.record(TlonAccountSchema).optional(),
});
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);

View File

@@ -0,0 +1,71 @@
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { formatChangesDate } from "./utils.js";
export async function fetchGroupChanges(
api: { scry: (path: string) => Promise<unknown> },
runtime: RuntimeEnv,
daysAgo = 5,
) {
try {
const changeDate = formatChangesDate(daysAgo);
runtime.log?.(`[tlon] Fetching group changes since ${daysAgo} days ago (${changeDate})...`);
const changes = await api.scry(`/groups-ui/v5/changes/${changeDate}.json`);
if (changes) {
runtime.log?.("[tlon] Successfully fetched changes data");
return changes;
}
return null;
} catch (error: any) {
runtime.log?.(`[tlon] Failed to fetch changes (falling back to full init): ${error?.message ?? String(error)}`);
return null;
}
}
export async function fetchAllChannels(
api: { scry: (path: string) => Promise<unknown> },
runtime: RuntimeEnv,
): Promise<string[]> {
try {
runtime.log?.("[tlon] Attempting auto-discovery of group channels...");
const changes = await fetchGroupChanges(api, runtime, 5);
let initData: any;
if (changes) {
runtime.log?.("[tlon] Changes data received, using full init for channel extraction");
initData = await api.scry("/groups-ui/v6/init.json");
} else {
initData = await api.scry("/groups-ui/v6/init.json");
}
const channels: string[] = [];
if (initData && initData.groups) {
for (const groupData of Object.values(initData.groups as Record<string, any>)) {
if (groupData && typeof groupData === "object" && groupData.channels) {
for (const channelNest of Object.keys(groupData.channels)) {
if (channelNest.startsWith("chat/")) {
channels.push(channelNest);
}
}
}
}
}
if (channels.length > 0) {
runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`);
runtime.log?.(
`[tlon] Channels: ${channels.slice(0, 5).join(", ")}${channels.length > 5 ? "..." : ""}`,
);
} else {
runtime.log?.("[tlon] No chat channels found via auto-discovery");
runtime.log?.("[tlon] Add channels manually to config: channels.tlon.groupChannels");
}
return channels;
} catch (error: any) {
runtime.log?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`);
runtime.log?.("[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels");
runtime.log?.("[tlon] Example: [\"chat/~host-ship/channel-name\"]");
return [];
}
}

View File

@@ -0,0 +1,87 @@
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { extractMessageText } from "./utils.js";
export type TlonHistoryEntry = {
author: string;
content: string;
timestamp: number;
id?: string;
};
const messageCache = new Map<string, TlonHistoryEntry[]>();
const MAX_CACHED_MESSAGES = 100;
export function cacheMessage(channelNest: string, message: TlonHistoryEntry) {
if (!messageCache.has(channelNest)) {
messageCache.set(channelNest, []);
}
const cache = messageCache.get(channelNest);
if (!cache) return;
cache.unshift(message);
if (cache.length > MAX_CACHED_MESSAGES) {
cache.pop();
}
}
export async function fetchChannelHistory(
api: { scry: (path: string) => Promise<unknown> },
channelNest: string,
count = 50,
runtime?: RuntimeEnv,
): Promise<TlonHistoryEntry[]> {
try {
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
const data: any = await api.scry(scryPath);
if (!data) return [];
let posts: any[] = [];
if (Array.isArray(data)) {
posts = data;
} else if (data.posts && typeof data.posts === "object") {
posts = Object.values(data.posts);
} else if (typeof data === "object") {
posts = Object.values(data);
}
const messages = posts
.map((item) => {
const essay = item.essay || item["r-post"]?.set?.essay;
const seal = item.seal || item["r-post"]?.set?.seal;
return {
author: essay?.author || "unknown",
content: extractMessageText(essay?.content || []),
timestamp: essay?.sent || Date.now(),
id: seal?.id,
} as TlonHistoryEntry;
})
.filter((msg) => msg.content);
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
return messages;
} catch (error: any) {
runtime?.log?.(`[tlon] Error fetching channel history: ${error?.message ?? String(error)}`);
return [];
}
}
export async function getChannelHistory(
api: { scry: (path: string) => Promise<unknown> },
channelNest: string,
count = 50,
runtime?: RuntimeEnv,
): Promise<TlonHistoryEntry[]> {
const cache = messageCache.get(channelNest) ?? [];
if (cache.length >= count) {
runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`);
return cache.slice(0, count);
}
runtime?.log?.(
`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`,
);
return await fetchChannelHistory(api, channelNest, count, runtime);
}

View File

@@ -0,0 +1,501 @@
import { format } from "node:util";
import type { RuntimeEnv, ReplyPayload, ClawdbotConfig } from "clawdbot/plugin-sdk";
import { getTlonRuntime } from "../runtime.js";
import { resolveTlonAccount } from "../types.js";
import { normalizeShip, parseChannelNest } from "../targets.js";
import { authenticate } from "../urbit/auth.js";
import { UrbitSSEClient } from "../urbit/sse-client.js";
import { sendDm, sendGroupMessage } from "../urbit/send.js";
import { cacheMessage, getChannelHistory } from "./history.js";
import { createProcessedMessageTracker } from "./processed-messages.js";
import {
extractMessageText,
formatModelName,
isBotMentioned,
isDmAllowed,
isSummarizationRequest,
} from "./utils.js";
import { fetchAllChannels } from "./discovery.js";
export type MonitorTlonOpts = {
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
accountId?: string | null;
};
type ChannelAuthorization = {
mode?: "restricted" | "open";
allowedShips?: string[];
};
function resolveChannelAuthorization(
cfg: ClawdbotConfig,
channelNest: string,
): { mode: "restricted" | "open"; allowedShips: string[] } {
const tlonConfig = cfg.channels?.tlon as
| {
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
defaultAuthorizedShips?: string[];
}
| undefined;
const rules = tlonConfig?.authorization?.channelRules ?? {};
const rule = rules[channelNest];
const allowedShips = rule?.allowedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
const mode = rule?.mode ?? "restricted";
return { mode, allowedShips };
}
export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<void> {
const core = getTlonRuntime();
const cfg = core.config.loadConfig() as ClawdbotConfig;
if (cfg.channels?.tlon?.enabled === false) return;
const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" });
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
const runtime: RuntimeEnv = opts.runtime ?? {
log: (...args) => {
logger.info(formatRuntimeMessage(...args));
},
error: (...args) => {
logger.error(formatRuntimeMessage(...args));
},
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const account = resolveTlonAccount(cfg, opts.accountId ?? undefined);
if (!account.enabled) return;
if (!account.configured || !account.ship || !account.url || !account.code) {
throw new Error("Tlon account not configured (ship/url/code required)");
}
const botShipName = normalizeShip(account.ship);
runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
let api: UrbitSSEClient | null = null;
try {
runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`);
const cookie = await authenticate(account.url, account.code);
api = new UrbitSSEClient(account.url, cookie, {
ship: botShipName,
logger: {
log: (message) => runtime.log?.(message),
error: (message) => runtime.error?.(message),
},
});
} catch (error: any) {
runtime.error?.(`[tlon] Failed to authenticate: ${error?.message ?? String(error)}`);
throw error;
}
const processedTracker = createProcessedMessageTracker(2000);
let groupChannels: string[] = [];
if (account.autoDiscoverChannels !== false) {
try {
const discoveredChannels = await fetchAllChannels(api, runtime);
if (discoveredChannels.length > 0) {
groupChannels = discoveredChannels;
}
} catch (error: any) {
runtime.error?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`);
}
}
if (groupChannels.length === 0 && account.groupChannels.length > 0) {
groupChannels = account.groupChannels;
runtime.log?.(`[tlon] Using manual groupChannels config: ${groupChannels.join(", ")}`);
}
if (groupChannels.length > 0) {
runtime.log?.(
`[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`,
);
} else {
runtime.log?.("[tlon] No group channels to monitor (DMs only)");
}
const handleIncomingDM = async (update: any) => {
try {
const memo = update?.response?.add?.memo;
if (!memo) return;
const messageId = update.id as string | undefined;
if (!processedTracker.mark(messageId)) return;
const senderShip = normalizeShip(memo.author ?? "");
if (!senderShip || senderShip === botShipName) return;
const messageText = extractMessageText(memo.content);
if (!messageText) return;
if (!isDmAllowed(senderShip, account.dmAllowlist)) {
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
return;
}
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText,
isGroup: false,
timestamp: memo.sent || Date.now(),
});
} catch (error: any) {
runtime.error?.(`[tlon] Error handling DM: ${error?.message ?? String(error)}`);
}
};
const handleIncomingGroupMessage = (channelNest: string) => async (update: any) => {
try {
const parsed = parseChannelNest(channelNest);
if (!parsed) return;
const essay = update?.response?.post?.["r-post"]?.set?.essay;
const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
if (!essay && !memo) return;
const content = memo || essay;
const isThreadReply = Boolean(memo);
const messageId = isThreadReply
? update?.response?.post?.["r-post"]?.reply?.id
: update?.response?.post?.id;
if (!processedTracker.mark(messageId)) return;
const senderShip = normalizeShip(content.author ?? "");
if (!senderShip || senderShip === botShipName) return;
const messageText = extractMessageText(content.content);
if (!messageText) return;
cacheMessage(channelNest, {
author: senderShip,
content: messageText,
timestamp: content.sent || Date.now(),
id: messageId,
});
const mentioned = isBotMentioned(messageText, botShipName);
if (!mentioned) return;
const { mode, allowedShips } = resolveChannelAuthorization(cfg, channelNest);
if (mode === "restricted") {
if (allowedShips.length === 0) {
runtime.log?.(`[tlon] Access denied: ${senderShip} in ${channelNest} (no allowlist)`);
return;
}
const normalizedAllowed = allowedShips.map(normalizeShip);
if (!normalizedAllowed.includes(senderShip)) {
runtime.log?.(
`[tlon] Access denied: ${senderShip} in ${channelNest} (allowed: ${allowedShips.join(", ")})`,
);
return;
}
}
const seal = isThreadReply
? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
: update?.response?.post?.["r-post"]?.set?.seal;
const parentId = seal?.["parent-id"] || seal?.parent || null;
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText,
isGroup: true,
groupChannel: channelNest,
groupName: `${parsed.hostShip}/${parsed.channelName}`,
timestamp: content.sent || Date.now(),
parentId,
});
} catch (error: any) {
runtime.error?.(`[tlon] Error handling group message: ${error?.message ?? String(error)}`);
}
};
const processMessage = async (params: {
messageId: string;
senderShip: string;
messageText: string;
isGroup: boolean;
groupChannel?: string;
groupName?: string;
timestamp: number;
parentId?: string | null;
}) => {
const { messageId, senderShip, isGroup, groupChannel, groupName, timestamp, parentId } = params;
let messageText = params.messageText;
if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
try {
const history = await getChannelHistory(api!, groupChannel, 50, runtime);
if (history.length === 0) {
const noHistoryMsg =
"I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
if (isGroup) {
const parsed = parseChannelNest(groupChannel);
if (parsed) {
await sendGroupMessage({
api: api!,
fromShip: botShipName,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
text: noHistoryMsg,
});
}
} else {
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: noHistoryMsg });
}
return;
}
const historyText = history
.map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
.join("\n");
messageText =
`Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
"Provide a concise summary highlighting:\n" +
"1. Main topics discussed\n" +
"2. Key decisions or conclusions\n" +
"3. Action items if any\n" +
"4. Notable participants";
} catch (error: any) {
const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`;
if (isGroup && groupChannel) {
const parsed = parseChannelNest(groupChannel);
if (parsed) {
await sendGroupMessage({
api: api!,
fromShip: botShipName,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
text: errorMsg,
});
}
} else {
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: errorMsg });
}
return;
}
}
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "tlon",
accountId: opts.accountId ?? undefined,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup ? groupChannel ?? senderShip : senderShip,
},
});
const fromLabel = isGroup ? `${senderShip} in ${groupName}` : senderShip;
const body = core.channel.reply.formatAgentEnvelope({
channel: "Tlon",
from: fromLabel,
timestamp,
body: messageText,
});
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: messageText,
CommandBody: messageText,
From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
To: `tlon:${botShipName}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: senderShip,
SenderId: senderShip,
Provider: "tlon",
Surface: "tlon",
MessageSid: messageId,
OriginatingChannel: "tlon",
OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
});
const dispatchStartTime = Date.now();
const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix;
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix,
humanDelay,
deliver: async (payload: ReplyPayload) => {
let replyText = payload.text;
if (!replyText) return;
const showSignature = account.showModelSignature ?? cfg.channels?.tlon?.showModelSignature ?? false;
if (showSignature) {
const modelInfo =
payload.metadata?.model || payload.model || route.model || cfg.agents?.defaults?.model?.primary;
replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
}
if (isGroup && groupChannel) {
const parsed = parseChannelNest(groupChannel);
if (!parsed) return;
await sendGroupMessage({
api: api!,
fromShip: botShipName,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
text: replyText,
replyToId: parentId ?? undefined,
});
} else {
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: replyText });
}
},
onError: (err, info) => {
const dispatchDuration = Date.now() - dispatchStartTime;
runtime.error?.(
`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`,
);
},
},
});
};
const subscribedChannels = new Set<string>();
const subscribedDMs = new Set<string>();
async function subscribeToChannel(channelNest: string) {
if (subscribedChannels.has(channelNest)) return;
const parsed = parseChannelNest(channelNest);
if (!parsed) {
runtime.error?.(`[tlon] Invalid channel format: ${channelNest}`);
return;
}
try {
await api!.subscribe({
app: "channels",
path: `/${channelNest}`,
event: handleIncomingGroupMessage(channelNest),
err: (error) => {
runtime.error?.(`[tlon] Group subscription error for ${channelNest}: ${String(error)}`);
},
quit: () => {
runtime.log?.(`[tlon] Group subscription ended for ${channelNest}`);
subscribedChannels.delete(channelNest);
},
});
subscribedChannels.add(channelNest);
runtime.log?.(`[tlon] Subscribed to group channel: ${channelNest}`);
} catch (error: any) {
runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error?.message ?? String(error)}`);
}
}
async function subscribeToDM(dmShip: string) {
if (subscribedDMs.has(dmShip)) return;
try {
await api!.subscribe({
app: "chat",
path: `/dm/${dmShip}`,
event: handleIncomingDM,
err: (error) => {
runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${String(error)}`);
},
quit: () => {
runtime.log?.(`[tlon] DM subscription ended for ${dmShip}`);
subscribedDMs.delete(dmShip);
},
});
subscribedDMs.add(dmShip);
runtime.log?.(`[tlon] Subscribed to DM with ${dmShip}`);
} catch (error: any) {
runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error?.message ?? String(error)}`);
}
}
async function refreshChannelSubscriptions() {
try {
const dmShips = await api!.scry("/chat/dm.json");
if (Array.isArray(dmShips)) {
for (const dmShip of dmShips) {
await subscribeToDM(dmShip);
}
}
if (account.autoDiscoverChannels !== false) {
const discoveredChannels = await fetchAllChannels(api!, runtime);
for (const channelNest of discoveredChannels) {
await subscribeToChannel(channelNest);
}
}
} catch (error: any) {
runtime.error?.(`[tlon] Channel refresh failed: ${error?.message ?? String(error)}`);
}
}
try {
runtime.log?.("[tlon] Subscribing to updates...");
let dmShips: string[] = [];
try {
const dmList = await api!.scry("/chat/dm.json");
if (Array.isArray(dmList)) {
dmShips = dmList;
runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`);
}
} catch (error: any) {
runtime.error?.(`[tlon] Failed to fetch DM list: ${error?.message ?? String(error)}`);
}
for (const dmShip of dmShips) {
await subscribeToDM(dmShip);
}
for (const channelNest of groupChannels) {
await subscribeToChannel(channelNest);
}
runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
await api!.connect();
runtime.log?.("[tlon] Connected! All subscriptions active");
const pollInterval = setInterval(() => {
if (!opts.abortSignal?.aborted) {
refreshChannelSubscriptions().catch((error) => {
runtime.error?.(`[tlon] Channel refresh error: ${error?.message ?? String(error)}`);
});
}
}, 2 * 60 * 1000);
if (opts.abortSignal) {
await new Promise((resolve) => {
opts.abortSignal.addEventListener(
"abort",
() => {
clearInterval(pollInterval);
resolve(null);
},
{ once: true },
);
});
} else {
await new Promise(() => {});
}
} finally {
try {
await api?.close();
} catch (error: any) {
runtime.error?.(`[tlon] Cleanup error: ${error?.message ?? String(error)}`);
}
}
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createProcessedMessageTracker } from "./processed-messages.js";
describe("createProcessedMessageTracker", () => {
it("dedupes and evicts oldest entries", () => {
const tracker = createProcessedMessageTracker(3);
expect(tracker.mark("a")).toBe(true);
expect(tracker.mark("a")).toBe(false);
expect(tracker.has("a")).toBe(true);
tracker.mark("b");
tracker.mark("c");
expect(tracker.size()).toBe(3);
tracker.mark("d");
expect(tracker.size()).toBe(3);
expect(tracker.has("a")).toBe(false);
expect(tracker.has("b")).toBe(true);
expect(tracker.has("c")).toBe(true);
expect(tracker.has("d")).toBe(true);
});
});

View File

@@ -0,0 +1,38 @@
export type ProcessedMessageTracker = {
mark: (id?: string | null) => boolean;
has: (id?: string | null) => boolean;
size: () => number;
};
export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTracker {
const seen = new Set<string>();
const order: string[] = [];
const mark = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) return true;
if (seen.has(trimmed)) return false;
seen.add(trimmed);
order.push(trimmed);
if (order.length > limit) {
const overflow = order.length - limit;
for (let i = 0; i < overflow; i += 1) {
const oldest = order.shift();
if (oldest) seen.delete(oldest);
}
}
return true;
};
const has = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) return false;
return seen.has(trimmed);
};
return {
mark,
has,
size: () => seen.size,
};
}

View File

@@ -0,0 +1,83 @@
import { normalizeShip } from "../targets.js";
export function formatModelName(modelString?: string | null): string {
if (!modelString) return "AI";
const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString;
const modelMappings: Record<string, string> = {
"claude-opus-4-5": "Claude Opus 4.5",
"claude-sonnet-4-5": "Claude Sonnet 4.5",
"claude-sonnet-3-5": "Claude Sonnet 3.5",
"gpt-4o": "GPT-4o",
"gpt-4-turbo": "GPT-4 Turbo",
"gpt-4": "GPT-4",
"gemini-2.0-flash": "Gemini 2.0 Flash",
"gemini-pro": "Gemini Pro",
};
if (modelMappings[modelName]) return modelMappings[modelName];
return modelName
.replace(/-/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
export function isBotMentioned(messageText: string, botShipName: string): boolean {
if (!messageText || !botShipName) return false;
const normalizedBotShip = normalizeShip(botShipName);
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
return mentionPattern.test(messageText);
}
export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean {
if (!allowlist || allowlist.length === 0) return true;
const normalizedSender = normalizeShip(senderShip);
return allowlist
.map((ship) => normalizeShip(ship))
.some((ship) => ship === normalizedSender);
}
export function extractMessageText(content: unknown): string {
if (!content || !Array.isArray(content)) return "";
return content
.map((block: any) => {
if (block.inline && Array.isArray(block.inline)) {
return block.inline
.map((item: any) => {
if (typeof item === "string") return item;
if (item && typeof item === "object") {
if (item.ship) return item.ship;
if (item.break !== undefined) return "\n";
if (item.link && item.link.href) return item.link.href;
}
return "";
})
.join("");
}
return "";
})
.join("\n")
.trim();
}
export function isSummarizationRequest(messageText: string): boolean {
const patterns = [
/summarize\s+(this\s+)?(channel|chat|conversation)/i,
/what\s+did\s+i\s+miss/i,
/catch\s+me\s+up/i,
/channel\s+summary/i,
/tldr/i,
];
return patterns.some((pattern) => pattern.test(messageText));
}
export function formatChangesDate(daysAgo = 5): string {
const now = new Date();
const targetDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
const year = targetDate.getFullYear();
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
return `~${year}.${month}.${day}..20.19.51..9b9d`;
}

View File

@@ -0,0 +1,213 @@
import {
formatDocsLink,
promptAccountId,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type ChannelOnboardingAdapter,
type WizardPrompter,
} from "clawdbot/plugin-sdk";
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
import type { TlonResolvedAccount } from "./types.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
const channel = "tlon" as const;
function isConfigured(account: TlonResolvedAccount): boolean {
return Boolean(account.ship && account.url && account.code);
}
function applyAccountConfig(params: {
cfg: ClawdbotConfig;
accountId: string;
input: {
name?: string;
ship?: string;
url?: string;
code?: string;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
};
}): ClawdbotConfig {
const { cfg, accountId, input } = params;
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
const base = cfg.channels?.tlon ?? {};
if (useDefault) {
return {
...cfg,
channels: {
...cfg.channels,
tlon: {
...base,
enabled: true,
...(input.name ? { name: input.name } : {}),
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
tlon: {
...base,
enabled: base.enabled ?? true,
accounts: {
...(base as { accounts?: Record<string, unknown> }).accounts,
[accountId]: {
...((base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[accountId] ?? {}),
enabled: true,
...(input.name ? { name: input.name } : {}),
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
},
},
},
},
};
}
async function noteTlonHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"You need your Urbit ship URL and login code.",
"Example URL: https://your-ship-host",
"Example ship: ~sampel-palnet",
`Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`,
].join("\n"),
"Tlon setup",
);
}
function parseList(value: string): string[] {
return value
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
export const tlonOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const accountIds = listTlonAccountIds(cfg);
const configured =
accountIds.length > 0
? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId)))
: isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID));
return {
channel,
configured,
statusLines: [`Tlon: ${configured ? "configured" : "needs setup"}`],
selectionHint: configured ? "configured" : "urbit messenger",
quickstartScore: configured ? 1 : 4,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
const override = accountOverrides[channel]?.trim();
const defaultAccountId = DEFAULT_ACCOUNT_ID;
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
if (shouldPromptAccountIds && !override) {
accountId = await promptAccountId({
cfg,
prompter,
label: "Tlon",
currentId: accountId,
listAccountIds: listTlonAccountIds,
defaultAccountId,
});
}
const resolved = resolveTlonAccount(cfg, accountId);
await noteTlonHelp(prompter);
const ship = await prompter.text({
message: "Ship name",
placeholder: "~sampel-palnet",
initialValue: resolved.ship ?? undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const url = await prompter.text({
message: "Ship URL",
placeholder: "https://your-ship-host",
initialValue: resolved.url ?? undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const code = await prompter.text({
message: "Login code",
placeholder: "lidlut-tabwed-pillex-ridrup",
initialValue: resolved.code ?? undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const wantsGroupChannels = await prompter.confirm({
message: "Add group channels manually? (optional)",
initialValue: false,
});
let groupChannels: string[] | undefined;
if (wantsGroupChannels) {
const entry = await prompter.text({
message: "Group channels (comma-separated)",
placeholder: "chat/~host-ship/general, chat/~host-ship/support",
});
const parsed = parseList(String(entry ?? ""));
groupChannels = parsed.length > 0 ? parsed : undefined;
}
const wantsAllowlist = await prompter.confirm({
message: "Restrict DMs with an allowlist?",
initialValue: false,
});
let dmAllowlist: string[] | undefined;
if (wantsAllowlist) {
const entry = await prompter.text({
message: "DM allowlist (comma-separated ship names)",
placeholder: "~zod, ~nec",
});
const parsed = parseList(String(entry ?? ""));
dmAllowlist = parsed.length > 0 ? parsed : undefined;
}
const autoDiscoverChannels = await prompter.confirm({
message: "Enable auto-discovery of group channels?",
initialValue: resolved.autoDiscoverChannels ?? true,
});
const next = applyAccountConfig({
cfg,
accountId,
input: {
ship: String(ship).trim(),
url: String(url).trim(),
code: String(code).trim(),
groupChannels,
dmAllowlist,
autoDiscoverChannels,
},
});
return { cfg: next, accountId };
},
};

View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setTlonRuntime(next: PluginRuntime) {
runtime = next;
}
export function getTlonRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Tlon runtime not initialized");
}
return runtime;
}

View File

@@ -0,0 +1,79 @@
export type TlonTarget =
| { kind: "dm"; ship: string }
| { kind: "group"; nest: string; hostShip: string; channelName: string };
const SHIP_RE = /^~?[a-z-]+$/i;
const NEST_RE = /^chat\/([^/]+)\/([^/]+)$/i;
export function normalizeShip(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return trimmed;
return trimmed.startsWith("~") ? trimmed : `~${trimmed}`;
}
export function parseChannelNest(raw: string): { hostShip: string; channelName: string } | null {
const match = NEST_RE.exec(raw.trim());
if (!match) return null;
const hostShip = normalizeShip(match[1]);
const channelName = match[2];
return { hostShip, channelName };
}
export function parseTlonTarget(raw?: string | null): TlonTarget | null {
const trimmed = raw?.trim();
if (!trimmed) return null;
const withoutPrefix = trimmed.replace(/^tlon:/i, "");
const dmPrefix = withoutPrefix.match(/^dm[/:](.+)$/i);
if (dmPrefix) {
return { kind: "dm", ship: normalizeShip(dmPrefix[1]) };
}
const groupPrefix = withoutPrefix.match(/^(group|room)[/:](.+)$/i);
if (groupPrefix) {
const groupTarget = groupPrefix[2].trim();
if (groupTarget.startsWith("chat/")) {
const parsed = parseChannelNest(groupTarget);
if (!parsed) return null;
return {
kind: "group",
nest: `chat/${parsed.hostShip}/${parsed.channelName}`,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
};
}
const parts = groupTarget.split("/");
if (parts.length === 2) {
const hostShip = normalizeShip(parts[0]);
const channelName = parts[1];
return {
kind: "group",
nest: `chat/${hostShip}/${channelName}`,
hostShip,
channelName,
};
}
return null;
}
if (withoutPrefix.startsWith("chat/")) {
const parsed = parseChannelNest(withoutPrefix);
if (!parsed) return null;
return {
kind: "group",
nest: `chat/${parsed.hostShip}/${parsed.channelName}`,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
};
}
if (SHIP_RE.test(withoutPrefix)) {
return { kind: "dm", ship: normalizeShip(withoutPrefix) };
}
return null;
}
export function formatTargetHint(): string {
return "dm/~sampel-palnet | ~sampel-palnet | chat/~host-ship/channel | group:~host-ship/channel";
}

View File

@@ -0,0 +1,85 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
export type TlonResolvedAccount = {
accountId: string;
name: string | null;
enabled: boolean;
configured: boolean;
ship: string | null;
url: string | null;
code: string | null;
groupChannels: string[];
dmAllowlist: string[];
autoDiscoverChannels: boolean | null;
showModelSignature: boolean | null;
};
export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | null): TlonResolvedAccount {
const base = cfg.channels?.tlon as
| {
name?: string;
enabled?: boolean;
ship?: string;
url?: string;
code?: string;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
showModelSignature?: boolean;
accounts?: Record<string, Record<string, unknown>>;
}
| undefined;
if (!base) {
return {
accountId: accountId || "default",
name: null,
enabled: false,
configured: false,
ship: null,
url: null,
code: null,
groupChannels: [],
dmAllowlist: [],
autoDiscoverChannels: null,
showModelSignature: null,
};
}
const useDefault = !accountId || accountId === "default";
const account = useDefault ? base : (base.accounts?.[accountId] as Record<string, unknown> | undefined);
const ship = (account?.ship ?? base.ship ?? null) as string | null;
const url = (account?.url ?? base.url ?? null) as string | null;
const code = (account?.code ?? base.code ?? null) as string | null;
const groupChannels = (account?.groupChannels ?? base.groupChannels ?? []) as string[];
const dmAllowlist = (account?.dmAllowlist ?? base.dmAllowlist ?? []) as string[];
const autoDiscoverChannels =
(account?.autoDiscoverChannels ?? base.autoDiscoverChannels ?? null) as boolean | null;
const showModelSignature =
(account?.showModelSignature ?? base.showModelSignature ?? null) as boolean | null;
const configured = Boolean(ship && url && code);
return {
accountId: accountId || "default",
name: (account?.name ?? base.name ?? null) as string | null,
enabled: (account?.enabled ?? base.enabled ?? true) !== false,
configured,
ship,
url,
code,
groupChannels,
dmAllowlist,
autoDiscoverChannels,
showModelSignature,
};
}
export function listTlonAccountIds(cfg: ClawdbotConfig): string[] {
const base = cfg.channels?.tlon as
| { ship?: string; accounts?: Record<string, Record<string, unknown>> }
| undefined;
if (!base) return [];
const accounts = base.accounts ?? {};
return [...(base.ship ? ["default"] : []), ...Object.keys(accounts)];
}

View File

@@ -0,0 +1,18 @@
export async function authenticate(url: string, code: string): Promise<string> {
const resp = await fetch(`${url}/~/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `password=${code}`,
});
if (!resp.ok) {
throw new Error(`Login failed with status ${resp.status}`);
}
await resp.text();
const cookie = resp.headers.get("set-cookie");
if (!cookie) {
throw new Error("No authentication cookie received");
}
return cookie;
}

View File

@@ -0,0 +1,36 @@
import { Urbit } from "@urbit/http-api";
let patched = false;
export function ensureUrbitConnectPatched() {
if (patched) return;
patched = true;
Urbit.prototype.connect = async function patchedConnect() {
const resp = await fetch(`${this.url}/~/login`, {
method: "POST",
body: `password=${this.code}`,
credentials: "include",
});
if (resp.status >= 400) {
throw new Error(`Login failed with status ${resp.status}`);
}
const cookie = resp.headers.get("set-cookie");
if (cookie) {
const match = /urbauth-~([\w-]+)/.exec(cookie);
if (match) {
if (!(this as unknown as { ship?: string | null }).ship) {
(this as unknown as { ship?: string | null }).ship = match[1];
}
(this as unknown as { nodeId?: string }).nodeId = match[1];
}
(this as unknown as { cookie?: string }).cookie = cookie;
}
await (this as typeof Urbit.prototype).getShipName();
await (this as typeof Urbit.prototype).getOurName();
};
}
export { Urbit };

View File

@@ -0,0 +1,114 @@
import { unixToDa, formatUd } from "@urbit/aura";
export type TlonPokeApi = {
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
};
type SendTextParams = {
api: TlonPokeApi;
fromShip: string;
toShip: string;
text: string;
};
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
const story = [{ inline: [text] }];
const sentAt = Date.now();
const idUd = formatUd(unixToDa(sentAt));
const id = `${fromShip}/${idUd}`;
const delta = {
add: {
memo: {
content: story,
author: fromShip,
sent: sentAt,
},
kind: null,
time: null,
},
};
const action = {
ship: toShip,
diff: { id, delta },
};
await api.poke({
app: "chat",
mark: "chat-dm-action",
json: action,
});
return { channel: "tlon", messageId: id };
}
type SendGroupParams = {
api: TlonPokeApi;
fromShip: string;
hostShip: string;
channelName: string;
text: string;
replyToId?: string | null;
};
export async function sendGroupMessage({
api,
fromShip,
hostShip,
channelName,
text,
replyToId,
}: SendGroupParams) {
const story = [{ inline: [text] }];
const sentAt = Date.now();
const action = {
channel: {
nest: `chat/${hostShip}/${channelName}`,
action: replyToId
? {
reply: {
id: replyToId,
delta: {
add: {
memo: {
content: story,
author: fromShip,
sent: sentAt,
},
},
},
},
}
: {
post: {
add: {
content: story,
author: fromShip,
sent: sentAt,
kind: "/chat",
blob: null,
meta: null,
},
},
},
},
};
await api.poke({
app: "channels",
mark: "channel-action-1",
json: action,
});
return { channel: "tlon", messageId: `${fromShip}/${sentAt}` };
}
export function buildMediaText(text: string | undefined, mediaUrl: string | undefined): string {
const cleanText = text?.trim() ?? "";
const cleanUrl = mediaUrl?.trim() ?? "";
if (cleanText && cleanUrl) return `${cleanText}\n${cleanUrl}`;
if (cleanUrl) return cleanUrl;
return cleanText;
}

View File

@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { UrbitSSEClient } from "./sse-client.js";
const mockFetch = vi.fn();
describe("UrbitSSEClient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("sends subscriptions added after connect", async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" });
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
(client as { isConnected: boolean }).isConnected = true;
await client.subscribe({
app: "chat",
path: "/dm/~zod",
event: () => {},
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe(client.channelUrl);
expect(init.method).toBe("PUT");
const body = JSON.parse(init.body as string);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({
action: "subscribe",
app: "chat",
path: "/dm/~zod",
});
});
});

View File

@@ -0,0 +1,367 @@
import { Readable } from "node:stream";
export type UrbitSseLogger = {
log?: (message: string) => void;
error?: (message: string) => void;
};
type UrbitSseOptions = {
ship?: string;
onReconnect?: (client: UrbitSSEClient) => Promise<void> | void;
autoReconnect?: boolean;
maxReconnectAttempts?: number;
reconnectDelay?: number;
maxReconnectDelay?: number;
logger?: UrbitSseLogger;
};
export class UrbitSSEClient {
url: string;
cookie: string;
ship: string;
channelId: string;
channelUrl: string;
subscriptions: Array<{
id: number;
action: "subscribe";
ship: string;
app: string;
path: string;
}> = [];
eventHandlers = new Map<
number,
{ event?: (data: unknown) => void; err?: (error: unknown) => void; quit?: () => void }
>();
aborted = false;
streamController: AbortController | null = null;
onReconnect: UrbitSseOptions["onReconnect"] | null;
autoReconnect: boolean;
reconnectAttempts = 0;
maxReconnectAttempts: number;
reconnectDelay: number;
maxReconnectDelay: number;
isConnected = false;
logger: UrbitSseLogger;
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
this.url = url;
this.cookie = cookie.split(";")[0];
this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromUrl(url);
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
this.channelUrl = `${url}/~/channel/${this.channelId}`;
this.onReconnect = options.onReconnect ?? null;
this.autoReconnect = options.autoReconnect !== false;
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
this.reconnectDelay = options.reconnectDelay ?? 1000;
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
this.logger = options.logger ?? {};
}
private resolveShipFromUrl(url: string): string {
try {
const parsed = new URL(url);
const host = parsed.hostname;
if (host.includes(".")) {
return host.split(".")[0] ?? host;
}
return host;
} catch {
return "";
}
}
async subscribe(params: {
app: string;
path: string;
event?: (data: unknown) => void;
err?: (error: unknown) => void;
quit?: () => void;
}) {
const subId = this.subscriptions.length + 1;
const subscription = {
id: subId,
action: "subscribe",
ship: this.ship,
app: params.app,
path: params.path,
} as const;
this.subscriptions.push(subscription);
this.eventHandlers.set(subId, { event: params.event, err: params.err, quit: params.quit });
if (this.isConnected) {
try {
await this.sendSubscription(subscription);
} catch (error) {
const handler = this.eventHandlers.get(subId);
handler?.err?.(error);
}
}
return subId;
}
private async sendSubscription(subscription: {
id: number;
action: "subscribe";
ship: string;
app: string;
path: string;
}) {
const response = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([subscription]),
});
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
throw new Error(`Subscribe failed: ${response.status} - ${errorText}`);
}
}
async connect() {
const createResp = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(this.subscriptions),
});
if (!createResp.ok && createResp.status !== 204) {
throw new Error(`Channel creation failed: ${createResp.status}`);
}
const pokeResp = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([
{
id: Date.now(),
action: "poke",
ship: this.ship,
app: "hood",
mark: "helm-hi",
json: "Opening API channel",
},
]),
});
if (!pokeResp.ok && pokeResp.status !== 204) {
throw new Error(`Channel activation failed: ${pokeResp.status}`);
}
await this.openStream();
this.isConnected = true;
this.reconnectAttempts = 0;
}
async openStream() {
const response = await fetch(this.channelUrl, {
method: "GET",
headers: {
Accept: "text/event-stream",
Cookie: this.cookie,
},
});
if (!response.ok) {
throw new Error(`Stream connection failed: ${response.status}`);
}
this.processStream(response.body).catch((error) => {
if (!this.aborted) {
this.logger.error?.(`Stream error: ${String(error)}`);
for (const { err } of this.eventHandlers.values()) {
if (err) err(error);
}
}
});
}
async processStream(body: ReadableStream<Uint8Array> | Readable | null) {
if (!body) return;
const stream = body instanceof ReadableStream ? Readable.fromWeb(body) : body;
let buffer = "";
try {
for await (const chunk of stream) {
if (this.aborted) break;
buffer += chunk.toString();
let eventEnd;
while ((eventEnd = buffer.indexOf("\n\n")) !== -1) {
const eventData = buffer.substring(0, eventEnd);
buffer = buffer.substring(eventEnd + 2);
this.processEvent(eventData);
}
}
} finally {
if (!this.aborted && this.autoReconnect) {
this.isConnected = false;
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
await this.attemptReconnect();
}
}
}
processEvent(eventData: string) {
const lines = eventData.split("\n");
let data: string | null = null;
for (const line of lines) {
if (line.startsWith("data: ")) {
data = line.substring(6);
}
}
if (!data) return;
try {
const parsed = JSON.parse(data) as { id?: number; json?: unknown; response?: string };
if (parsed.response === "quit") {
if (parsed.id) {
const handlers = this.eventHandlers.get(parsed.id);
if (handlers?.quit) handlers.quit();
}
return;
}
if (parsed.id && this.eventHandlers.has(parsed.id)) {
const { event } = this.eventHandlers.get(parsed.id) ?? {};
if (event && parsed.json) {
event(parsed.json);
}
} else if (parsed.json) {
for (const { event } of this.eventHandlers.values()) {
if (event) event(parsed.json);
}
}
} catch (error) {
this.logger.error?.(`Error parsing SSE event: ${String(error)}`);
}
}
async poke(params: { app: string; mark: string; json: unknown }) {
const pokeId = Date.now();
const pokeData = {
id: pokeId,
action: "poke",
ship: this.ship,
app: params.app,
mark: params.mark,
json: params.json,
};
const response = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([pokeData]),
});
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
}
return pokeId;
}
async scry(path: string) {
const scryUrl = `${this.url}/~/scry${path}`;
const response = await fetch(scryUrl, {
method: "GET",
headers: {
Cookie: this.cookie,
},
});
if (!response.ok) {
throw new Error(`Scry failed: ${response.status} for path ${path}`);
}
return await response.json();
}
async attemptReconnect() {
if (this.aborted || !this.autoReconnect) {
this.logger.log?.("[SSE] Reconnection aborted or disabled");
return;
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.logger.error?.(
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`,
);
return;
}
this.reconnectAttempts += 1;
const delay = Math.min(
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
this.maxReconnectDelay,
);
this.logger.log?.(
`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
this.channelUrl = `${this.url}/~/channel/${this.channelId}`;
if (this.onReconnect) {
await this.onReconnect(this);
}
await this.connect();
this.logger.log?.("[SSE] Reconnection successful!");
} catch (error) {
this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`);
await this.attemptReconnect();
}
}
async close() {
this.aborted = true;
this.isConnected = false;
try {
const unsubscribes = this.subscriptions.map((sub) => ({
id: sub.id,
action: "unsubscribe",
subscription: sub.id,
}));
await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(unsubscribes),
});
await fetch(this.channelUrl, {
method: "DELETE",
headers: {
Cookie: this.cookie,
},
});
} catch (error) {
this.logger.error?.(`Error closing channel: ${String(error)}`);
}
}
}

View File

@@ -147,6 +147,7 @@
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.13.0",
"@aws-sdk/client-bedrock": "^3.975.0",
"@buape/carbon": "0.14.0",
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",

625
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
import type { BedrockClient } from "@aws-sdk/client-bedrock";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn();
const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient;
describe("bedrock discovery", () => {
beforeEach(() => {
sendMock.mockReset();
});
it("filters to active streaming text models and maps modalities", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
{
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
modelName: "Claude 3 Haiku",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: false,
modelLifecycle: { status: "ACTIVE" },
},
{
modelId: "meta.llama3-8b-instruct-v1:0",
modelName: "Llama 3 8B",
providerName: "meta",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "INACTIVE" },
},
{
modelId: "amazon.titan-embed-text-v1",
modelName: "Titan Embed",
providerName: "amazon",
inputModalities: ["TEXT"],
outputModalities: ["EMBEDDING"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet",
reasoning: false,
input: ["text", "image"],
contextWindow: 32000,
maxTokens: 4096,
});
});
it("applies provider filter", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const models = await discoverBedrockModels({
region: "us-east-1",
config: { providerFilter: ["amazon"] },
clientFactory,
});
expect(models).toHaveLength(0);
});
it("uses configured defaults for context and max tokens", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const models = await discoverBedrockModels({
region: "us-east-1",
config: { defaultContextWindow: 64000, defaultMaxTokens: 8192 },
clientFactory,
});
expect(models[0]).toMatchObject({ contextWindow: 64000, maxTokens: 8192 });
});
it("caches results when refreshInterval is enabled", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
await discoverBedrockModels({ region: "us-east-1", clientFactory });
await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(sendMock).toHaveBeenCalledTimes(1);
});
it("skips cache when refreshInterval is 0", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 0 },
clientFactory,
});
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 0 },
clientFactory,
});
expect(sendMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,200 @@
import {
BedrockClient,
ListFoundationModelsCommand,
type ListFoundationModelsCommandOutput,
} from "@aws-sdk/client-bedrock";
import type { BedrockDiscoveryConfig, ModelDefinitionConfig } from "../config/types.js";
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600;
const DEFAULT_CONTEXT_WINDOW = 32000;
const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
type BedrockModelSummary = NonNullable<ListFoundationModelsCommandOutput["modelSummaries"]>[number];
type BedrockDiscoveryCacheEntry = {
expiresAt: number;
value?: ModelDefinitionConfig[];
inFlight?: Promise<ModelDefinitionConfig[]>;
};
const discoveryCache = new Map<string, BedrockDiscoveryCacheEntry>();
let hasLoggedBedrockError = false;
function normalizeProviderFilter(filter?: string[]): string[] {
if (!filter || filter.length === 0) return [];
const normalized = new Set(
filter.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0),
);
return Array.from(normalized).sort();
}
function buildCacheKey(params: {
region: string;
providerFilter: string[];
refreshIntervalSeconds: number;
defaultContextWindow: number;
defaultMaxTokens: number;
}): string {
return JSON.stringify(params);
}
function includesTextModalities(modalities?: Array<string>): boolean {
return (modalities ?? []).some((entry) => entry.toLowerCase() === "text");
}
function isActive(summary: BedrockModelSummary): boolean {
const status = summary.modelLifecycle?.status;
return typeof status === "string" ? status.toUpperCase() === "ACTIVE" : false;
}
function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image"> {
const inputs = summary.inputModalities ?? [];
const mapped = new Set<"text" | "image">();
for (const modality of inputs) {
const lower = modality.toLowerCase();
if (lower === "text") mapped.add("text");
if (lower === "image") mapped.add("image");
}
if (mapped.size === 0) mapped.add("text");
return Array.from(mapped);
}
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
const haystack = `${summary.modelId ?? ""} ${summary.modelName ?? ""}`.toLowerCase();
return haystack.includes("reasoning") || haystack.includes("thinking");
}
function resolveDefaultContextWindow(config?: BedrockDiscoveryConfig): number {
const value = Math.floor(config?.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW);
return value > 0 ? value : DEFAULT_CONTEXT_WINDOW;
}
function resolveDefaultMaxTokens(config?: BedrockDiscoveryConfig): number {
const value = Math.floor(config?.defaultMaxTokens ?? DEFAULT_MAX_TOKENS);
return value > 0 ? value : DEFAULT_MAX_TOKENS;
}
function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]): boolean {
if (filter.length === 0) return true;
const providerName =
summary.providerName ??
(typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined);
const normalized = providerName?.trim().toLowerCase();
if (!normalized) return false;
return filter.includes(normalized);
}
function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): boolean {
if (!summary.modelId?.trim()) return false;
if (!matchesProviderFilter(summary, filter)) return false;
if (summary.responseStreamingSupported !== true) return false;
if (!includesTextModalities(summary.outputModalities)) return false;
if (!isActive(summary)) return false;
return true;
}
function toModelDefinition(
summary: BedrockModelSummary,
defaults: { contextWindow: number; maxTokens: number },
): ModelDefinitionConfig {
const id = summary.modelId?.trim() ?? "";
return {
id,
name: summary.modelName?.trim() || id,
reasoning: inferReasoningSupport(summary),
input: mapInputModalities(summary),
cost: DEFAULT_COST,
contextWindow: defaults.contextWindow,
maxTokens: defaults.maxTokens,
};
}
export function resetBedrockDiscoveryCacheForTest(): void {
discoveryCache.clear();
hasLoggedBedrockError = false;
}
export async function discoverBedrockModels(params: {
region: string;
config?: BedrockDiscoveryConfig;
now?: () => number;
clientFactory?: (region: string) => BedrockClient;
}): Promise<ModelDefinitionConfig[]> {
const refreshIntervalSeconds = Math.max(
0,
Math.floor(params.config?.refreshInterval ?? DEFAULT_REFRESH_INTERVAL_SECONDS),
);
const providerFilter = normalizeProviderFilter(params.config?.providerFilter);
const defaultContextWindow = resolveDefaultContextWindow(params.config);
const defaultMaxTokens = resolveDefaultMaxTokens(params.config);
const cacheKey = buildCacheKey({
region: params.region,
providerFilter,
refreshIntervalSeconds,
defaultContextWindow,
defaultMaxTokens,
});
const now = params.now?.() ?? Date.now();
if (refreshIntervalSeconds > 0) {
const cached = discoveryCache.get(cacheKey);
if (cached?.value && cached.expiresAt > now) {
return cached.value;
}
if (cached?.inFlight) {
return cached.inFlight;
}
}
const clientFactory = params.clientFactory ?? ((region: string) => new BedrockClient({ region }));
const client = clientFactory(params.region);
const discoveryPromise = (async () => {
const response = await client.send(new ListFoundationModelsCommand({}));
const discovered: ModelDefinitionConfig[] = [];
for (const summary of response.modelSummaries ?? []) {
if (!shouldIncludeSummary(summary, providerFilter)) continue;
discovered.push(
toModelDefinition(summary, {
contextWindow: defaultContextWindow,
maxTokens: defaultMaxTokens,
}),
);
}
return discovered.sort((a, b) => a.name.localeCompare(b.name));
})();
if (refreshIntervalSeconds > 0) {
discoveryCache.set(cacheKey, {
expiresAt: now + refreshIntervalSeconds * 1000,
inFlight: discoveryPromise,
});
}
try {
const value = await discoveryPromise;
if (refreshIntervalSeconds > 0) {
discoveryCache.set(cacheKey, {
expiresAt: now + refreshIntervalSeconds * 1000,
value,
});
}
return value;
} catch (error) {
if (refreshIntervalSeconds > 0) {
discoveryCache.delete(cacheKey);
}
if (!hasLoggedBedrockError) {
hasLoggedBedrockError = true;
console.warn(`[bedrock-discovery] Failed to list models: ${String(error)}`);
}
return [];
}
}

View File

@@ -75,12 +75,12 @@ function resolveEnvSourceLabel(params: {
return `${prefix}${params.label}`;
}
export function resolveAwsSdkEnvVarName(): string | undefined {
if (process.env[AWS_BEARER_ENV]?.trim()) return AWS_BEARER_ENV;
if (process.env[AWS_ACCESS_KEY_ENV]?.trim() && process.env[AWS_SECRET_KEY_ENV]?.trim()) {
export function resolveAwsSdkEnvVarName(env: NodeJS.ProcessEnv = process.env): string | undefined {
if (env[AWS_BEARER_ENV]?.trim()) return AWS_BEARER_ENV;
if (env[AWS_ACCESS_KEY_ENV]?.trim() && env[AWS_SECRET_KEY_ENV]?.trim()) {
return AWS_ACCESS_KEY_ENV;
}
if (process.env[AWS_PROFILE_ENV]?.trim()) return AWS_PROFILE_ENV;
if (env[AWS_PROFILE_ENV]?.trim()) return AWS_PROFILE_ENV;
return undefined;
}

View File

@@ -5,6 +5,7 @@ import {
} from "../providers/github-copilot-token.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
@@ -375,3 +376,27 @@ export async function resolveImplicitCopilotProvider(params: {
models: [],
} satisfies ProviderConfig;
}
export async function resolveImplicitBedrockProvider(params: {
agentDir: string;
config?: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
const discoveryConfig = params.config?.models?.bedrockDiscovery;
const enabled = discoveryConfig?.enabled;
const hasAwsCreds = resolveAwsSdkEnvVarName(env) !== undefined;
if (enabled === false) return null;
if (enabled !== true && !hasAwsCreds) return null;
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
const models = await discoverBedrockModels({ region, config: discoveryConfig });
if (models.length === 0) return null;
return {
baseUrl: `https://bedrock-runtime.${region}.amazonaws.com`,
api: "bedrock-converse-stream",
auth: "aws-sdk",
models,
} satisfies ProviderConfig;
}

View File

@@ -6,6 +6,7 @@ import { resolveClawdbotAgentDir } from "./agent-paths.js";
import {
normalizeProviders,
type ProviderConfig,
resolveImplicitBedrockProvider,
resolveImplicitCopilotProvider,
resolveImplicitProviders,
} from "./models-config.providers.js";
@@ -84,6 +85,13 @@ export async function ensureClawdbotModelsJson(
implicit: implicitProviders,
explicit: explicitProviders,
});
const implicitBedrock = await resolveImplicitBedrockProvider({ agentDir, config: cfg });
if (implicitBedrock) {
const existing = providers["amazon-bedrock"];
providers["amazon-bedrock"] = existing
? mergeProviderModels(implicitBedrock, existing)
: implicitBedrock;
}
const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
if (implicitCopilot && !providers["github-copilot"]) {
providers["github-copilot"] = implicitCopilot;

View File

@@ -70,7 +70,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
},
streamSimple: (model: { api: string; provider: string; id: string }) => {
const stream = new actual.AssistantMessageEventStream();
queueMicrotask(() => {
setTimeout(() => {
stream.push({
type: "done",
reason: "stop",
@@ -80,7 +80,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
: buildAssistantMessage(model),
});
stream.end();
});
}, 0);
return stream;
},
};
@@ -165,6 +165,7 @@ const readSessionMessages = async (sessionFile: string) => {
};
describe("runEmbeddedPiAgent", () => {
const itIfNotWin32 = process.platform === "win32" ? it.skip : it;
it("writes models.json into the provided agentDir", async () => {
const sessionFile = nextSessionFile();
@@ -210,35 +211,39 @@ describe("runEmbeddedPiAgent", () => {
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
});
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
itIfNotWin32(
"persists the first user message before assistant output",
{ timeout: 60_000 },
async () => {
const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
const messages = await readSessionMessages(sessionFile);
const firstUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
);
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
if (firstAssistantIndex !== -1) {
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
}
});
const messages = await readSessionMessages(sessionFile);
const firstUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
);
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
if (firstAssistantIndex !== -1) {
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
}
},
);
it("persists the user message when prompt fails before assistant output", async () => {
const sessionFile = nextSessionFile();

View File

@@ -71,6 +71,8 @@ export async function runEmbeddedPiAgent(
const globalLane = resolveGlobalLane(params.lane);
const enqueueGlobal =
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
const enqueueSession =
params.enqueue ?? ((task, opts) => enqueueCommandInLane(sessionLane, task, opts));
const channelHint = params.messageChannel ?? params.messageProvider;
const resolvedToolResultFormat =
params.toolResultFormat ??
@@ -79,8 +81,9 @@ export async function runEmbeddedPiAgent(
? "markdown"
: "plain"
: "markdown");
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
return enqueueCommandInLane(sessionLane, () =>
return enqueueSession(() =>
enqueueGlobal(async () => {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
@@ -272,6 +275,7 @@ export async function runEmbeddedPiAgent(
skillsSnapshot: params.skillsSnapshot,
prompt,
images: params.images,
disableTools: params.disableTools,
provider,
modelId,
model,
@@ -455,7 +459,7 @@ export async function runEmbeddedPiAgent(
cfg: params.config,
agentDir: params.agentDir,
});
if (timedOut) {
if (timedOut && !isProbeSession) {
log.warn(
`Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
);

View File

@@ -196,30 +196,32 @@ export async function runEmbeddedAttempt(
// Check if the model supports native image input
const modelHasVision = params.model.input?.includes("image") ?? false;
const toolsRaw = createClawdbotCodingTools({
exec: {
...params.execOverrides,
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
});
const toolsRaw = params.disableTools
? []
: createClawdbotCodingTools({
exec: {
...params.execOverrides,
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider: params.provider });
logToolSchemasForGoogle({ tools, provider: params.provider });
@@ -595,18 +597,23 @@ export async function runEmbeddedAttempt(
setActiveEmbeddedRun(params.sessionId, queueHandle);
let abortWarnTimer: NodeJS.Timeout | undefined;
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
const abortTimer = setTimeout(
() => {
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
if (!isProbeSession) {
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
}
abortRun(true);
if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => {
if (!activeSession.isStreaming) return;
log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
if (!isProbeSession) {
log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
}
}, 10_000);
}
},

View File

@@ -44,6 +44,8 @@ export type RunEmbeddedPiAgentParams = {
images?: ImageContent[];
/** Optional client-provided tools (OpenResponses hosted tools). */
clientTools?: ClientToolDefinition[];
/** Disable built-in tools for this run (LLM-only mode). */
disableTools?: boolean;
provider?: string;
model?: string;
authProfileId?: string;

View File

@@ -36,6 +36,8 @@ export type EmbeddedRunAttemptParams = {
images?: ImageContent[];
/** Optional client-provided tools (OpenResponses hosted tools). */
clientTools?: ClientToolDefinition[];
/** Disable built-in tools for this run (LLM-only mode). */
disableTools?: boolean;
provider: string;
modelId: string;
model: Model<Api>;

View File

@@ -0,0 +1,36 @@
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { filterToolsByPolicy, isToolAllowedByPolicyName } from "./pi-tools.policy.js";
function createStubTool(name: string): AgentTool<unknown, unknown> {
return {
name,
label: name,
description: "",
parameters: {},
execute: async () => ({}) as AgentToolResult<unknown>,
};
}
describe("pi-tools.policy", () => {
it("treats * in allow as allow-all", () => {
const tools = [createStubTool("read"), createStubTool("exec")];
const filtered = filterToolsByPolicy(tools, { allow: ["*"] });
expect(filtered.map((tool) => tool.name)).toEqual(["read", "exec"]);
});
it("treats * in deny as deny-all", () => {
const tools = [createStubTool("read"), createStubTool("exec")];
const filtered = filterToolsByPolicy(tools, { deny: ["*"] });
expect(filtered).toEqual([]);
});
it("supports wildcard allow/deny patterns", () => {
expect(isToolAllowedByPolicyName("web_fetch", { allow: ["web_*"] })).toBe(true);
expect(isToolAllowedByPolicyName("web_search", { deny: ["web_*"] })).toBe(false);
});
it("keeps apply_patch when exec is allowlisted", () => {
expect(isToolAllowedByPolicyName("apply_patch", { allow: ["exec"] })).toBe(true);
});
});

View File

@@ -4,6 +4,52 @@ import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxToolPolicy } from "./sandbox.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
type CompiledPattern =
| { kind: "all" }
| { kind: "exact"; value: string }
| { kind: "regex"; value: RegExp };
function compilePattern(pattern: string): CompiledPattern {
const normalized = normalizeToolName(pattern);
if (!normalized) return { kind: "exact", value: "" };
if (normalized === "*") return { kind: "all" };
if (!normalized.includes("*")) return { kind: "exact", value: normalized };
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return {
kind: "regex",
value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`),
};
}
function compilePatterns(patterns?: string[]): CompiledPattern[] {
if (!Array.isArray(patterns)) return [];
return expandToolGroups(patterns)
.map(compilePattern)
.filter((pattern) => pattern.kind !== "exact" || pattern.value);
}
function matchesAny(name: string, patterns: CompiledPattern[]): boolean {
for (const pattern of patterns) {
if (pattern.kind === "all") return true;
if (pattern.kind === "exact" && name === pattern.value) return true;
if (pattern.kind === "regex" && pattern.value.test(name)) return true;
}
return false;
}
function makeToolPolicyMatcher(policy: SandboxToolPolicy) {
const deny = compilePatterns(policy.deny);
const allow = compilePatterns(policy.allow);
return (name: string) => {
const normalized = normalizeToolName(name);
if (matchesAny(normalized, deny)) return false;
if (allow.length === 0) return true;
if (matchesAny(normalized, allow)) return true;
if (normalized === "apply_patch" && matchesAny("exec", allow)) return true;
return false;
};
}
const DEFAULT_SUBAGENT_TOOL_DENY = [
// Session management - main agent orchestrates
"sessions_list",
@@ -35,22 +81,13 @@ export function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPoli
export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean {
if (!policy) return true;
const deny = new Set(expandToolGroups(policy.deny));
const allowRaw = expandToolGroups(policy.allow);
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
const normalized = normalizeToolName(name);
if (deny.has(normalized)) return false;
if (allow) {
if (allow.has(normalized)) return true;
if (normalized === "apply_patch" && allow.has("exec")) return true;
return false;
}
return true;
return makeToolPolicyMatcher(policy)(name);
}
export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolPolicy) {
if (!policy) return tools;
return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy));
const matcher = makeToolPolicyMatcher(policy);
return tools.filter((tool) => matcher(tool.name));
}
type ToolPolicyConfig = {

View File

@@ -50,7 +50,12 @@ async function dockerImageExists(image: string) {
const result = await execDocker(["image", "inspect", image], {
allowFailure: true,
});
return result.code === 0;
if (result.code === 0) return true;
const stderr = result.stderr.trim();
if (stderr.includes("No such image")) {
return false;
}
throw new Error(`Failed to inspect sandbox image: ${stderr}`);
}
export async function ensureDockerImage(image: string) {

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import type { SandboxToolPolicy } from "./types.js";
import { isToolAllowed } from "./tool-policy.js";
describe("sandbox tool policy", () => {
it("allows all tools with * allow", () => {
const policy: SandboxToolPolicy = { allow: ["*"], deny: [] };
expect(isToolAllowed(policy, "browser")).toBe(true);
});
it("denies all tools with * deny", () => {
const policy: SandboxToolPolicy = { allow: [], deny: ["*"] };
expect(isToolAllowed(policy, "read")).toBe(false);
});
it("supports wildcard patterns", () => {
const policy: SandboxToolPolicy = { allow: ["web_*"] };
expect(isToolAllowed(policy, "web_fetch")).toBe(true);
expect(isToolAllowed(policy, "read")).toBe(false);
});
});

View File

@@ -8,12 +8,46 @@ import type {
SandboxToolPolicySource,
} from "./types.js";
type CompiledPattern =
| { kind: "all" }
| { kind: "exact"; value: string }
| { kind: "regex"; value: RegExp };
function compilePattern(pattern: string): CompiledPattern {
const normalized = pattern.trim().toLowerCase();
if (!normalized) return { kind: "exact", value: "" };
if (normalized === "*") return { kind: "all" };
if (!normalized.includes("*")) return { kind: "exact", value: normalized };
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return {
kind: "regex",
value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`),
};
}
function compilePatterns(patterns?: string[]): CompiledPattern[] {
if (!Array.isArray(patterns)) return [];
return expandToolGroups(patterns)
.map(compilePattern)
.filter((pattern) => pattern.kind !== "exact" || pattern.value);
}
function matchesAny(name: string, patterns: CompiledPattern[]): boolean {
for (const pattern of patterns) {
if (pattern.kind === "all") return true;
if (pattern.kind === "exact" && name === pattern.value) return true;
if (pattern.kind === "regex" && pattern.value.test(name)) return true;
}
return false;
}
export function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const deny = new Set(expandToolGroups(policy.deny));
if (deny.has(name.toLowerCase())) return false;
const allow = expandToolGroups(policy.allow);
const normalized = name.trim().toLowerCase();
const deny = compilePatterns(policy.deny);
if (matchesAny(normalized, deny)) return false;
const allow = compilePatterns(policy.allow);
if (allow.length === 0) return true;
return allow.includes(name.toLowerCase());
return matchesAny(normalized, allow);
}
export function resolveSandboxToolPolicyForAgent(

View File

@@ -81,6 +81,14 @@ export async function extractReadableContent(params: {
url: string;
extractMode: ExtractMode;
}): Promise<{ text: string; title?: string } | null> {
const fallback = (): { text: string; title?: string } => {
const rendered = htmlToMarkdown(params.html);
if (params.extractMode === "text") {
const text = markdownToText(rendered.text) || normalizeWhitespace(stripTags(params.html));
return { text, title: rendered.title };
}
return rendered;
};
try {
const [{ Readability }, { parseHTML }] = await Promise.all([
import("@mozilla/readability"),
@@ -94,15 +102,15 @@ export async function extractReadableContent(params: {
}
const reader = new Readability(document, { charThreshold: 0 });
const parsed = reader.parse();
if (!parsed?.content) return null;
if (!parsed?.content) return fallback();
const title = parsed.title || undefined;
if (params.extractMode === "text") {
const text = normalizeWhitespace(parsed.textContent ?? "");
return { text, title };
return text ? { text, title } : fallback();
}
const rendered = htmlToMarkdown(parsed.content);
return { text: rendered.text, title: title ?? rendered.title };
} catch {
return null;
return fallback();
}
}

View File

@@ -97,7 +97,7 @@ afterEach(() => {
describe("trigger handling", () => {
it("includes the error cause when the embedded agent throws", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(new Error("sandbox is not defined"));
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(new Error("sandbox is not defined."));
const res = await getReplyFromConfig(
{
@@ -111,7 +111,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe(
"⚠️ Agent failed before reply: sandbox is not defined. Check gateway logs for details.",
"⚠️ Agent failed before reply: sandbox is not defined.\nLogs: clawdbot logs --follow",
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});

View File

@@ -507,14 +507,17 @@ export async function runAgentTurnWithFallback(params: {
}
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
const trimmedMessage = message.replace(/\.\s*$/, "");
const fallbackText = isContextOverflow
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
: isRoleOrderingError
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
: `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: clawdbot logs --follow`;
return {
kind: "final",
payload: {
text: isContextOverflow
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
: isRoleOrderingError
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
: `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`,
text: fallbackText,
},
};
}

View File

@@ -1,4 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
addSubagentRunForTests,
@@ -11,6 +15,17 @@ import { resetBashChatCommandForTests } from "./bash-command.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
let testWorkspaceDir = os.tmpdir();
beforeAll(async () => {
testWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-commands-"));
await fs.writeFile(path.join(testWorkspaceDir, "AGENTS.md"), "# Agents\n", "utf-8");
});
afterAll(async () => {
await fs.rm(testWorkspaceDir, { recursive: true, force: true });
});
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
@@ -37,7 +52,7 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
workspaceDir: testWorkspaceDir,
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
@@ -13,4 +16,37 @@ describe("channel plugin catalog", () => {
const ids = listChannelPluginCatalogEntries().map((entry) => entry.id);
expect(ids).toContain("msteams");
});
it("includes external catalog entries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-catalog-"));
const catalogPath = path.join(dir, "catalog.json");
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [
{
name: "@clawdbot/demo-channel",
clawdbot: {
channel: {
id: "demo-channel",
label: "Demo Channel",
selectionLabel: "Demo Channel",
docsPath: "/channels/demo-channel",
blurb: "Demo entry",
order: 999,
},
install: {
npmSpec: "@clawdbot/demo-channel",
},
},
},
],
}),
);
const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map(
(entry) => entry.id,
);
expect(ids).toContain("demo-channel");
});
});

View File

@@ -1,8 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import { discoverClawdbotPlugins } from "../../plugins/discovery.js";
import type { PluginOrigin } from "../../plugins/types.js";
import type { ClawdbotPackageManifest } from "../../plugins/manifest.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import type { ChannelMeta } from "./types.js";
export type ChannelUiMetaEntry = {
@@ -33,6 +35,7 @@ export type ChannelPluginCatalogEntry = {
type CatalogOptions = {
workspaceDir?: string;
catalogPaths?: string[];
};
const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
@@ -42,6 +45,74 @@ const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
bundled: 3,
};
type ExternalCatalogEntry = {
name?: string;
version?: string;
description?: string;
clawdbot?: ClawdbotPackageManifest;
};
const DEFAULT_CATALOG_PATHS = [
path.join(CONFIG_DIR, "mpm", "plugins.json"),
path.join(CONFIG_DIR, "mpm", "catalog.json"),
path.join(CONFIG_DIR, "plugins", "catalog.json"),
];
const ENV_CATALOG_PATHS = ["CLAWDBOT_PLUGIN_CATALOG_PATHS", "CLAWDBOT_MPM_CATALOG_PATHS"];
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] {
if (Array.isArray(raw)) {
return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry));
}
if (!isRecord(raw)) return [];
const list = raw.entries ?? raw.packages ?? raw.plugins;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry));
}
function splitEnvPaths(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
return trimmed
.split(/[;,]/g)
.flatMap((chunk) => chunk.split(path.delimiter))
.map((entry) => entry.trim())
.filter(Boolean);
}
function resolveExternalCatalogPaths(options: CatalogOptions): string[] {
if (options.catalogPaths && options.catalogPaths.length > 0) {
return options.catalogPaths.map((entry) => entry.trim()).filter(Boolean);
}
for (const key of ENV_CATALOG_PATHS) {
const raw = process.env[key];
if (raw && raw.trim()) {
return splitEnvPaths(raw);
}
}
return DEFAULT_CATALOG_PATHS;
}
function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEntry[] {
const paths = resolveExternalCatalogPaths(options);
const entries: ExternalCatalogEntry[] = [];
for (const rawPath of paths) {
const resolved = resolveUserPath(rawPath);
if (!fs.existsSync(resolved)) continue;
try {
const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown;
entries.push(...parseCatalogEntries(payload));
} catch {
// Ignore invalid catalog files.
}
}
return entries;
}
function toChannelMeta(params: {
channel: NonNullable<ClawdbotPackageManifest["channel"]>;
id: string;
@@ -132,6 +203,13 @@ function buildCatalogEntry(candidate: {
return { id, meta, install };
}
function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null {
return buildCatalogEntry({
packageName: entry.name,
packageClawdbot: entry.clawdbot,
});
}
export function buildChannelUiCatalog(
plugins: Array<{ id: string; meta: ChannelMeta }>,
): ChannelUiCatalog {
@@ -176,6 +254,15 @@ export function listChannelPluginCatalogEntries(
}
}
const externalEntries = loadExternalCatalogEntries(options)
.map((entry) => buildExternalCatalogEntry(entry))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
for (const entry of externalEntries) {
if (!resolved.has(entry.id)) {
resolved.set(entry.id, { entry, priority: 99 });
}
}
return Array.from(resolved.values())
.map(({ entry }) => entry)
.sort((a, b) => {

View File

@@ -39,6 +39,12 @@ export type ChannelSetupInput = {
password?: string;
deviceName?: string;
initialSyncLimit?: number;
ship?: string;
url?: string;
code?: string;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
};
export type ChannelStatusIssue = {

View File

@@ -51,14 +51,14 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
const line1 = `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)}`;
const line2 = `${" ".repeat(prefix.length)}${theme.muted("—")} ${theme.accentDim(tagline)}`;
const line2 = `${" ".repeat(prefix.length)}${theme.accentDim(tagline)}`;
return `${line1}\n${line2}`;
}
if (fitsOnOneLine) {
return plainFullLine;
}
const line1 = `${title} ${version} (${commitLabel})`;
const line2 = `${" ".repeat(prefix.length)}${tagline}`;
const line2 = `${" ".repeat(prefix.length)}${tagline}`;
return `${line1}\n${line2}`;
}

View File

@@ -1,14 +1,29 @@
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
function dedupe(values: string[]): string[] {
const seen = new Set<string>();
const resolved: string[] = [];
for (const value of values) {
if (!value || seen.has(value)) continue;
seen.add(value);
resolved.push(value);
}
return resolved;
}
export function resolveCliChannelOptions(): string[] {
const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id);
const base = dedupe([...CHAT_CHANNEL_ORDER, ...catalog]);
if (isTruthyEnvValue(process.env.CLAWDBOT_EAGER_CHANNEL_OPTIONS)) {
ensurePluginRegistryLoaded();
return listChannelPlugins().map((plugin) => plugin.id);
const pluginIds = listChannelPlugins().map((plugin) => plugin.id);
return dedupe([...base, ...pluginIds]);
}
return [...CHAT_CHANNEL_ORDER];
return base;
}
export function formatCliChannelOptions(extra: string[] = []): string {

View File

@@ -1,5 +1,5 @@
import type { Command } from "commander";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { formatCliChannelOptions } from "./channel-options.js";
import {
channelsAddCommand,
channelsCapabilitiesCommand,
@@ -42,6 +42,12 @@ const optionNamesAdd = [
"password",
"deviceName",
"initialSyncLimit",
"ship",
"url",
"code",
"groupChannels",
"dmAllowlist",
"autoDiscoverChannels",
] as const;
const optionNamesRemove = ["channel", "account", "delete"] as const;
@@ -58,9 +64,7 @@ function runChannelsCommandWithDanger(action: () => Promise<void>, label: string
}
export function registerChannelsCli(program: Command) {
const channelNames = listChannelPlugins()
.map((plugin) => plugin.id)
.join("|");
const channelNames = formatCliChannelOptions();
const channels = program
.command("channels")
.description("Manage chat channel accounts")
@@ -99,7 +103,7 @@ export function registerChannelsCli(program: Command) {
channels
.command("capabilities")
.description("Show provider capabilities (intents/scopes + supported features)")
.option("--channel <name>", `Channel (${channelNames}|all)`)
.option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])})`)
.option("--account <id>", "Account id (only with --channel)")
.option("--target <dest>", "Channel target for permission audit (Discord channel:<id>)")
.option("--timeout <ms>", "Timeout in ms", "10000")
@@ -136,7 +140,7 @@ export function registerChannelsCli(program: Command) {
channels
.command("logs")
.description("Show recent channel logs from the gateway log file")
.option("--channel <name>", `Channel (${channelNames}|all)`, "all")
.option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])})`, "all")
.option("--lines <n>", "Number of lines (default: 200)", "200")
.option("--json", "Output JSON", false)
.action(async (opts) => {
@@ -171,6 +175,13 @@ export function registerChannelsCli(program: Command) {
.option("--password <password>", "Matrix password")
.option("--device-name <name>", "Matrix device name")
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
.option("--ship <ship>", "Tlon ship name (~sampel-palnet)")
.option("--url <url>", "Tlon ship URL")
.option("--code <code>", "Tlon login code")
.option("--group-channels <list>", "Tlon group channels (comma-separated)")
.option("--dm-allowlist <list>", "Tlon DM allowlist (comma-separated ships)")
.option("--auto-discover-channels", "Tlon auto-discover group channels")
.option("--no-auto-discover-channels", "Disable Tlon auto-discovery")
.option("--use-env", "Use env token (default account only)", false)
.action(async (opts, command) => {
await runChannelsCommand(async () => {

View File

@@ -43,6 +43,12 @@ export function applyChannelAccountConfig(params: {
password?: string;
deviceName?: string;
initialSyncLimit?: number;
ship?: string;
url?: string;
code?: string;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
}): ClawdbotConfig {
const accountId = normalizeAccountId(params.accountId);
const plugin = getChannelPlugin(params.channel);
@@ -71,6 +77,12 @@ export function applyChannelAccountConfig(params: {
password: params.password,
deviceName: params.deviceName,
initialSyncLimit: params.initialSyncLimit,
ship: params.ship,
url: params.url,
code: params.code,
groupChannels: params.groupChannels,
dmAllowlist: params.dmAllowlist,
autoDiscoverChannels: params.autoDiscoverChannels,
};
return apply({ cfg: params.cfg, accountId, input });
}

View File

@@ -1,11 +1,17 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { writeConfigFile } from "../../config/config.js";
import { writeConfigFile, type ClawdbotConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { setupChannels } from "../onboard-channels.js";
import type { ChannelChoice } from "../onboard-types.js";
import {
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
} from "../onboarding/plugin-install.js";
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
@@ -34,8 +40,33 @@ export type ChannelsAddOptions = {
password?: string;
deviceName?: string;
initialSyncLimit?: number | string;
ship?: string;
url?: string;
code?: string;
groupChannels?: string;
dmAllowlist?: string;
autoDiscoverChannels?: boolean;
};
function parseList(value: string | undefined): string[] | undefined {
if (!value?.trim()) return undefined;
const parsed = value
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
return parsed.length > 0 ? parsed : undefined;
}
function resolveCatalogChannelEntry(raw: string, cfg: ClawdbotConfig | null) {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) return undefined;
const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined;
return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => {
if (entry.id.toLowerCase() === trimmed) return true;
return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed);
});
}
export async function channelsAddCommand(
opts: ChannelsAddOptions,
runtime: RuntimeEnv = defaultRuntime,
@@ -43,6 +74,7 @@ export async function channelsAddCommand(
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
let nextConfig = cfg;
const useWizard = shouldUseWizard(params);
if (useWizard) {
@@ -99,9 +131,31 @@ export async function channelsAddCommand(
return;
}
const channel = normalizeChannelId(opts.channel);
const rawChannel = String(opts.channel ?? "");
let channel = normalizeChannelId(rawChannel);
let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig);
if (!channel && catalogEntry) {
const prompter = createClackPrompter();
const workspaceDir = resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig));
const result = await ensureOnboardingPluginInstalled({
cfg: nextConfig,
entry: catalogEntry,
prompter,
runtime,
workspaceDir,
});
nextConfig = result.cfg;
if (!result.installed) return;
reloadOnboardingPluginRegistry({ cfg: nextConfig, runtime, workspaceDir });
channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId);
}
if (!channel) {
runtime.error(`Unknown channel: ${String(opts.channel ?? "")}`);
const hint = catalogEntry
? `Plugin ${catalogEntry.meta.label} could not be loaded after install.`
: `Unknown channel: ${String(opts.channel ?? "")}`;
runtime.error(hint);
runtime.exit(1);
return;
}
@@ -113,7 +167,7 @@ export async function channelsAddCommand(
return;
}
const accountId =
plugin.setup.resolveAccountId?.({ cfg, accountId: opts.account }) ??
plugin.setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account }) ??
normalizeAccountId(opts.account);
const useEnv = opts.useEnv === true;
const initialSyncLimit =
@@ -122,8 +176,11 @@ export async function channelsAddCommand(
: typeof opts.initialSyncLimit === "string" && opts.initialSyncLimit.trim()
? Number.parseInt(opts.initialSyncLimit, 10)
: undefined;
const groupChannels = parseList(opts.groupChannels);
const dmAllowlist = parseList(opts.dmAllowlist);
const validationError = plugin.setup.validateInput?.({
cfg,
cfg: nextConfig,
accountId,
input: {
name: opts.name,
@@ -148,6 +205,12 @@ export async function channelsAddCommand(
deviceName: opts.deviceName,
initialSyncLimit,
useEnv,
ship: opts.ship,
url: opts.url,
code: opts.code,
groupChannels,
dmAllowlist,
autoDiscoverChannels: opts.autoDiscoverChannels,
},
});
if (validationError) {
@@ -156,8 +219,8 @@ export async function channelsAddCommand(
return;
}
const nextConfig = applyChannelAccountConfig({
cfg,
nextConfig = applyChannelAccountConfig({
cfg: nextConfig,
channel,
accountId,
name: opts.name,
@@ -182,6 +245,12 @@ export async function channelsAddCommand(
deviceName: opts.deviceName,
initialSyncLimit,
useEnv,
ship: opts.ship,
url: opts.url,
code: opts.code,
groupChannels,
dmAllowlist,
autoDiscoverChannels: opts.autoDiscoverChannels,
});
await writeConfigFile(nextConfig);

View File

@@ -78,8 +78,12 @@ async function dockerImageExists(image: string): Promise<boolean> {
try {
await runExec("docker", ["image", "inspect", image], { timeoutMs: 5_000 });
return true;
} catch {
return false;
} catch (error: any) {
const stderr = error?.stderr || error?.message || "";
if (String(stderr).includes("No such image")) {
return false;
}
throw error;
}
}

View File

@@ -11,6 +11,7 @@ const resolveAuthStorePathForDisplay = vi
.mockReturnValue("/tmp/clawdbot-agent/auth-profiles.json");
const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null);
const resolveEnvApiKey = vi.fn().mockReturnValue(undefined);
const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined);
const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined);
const discoverAuthStorage = vi.fn().mockReturnValue({});
const discoverModels = vi.fn();
@@ -39,6 +40,7 @@ vi.mock("../agents/auth-profiles.js", () => ({
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey,
resolveAwsSdkEnvVarName,
getCustomProviderApiKey,
}));

View File

@@ -6,6 +6,7 @@ import {
ensureAuthProfileStore,
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { describeFailoverError } from "../../agents/failover-error.js";
@@ -143,6 +144,25 @@ function buildProbeTargets(params: {
});
const profileIds = listProfilesForProvider(store, providerKey);
const explicitOrder = (() => {
const order = store.order;
if (order) {
for (const [key, value] of Object.entries(order)) {
if (normalizeProviderId(key) === providerKey) return value;
}
}
const cfgOrder = cfg?.auth?.order;
if (cfgOrder) {
for (const [key, value] of Object.entries(cfgOrder)) {
if (normalizeProviderId(key) === providerKey) return value;
}
}
return undefined;
})();
const allowedProfiles =
explicitOrder && explicitOrder.length > 0
? new Set(resolveAuthProfileOrder({ cfg, store, provider: providerKey }))
: null;
const filteredProfiles = profileFilter.size
? profileIds.filter((id) => profileFilter.has(id))
: profileIds;
@@ -152,6 +172,32 @@ function buildProbeTargets(params: {
const profile = store.profiles[profileId];
const mode = profile?.type;
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
if (explicitOrder && !explicitOrder.includes(profileId)) {
results.push({
provider: providerKey,
model: model ? `${model.provider}/${model.model}` : undefined,
profileId,
label,
source: "profile",
mode,
status: "unknown",
error: "Excluded by auth.order for this provider.",
});
continue;
}
if (allowedProfiles && !allowedProfiles.has(profileId)) {
results.push({
provider: providerKey,
model: model ? `${model.provider}/${model.model}` : undefined,
profileId,
label,
source: "profile",
mode,
status: "unknown",
error: "Auth profile credentials are missing or expired.",
});
continue;
}
if (!model) {
results.push({
provider: providerKey,

View File

@@ -4,7 +4,11 @@ import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-age
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import { listProfilesForProvider } from "../../agents/auth-profiles.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import {
getCustomProviderApiKey,
resolveAwsSdkEnvVarName,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { ensureClawdbotModelsJson } from "../../agents/models-config.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { ModelRow } from "./list.types.js";
@@ -28,6 +32,7 @@ const isLocalBaseUrl = (baseUrl: string) => {
const hasAuthForProvider = (provider: string, cfg: ClawdbotConfig, authStore: AuthProfileStore) => {
if (listProfilesForProvider(authStore, provider).length > 0) return true;
if (provider === "amazon-bedrock" && resolveAwsSdkEnvVarName()) return true;
if (resolveEnvApiKey(provider)) return true;
if (getCustomProviderApiKey(cfg, provider)) return true;
return false;

View File

@@ -28,6 +28,7 @@ import {
} from "../../infra/provider-usage.js";
import type { RuntimeEnv } from "../../runtime.js";
import { colorize, theme } from "../../terminal/theme.js";
import { renderTable } from "../../terminal/table.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { shortenHomePath } from "../../utils.js";
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
@@ -35,7 +36,6 @@ import { isRich } from "./list.format.js";
import {
describeProbeSummary,
formatProbeLatency,
groupProbeResults,
runAuthProbes,
sortProbeResults,
type AuthProbeSummary,
@@ -571,7 +571,8 @@ export async function modelsStatusCommand(
if (probeSummary.results.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
} else {
const grouped = groupProbeResults(sortProbeResults(probeSummary.results));
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const sorted = sortProbeResults(probeSummary.results);
const statusColor = (status: string) => {
if (status === "ok") return theme.success;
if (status === "rate_limit") return theme.warn;
@@ -580,29 +581,32 @@ export async function modelsStatusCommand(
if (status === "no_model") return theme.muted;
return theme.muted;
};
for (const [provider, results] of grouped) {
const modelLabel = results.find((r) => r.model)?.model ?? "-";
runtime.log(
`- ${theme.heading(provider)}${colorize(
rich,
theme.muted,
modelLabel ? ` (model: ${modelLabel})` : "",
)}`,
);
for (const result of results) {
const status = colorize(rich, statusColor(result.status), result.status);
const latency = formatProbeLatency(result.latencyMs);
const mode = result.mode ? ` (${result.mode})` : "";
const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : "";
runtime.log(
` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize(
rich,
theme.muted,
latency,
)}${detail}`,
);
}
}
const rows = sorted.map((result) => {
const status = colorize(rich, statusColor(result.status), result.status);
const latency = formatProbeLatency(result.latencyMs);
const modelLabel = result.model ?? `${result.provider}/-`;
const modeLabel = result.mode ? ` ${colorize(rich, theme.muted, `(${result.mode})`)}` : "";
const profile = `${colorize(rich, theme.accent, result.label)}${modeLabel}`;
const detail = result.error?.trim();
const detailLabel = detail ? `\n${colorize(rich, theme.muted, `${detail}`)}` : "";
const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}${detailLabel}`;
return {
Model: colorize(rich, theme.heading, modelLabel),
Profile: profile,
Status: statusLabel,
};
});
runtime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Model", header: "Model", minWidth: 18 },
{ key: "Profile", header: "Profile", minWidth: 24 },
{ key: "Status", header: "Status", minWidth: 12 },
],
rows,
}).trimEnd(),
);
runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
}
}

View File

@@ -111,7 +111,8 @@ async function collectChannelStatus(params: {
}): Promise<ChannelStatusSummary> {
const installedPlugins = listChannelPlugins();
const installedIds = new Set(installedPlugins.map((plugin) => plugin.id));
const catalogEntries = listChannelPluginCatalogEntries().filter(
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter(
(entry) => !installedIds.has(entry.id),
);
const statusEntries = await Promise.all(
@@ -388,7 +389,8 @@ export async function setupChannels(
const core = listChatChannels();
const installed = listChannelPlugins();
const installedIds = new Set(installed.map((plugin) => plugin.id));
const catalog = listChannelPluginCatalogEntries().filter(
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter(
(entry) => !installedIds.has(entry.id),
);
const metaById = new Map<string, ChannelMeta>();

View File

@@ -43,7 +43,17 @@ export type ModelProviderConfig = {
models: ModelDefinitionConfig[];
};
export type BedrockDiscoveryConfig = {
enabled?: boolean;
region?: string;
providerFilter?: string[];
refreshInterval?: number;
defaultContextWindow?: number;
defaultMaxTokens?: number;
};
export type ModelsConfig = {
mode?: "merge" | "replace";
providers?: Record<string, ModelProviderConfig>;
bedrockDiscovery?: BedrockDiscoveryConfig;
};

View File

@@ -59,10 +59,23 @@ export const ModelProviderSchema = z
})
.strict();
export const BedrockDiscoverySchema = z
.object({
enabled: z.boolean().optional(),
region: z.string().optional(),
providerFilter: z.array(z.string()).optional(),
refreshInterval: z.number().int().nonnegative().optional(),
defaultContextWindow: z.number().int().positive().optional(),
defaultMaxTokens: z.number().int().positive().optional(),
})
.strict()
.optional();
export const ModelsConfigSchema = z
.object({
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
providers: z.record(z.string(), ModelProviderSchema).optional(),
bedrockDiscovery: BedrockDiscoverySchema,
})
.strict()
.optional();

View File

@@ -122,11 +122,14 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
});
describe("buildMinimalServicePath", () => {
const splitPath = (value: string, platform: NodeJS.Platform) =>
value.split(platform === "win32" ? path.win32.delimiter : path.posix.delimiter);
it("includes Homebrew + system dirs on macOS", () => {
const result = buildMinimalServicePath({
platform: "darwin",
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "darwin");
expect(parts).toContain("/opt/homebrew/bin");
expect(parts).toContain("/usr/local/bin");
expect(parts).toContain("/usr/bin");
@@ -146,7 +149,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
env: { HOME: "/home/alice" },
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "linux");
// Verify user directories are included
expect(parts).toContain("/home/alice/.local/bin");
@@ -164,7 +167,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
env: {},
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "linux");
// Should only have system directories
expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]);
@@ -178,7 +181,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
env: { HOME: "/home/bob" },
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "linux");
const firstUserDirIdx = parts.indexOf("/home/bob/.local/bin");
const firstSystemDirIdx = parts.indexOf("/usr/local/bin");
@@ -191,7 +194,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
extraDirs: ["/custom/tools"],
});
expect(result.split(path.delimiter)).toContain("/custom/tools");
expect(splitPath(result, "linux")).toContain("/custom/tools");
});
it("deduplicates directories", () => {
@@ -199,7 +202,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
extraDirs: ["/usr/bin"],
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "linux");
const unique = [...new Set(parts)];
expect(parts.length).toBe(unique.length);
});

View File

@@ -121,7 +121,7 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}):
return env.PATH ?? "";
}
return getMinimalServicePathPartsFromEnv({ ...options, env }).join(path.delimiter);
return getMinimalServicePathPartsFromEnv({ ...options, env }).join(path.posix.delimiter);
}
export function buildServiceEnvironment(params: {

View File

@@ -251,6 +251,10 @@ export const sessionsHandlers: GatewayRequestHandlers = {
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
// Reset token counts to 0 on session reset (#1523)
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
store[primaryKey] = nextEntry;
return nextEntry;

View File

@@ -1,8 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js";
import * as tailscale from "./tailscale.js";
const {
ensureGoInstalled,
ensureTailscaledInstalled,
getTailnetHostname,
enableTailscaleServe,
disableTailscaleServe,
ensureFunnel,
} = tailscale;
const tailscaleBin = expect.stringMatching(/tailscale$/);
describe("tailscale helpers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("parses DNS name from tailscale status", async () => {
const exec = vi.fn().mockResolvedValue({
stdout: JSON.stringify({
@@ -48,4 +62,131 @@ describe("tailscale helpers", () => {
await ensureTailscaledInstalled(exec as never, prompt, runtime);
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
});
it("enableTailscaleServe attempts normal first, then sudo", async () => {
// 1. First attempt fails
// 2. Second attempt (sudo) succeeds
vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
const exec = vi
.fn()
.mockRejectedValueOnce(new Error("permission denied"))
.mockResolvedValueOnce({ stdout: "" });
await enableTailscaleServe(3000, exec as never);
expect(exec).toHaveBeenNthCalledWith(
1,
tailscaleBin,
expect.arrayContaining(["serve", "--bg", "--yes", "3000"]),
expect.any(Object),
);
expect(exec).toHaveBeenNthCalledWith(
2,
"sudo",
expect.arrayContaining(["-n", tailscaleBin, "serve", "--bg", "--yes", "3000"]),
expect.any(Object),
);
});
it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => {
vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
const exec = vi.fn().mockResolvedValue({ stdout: "" });
await enableTailscaleServe(3000, exec as never);
expect(exec).toHaveBeenCalledTimes(1);
expect(exec).toHaveBeenCalledWith(
tailscaleBin,
expect.arrayContaining(["serve", "--bg", "--yes", "3000"]),
expect.any(Object),
);
});
it("disableTailscaleServe uses fallback", async () => {
vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
const exec = vi
.fn()
.mockRejectedValueOnce(new Error("permission denied"))
.mockResolvedValueOnce({ stdout: "" });
await disableTailscaleServe(exec as never);
expect(exec).toHaveBeenCalledTimes(2);
expect(exec).toHaveBeenNthCalledWith(
2,
"sudo",
expect.arrayContaining(["-n", tailscaleBin, "serve", "reset"]),
expect.any(Object),
);
});
it("ensureFunnel uses fallback for enabling", async () => {
// Mock exec:
// 1. status (success)
// 2. enable (fails)
// 3. enable sudo (success)
vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
const exec = vi
.fn()
.mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status
.mockRejectedValueOnce(new Error("permission denied")) // enable normal
.mockResolvedValueOnce({ stdout: "" }); // enable sudo
const runtime = {
error: vi.fn(),
log: vi.fn(),
exit: vi.fn() as unknown as (code: number) => never,
};
const prompt = vi.fn();
await ensureFunnel(8080, exec as never, runtime, prompt);
// 1. status
expect(exec).toHaveBeenNthCalledWith(
1,
tailscaleBin,
expect.arrayContaining(["funnel", "status", "--json"]),
);
// 2. enable normal
expect(exec).toHaveBeenNthCalledWith(
2,
tailscaleBin,
expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]),
expect.any(Object),
);
// 3. enable sudo
expect(exec).toHaveBeenNthCalledWith(
3,
"sudo",
expect.arrayContaining(["-n", tailscaleBin, "funnel", "--yes", "--bg", "8080"]),
expect.any(Object),
);
});
it("enableTailscaleServe skips sudo on non-permission errors", async () => {
vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
const exec = vi.fn().mockRejectedValueOnce(new Error("boom"));
await expect(enableTailscaleServe(3000, exec as never)).rejects.toThrow("boom");
expect(exec).toHaveBeenCalledTimes(1);
});
it("enableTailscaleServe rethrows original error if sudo fails", async () => {
vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
const originalError = Object.assign(new Error("permission denied"), {
stderr: "permission denied",
});
const exec = vi
.fn()
.mockRejectedValueOnce(originalError)
.mockRejectedValueOnce(new Error("sudo: a password is required"));
await expect(enableTailscaleServe(3000, exec as never)).rejects.toBe(originalError);
expect(exec).toHaveBeenCalledTimes(2);
});
});

View File

@@ -206,6 +206,64 @@ export async function ensureTailscaledInstalled(
await exec("brew", ["install", "tailscale"]);
}
type ExecErrorDetails = {
stdout?: unknown;
stderr?: unknown;
message?: unknown;
code?: unknown;
};
function extractExecErrorText(err: unknown) {
const errOutput = err as ExecErrorDetails;
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
const message = typeof errOutput.message === "string" ? errOutput.message : "";
const code = typeof errOutput.code === "string" ? errOutput.code : "";
return { stdout, stderr, message, code };
}
function isPermissionDeniedError(err: unknown): boolean {
const { stdout, stderr, message, code } = extractExecErrorText(err);
if (code.toUpperCase() === "EACCES") return true;
const combined = `${stdout}\n${stderr}\n${message}`.toLowerCase();
return (
combined.includes("permission denied") ||
combined.includes("access denied") ||
combined.includes("operation not permitted") ||
combined.includes("not permitted") ||
combined.includes("requires root") ||
combined.includes("must be run as root") ||
combined.includes("must be run with sudo") ||
combined.includes("requires sudo") ||
combined.includes("need sudo")
);
}
// Helper to attempt a command, and retry with sudo if it fails.
async function execWithSudoFallback(
exec: typeof runExec,
bin: string,
args: string[],
opts: { maxBuffer?: number; timeoutMs?: number },
): Promise<{ stdout: string; stderr: string }> {
try {
return await exec(bin, args, opts);
} catch (err) {
if (!isPermissionDeniedError(err)) {
throw err;
}
logVerbose(`Command failed, retrying with sudo: ${bin} ${args.join(" ")}`);
try {
return await exec("sudo", ["-n", bin, ...args], opts);
} catch (sudoErr) {
const { stderr, message } = extractExecErrorText(sudoErr);
const detail = (stderr || message).trim();
if (detail) logVerbose(`Sudo retry failed: ${detail}`);
throw err;
}
}
}
export async function ensureFunnel(
port: number,
exec: typeof runExec = runExec,
@@ -237,10 +295,16 @@ export async function ensureFunnel(
}
logVerbose(`Enabling funnel on port ${port}`);
const { stdout } = await exec(tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});
// Attempt with fallback
const { stdout } = await execWithSudoFallback(
exec,
tailscaleBin,
["funnel", "--yes", "--bg", `${port}`],
{
maxBuffer: 200_000,
timeoutMs: 15_000,
},
);
if (stdout.trim()) console.log(stdout.trim());
} catch (err) {
const errOutput = err as { stdout?: unknown; stderr?: unknown };
@@ -288,7 +352,7 @@ export async function ensureFunnel(
export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], {
await execWithSudoFallback(exec, tailscaleBin, ["serve", "--bg", "--yes", `${port}`], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});
@@ -296,7 +360,7 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec =
export async function disableTailscaleServe(exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
await exec(tailscaleBin, ["serve", "reset"], {
await execWithSudoFallback(exec, tailscaleBin, ["serve", "reset"], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});
@@ -304,7 +368,7 @@ export async function disableTailscaleServe(exec: typeof runExec = runExec) {
export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], {
await execWithSudoFallback(exec, tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});
@@ -312,7 +376,7 @@ export async function enableTailscaleFunnel(port: number, exec: typeof runExec =
export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
await exec(tailscaleBin, ["funnel", "reset"], {
await execWithSudoFallback(exec, tailscaleBin, ["funnel", "reset"], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});

View File

@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./config.js", () => ({
readLoggingConfig: () => undefined,
}));
vi.mock("./logger.js", () => ({
getLogger: () => ({
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
fatal: () => {},
}),
}));
let loadConfigCalls = 0;
vi.mock("node:module", async () => {
const actual = await vi.importActual<typeof import("node:module")>("node:module");
return Object.assign({}, actual, {
createRequire: (url: string | URL) => {
const realRequire = actual.createRequire(url);
return (specifier: string) => {
if (specifier.endsWith("config.js")) {
return {
loadConfig: () => {
loadConfigCalls += 1;
if (loadConfigCalls > 5) {
return {};
}
console.error("config load failed");
return {};
},
};
}
return realRequire(specifier);
};
},
});
});
type ConsoleSnapshot = {
log: typeof console.log;
info: typeof console.info;
warn: typeof console.warn;
error: typeof console.error;
debug: typeof console.debug;
trace: typeof console.trace;
};
let originalIsTty: boolean | undefined;
let snapshot: ConsoleSnapshot;
beforeEach(() => {
loadConfigCalls = 0;
vi.resetModules();
snapshot = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
trace: console.trace,
};
originalIsTty = process.stdout.isTTY;
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
});
afterEach(() => {
console.log = snapshot.log;
console.info = snapshot.info;
console.warn = snapshot.warn;
console.error = snapshot.error;
console.debug = snapshot.debug;
console.trace = snapshot.trace;
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTty, configurable: true });
vi.restoreAllMocks();
});
async function loadLogging() {
const logging = await import("../logging.js");
const state = await import("./state.js");
state.loggingState.cachedConsoleSettings = null;
return { logging, state };
}
describe("getConsoleSettings", () => {
it("does not recurse when loadConfig logs during resolution", async () => {
const { logging } = await loadLogging();
logging.setConsoleTimestampPrefix(true);
logging.enableConsoleCapture();
const { getConsoleSettings } = logging;
getConsoleSettings();
expect(loadConfigCalls).toBe(1);
});
it("skips config fallback during re-entrant resolution", async () => {
const { logging, state } = await loadLogging();
state.loggingState.resolvingConsoleSettings = true;
logging.setConsoleTimestampPrefix(true);
logging.enableConsoleCapture();
logging.getConsoleSettings();
expect(loadConfigCalls).toBe(0);
state.loggingState.resolvingConsoleSettings = false;
});
});

View File

@@ -35,13 +35,20 @@ function resolveConsoleSettings(): ConsoleSettings {
let cfg: ClawdbotConfig["logging"] | undefined =
(loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig();
if (!cfg) {
try {
const loaded = requireConfig("../config/config.js") as {
loadConfig?: () => ClawdbotConfig;
};
cfg = loaded.loadConfig?.().logging;
} catch {
if (loggingState.resolvingConsoleSettings) {
cfg = undefined;
} else {
loggingState.resolvingConsoleSettings = true;
try {
const loaded = requireConfig("../config/config.js") as {
loadConfig?: () => ClawdbotConfig;
};
cfg = loaded.loadConfig?.().logging;
} catch {
cfg = undefined;
} finally {
loggingState.resolvingConsoleSettings = false;
}
}
}
const level = normalizeConsoleLevel(cfg?.consoleLevel);

View File

@@ -7,6 +7,7 @@ export const loggingState = {
forceConsoleToStderr: false,
consoleTimestampPrefix: false,
consoleSubsystemFilter: null as string[] | null,
resolvingConsoleSettings: false,
rawConsole: null as {
log: typeof console.log;
info: typeof console.info;

View File

@@ -4,6 +4,7 @@ import type { Logger as TsLogger } from "tslog";
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { getConsoleSettings, shouldLogSubsystemToConsole } from "./console.js";
import { isVerbose } from "../globals.js";
import { type LogLevel, levelToMinLevel } from "./levels.js";
import { getChildLogger } from "./logger.js";
import { loggingState } from "./state.js";
@@ -220,10 +221,18 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger {
logToFile(getFileLogger(), level, message, fileMeta);
if (!shouldLogToConsole(level, { level: consoleSettings.level })) return;
if (!shouldLogSubsystemToConsole(subsystem)) return;
const consoleMessage = consoleMessageOverride ?? message;
if (
!isVerbose() &&
subsystem === "agent/embedded" &&
/(sessionId|runId)=probe-/.test(consoleMessage)
) {
return;
}
const line = formatConsoleLine({
level,
subsystem,
message: consoleSettings.style === "json" ? message : (consoleMessageOverride ?? message),
message: consoleSettings.style === "json" ? message : consoleMessage,
style: consoleSettings.style,
meta: fileMeta,
});
@@ -241,6 +250,13 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger {
raw: (message) => {
logToFile(getFileLogger(), "info", message, { raw: true });
if (shouldLogSubsystemToConsole(subsystem)) {
if (
!isVerbose() &&
subsystem === "agent/embedded" &&
/(sessionId|runId)=probe-/.test(message)
) {
return;
}
writeConsoleLine("info", message);
}
},

View File

@@ -382,6 +382,65 @@ export async function resizeToPng(params: {
.toBuffer();
}
export async function optimizeImageToPng(
buffer: Buffer,
maxBytes: number,
): Promise<{
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
compressionLevel: number;
}> {
// Try a grid of sizes/compression levels until under the limit.
// PNG uses compression levels 0-9 (higher = smaller but slower).
const sides = [2048, 1536, 1280, 1024, 800];
const compressionLevels = [6, 7, 8, 9];
let smallest: {
buffer: Buffer;
size: number;
resizeSide: number;
compressionLevel: number;
} | null = null;
for (const side of sides) {
for (const compressionLevel of compressionLevels) {
try {
const out = await resizeToPng({
buffer,
maxSide: side,
compressionLevel,
withoutEnlargement: true,
});
const size = out.length;
if (!smallest || size < smallest.size) {
smallest = { buffer: out, size, resizeSide: side, compressionLevel };
}
if (size <= maxBytes) {
return {
buffer: out,
optimizedSize: size,
resizeSide: side,
compressionLevel,
};
}
} catch {
// Continue trying other size/compression combinations.
}
}
}
if (smallest) {
return {
buffer: smallest.buffer,
optimizedSize: smallest.size,
resizeSide: smallest.resizeSide,
compressionLevel: smallest.compressionLevel,
};
}
throw new Error("Failed to optimize PNG image");
}
/**
* Internal sips-only EXIF normalization (no sharp fallback).
* Used by resizeToJpeg to normalize before sips resize.

View File

@@ -85,6 +85,31 @@ describe("renderTable", () => {
}
});
it("resets ANSI styling on wrapped lines", () => {
const reset = "\x1b[0m";
const out = renderTable({
width: 24,
columns: [
{ key: "K", header: "K", minWidth: 3 },
{ key: "V", header: "V", flex: true, minWidth: 10 },
],
rows: [
{
K: "X",
V: `\x1b[31m${"a".repeat(80)}${reset}`,
},
],
});
const lines = out.split("\n").filter((line) => line.includes("a"));
for (const line of lines) {
const resetIndex = line.lastIndexOf(reset);
const lastSep = line.lastIndexOf("│");
expect(resetIndex).toBeGreaterThan(-1);
expect(lastSep).toBeGreaterThan(resetIndex);
}
});
it("respects explicit newlines in cell values", () => {
const out = renderTable({
width: 48,

View File

@@ -91,6 +91,27 @@ function wrapLine(text: string, width: number): string[] {
i += ch.length;
}
const firstCharIndex = tokens.findIndex((t) => t.kind === "char");
if (firstCharIndex < 0) return [text];
let lastCharIndex = -1;
for (let i = tokens.length - 1; i >= 0; i -= 1) {
if (tokens[i]?.kind === "char") {
lastCharIndex = i;
break;
}
}
const prefixAnsi = tokens
.slice(0, firstCharIndex)
.filter((t) => t.kind === "ansi")
.map((t) => t.value)
.join("");
const suffixAnsi = tokens
.slice(lastCharIndex + 1)
.filter((t) => t.kind === "ansi")
.map((t) => t.value)
.join("");
const coreTokens = tokens.slice(firstCharIndex, lastCharIndex + 1);
const lines: string[] = [];
const isBreakChar = (ch: string) =>
ch === " " || ch === "\t" || ch === "/" || ch === "-" || ch === "_" || ch === ".";
@@ -136,7 +157,7 @@ function wrapLine(text: string, width: number): string[] {
lastBreakIndex = null;
};
for (const token of tokens) {
for (const token of coreTokens) {
if (token.kind === "ansi") {
buf.push(token);
continue;
@@ -162,7 +183,12 @@ function wrapLine(text: string, width: number): string[] {
}
flushAt(buf.length);
return lines.length ? lines : [""];
if (!lines.length) return [""];
if (!prefixAnsi && !suffixAnsi) return lines;
return lines.map((line) => {
if (!line) return line;
return `${prefixAnsi}${line}${suffixAnsi}`;
});
}
function normalizeWidth(n: number | undefined): number | undefined {

View File

@@ -408,6 +408,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
case "new":
case "reset":
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
state.sessionInfo.outputTokens = null;
state.sessionInfo.totalTokens = null;
tui.requestRender();
await client.resetSession(state.currentSessionKey);
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
await loadHistory();

View File

@@ -5,10 +5,21 @@ import path from "node:path";
import sharp from "sharp";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadWebMedia, optimizeImageToJpeg, optimizeImageToPng } from "./media.js";
import { optimizeImageToPng } from "../media/image-ops.js";
import { loadWebMedia, optimizeImageToJpeg } from "./media.js";
const tmpFiles: string[] = [];
async function writeTempFile(buffer: Buffer, ext: string): Promise<string> {
const file = path.join(
os.tmpdir(),
`clawdbot-media-${Date.now()}-${Math.random().toString(16).slice(2)}${ext}`,
);
tmpFiles.push(file);
await fs.writeFile(file, buffer);
return file;
}
function buildDeterministicBytes(length: number): Buffer {
const buffer = Buffer.allocUnsafe(length);
let seed = 0x12345678;
@@ -37,9 +48,7 @@ describe("web media loading", () => {
.jpeg({ quality: 95 })
.toBuffer();
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.jpg`);
tmpFiles.push(file);
await fs.writeFile(file, buffer);
const file = await writeTempFile(buffer, ".jpg");
const cap = Math.floor(buffer.length * 0.8);
const result = await loadWebMedia(file, cap);
@@ -55,9 +64,7 @@ describe("web media loading", () => {
})
.png()
.toBuffer();
const wrongExt = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.bin`);
tmpFiles.push(wrongExt);
await fs.writeFile(wrongExt, pngBuffer);
const wrongExt = await writeTempFile(pngBuffer, ".bin");
const result = await loadWebMedia(wrongExt, 1024 * 1024);
@@ -160,9 +167,7 @@ describe("web media loading", () => {
0x3b, // minimal LZW data + trailer
]);
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.gif`);
tmpFiles.push(file);
await fs.writeFile(file, gifBuffer);
const file = await writeTempFile(gifBuffer, ".gif");
const result = await loadWebMedia(file, 1024 * 1024);
@@ -208,9 +213,7 @@ describe("web media loading", () => {
.png()
.toBuffer();
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.png`);
tmpFiles.push(file);
await fs.writeFile(file, buffer);
const file = await writeTempFile(buffer, ".png");
const result = await loadWebMedia(file, 1024 * 1024);
@@ -250,9 +253,7 @@ describe("web media loading", () => {
);
}
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}-alpha.png`);
tmpFiles.push(file);
await fs.writeFile(file, pngBuffer);
const file = await writeTempFile(pngBuffer, ".png");
const result = await loadWebMedia(file, cap);

View File

@@ -9,8 +9,8 @@ import { fetchRemoteMedia } from "../media/fetch.js";
import {
convertHeicToJpeg,
hasAlphaChannel,
optimizeImageToPng,
resizeToJpeg,
resizeToPng,
} from "../media/image-ops.js";
import { detectMime, extensionForMime } from "../media/mime.js";
@@ -28,6 +28,19 @@ type WebMediaOptions = {
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
const HEIC_EXT_RE = /\.(heic|heif)$/i;
const MB = 1024 * 1024;
function formatMb(bytes: number, digits = 2): string {
return (bytes / MB).toFixed(digits);
}
function formatCapLimit(label: string, cap: number, size: number): string {
return `${label} exceeds ${formatMb(cap, 0)}MB limit (got ${formatMb(size)}MB)`;
}
function formatCapReduce(label: string, cap: number, size: number): string {
return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`;
}
function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean {
if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) return true;
@@ -46,6 +59,54 @@ function toJpegFileName(fileName?: string): string | undefined {
return path.format({ dir: parsed.dir, name: parsed.name, ext: ".jpg" });
}
type OptimizedImage = {
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
format: "jpeg" | "png";
quality?: number;
compressionLevel?: number;
};
function logOptimizedImage(params: { originalSize: number; optimized: OptimizedImage }): void {
if (!shouldLogVerbose()) return;
if (params.optimized.optimizedSize >= params.originalSize) return;
if (params.optimized.format === "png") {
logVerbose(
`Optimized PNG (preserving alpha) from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px)`,
);
return;
}
logVerbose(
`Optimized media from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px, q=${params.optimized.quality})`,
);
}
async function optimizeImageWithFallback(params: {
buffer: Buffer;
cap: number;
meta?: { contentType?: string; fileName?: string };
}): Promise<OptimizedImage> {
const { buffer, cap, meta } = params;
const isPng = meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png");
const hasAlpha = isPng && (await hasAlphaChannel(buffer));
if (hasAlpha) {
const optimized = await optimizeImageToPng(buffer, cap);
if (optimized.buffer.length <= cap) {
return { ...optimized, format: "png" };
}
if (shouldLogVerbose()) {
logVerbose(
`PNG with alpha still exceeds ${formatMb(cap, 0)}MB after optimization; falling back to JPEG`,
);
}
}
const optimized = await optimizeImageToJpeg(buffer, cap, meta);
return { ...optimized, format: "jpeg" };
}
async function loadWebMediaInternal(
mediaUrl: string,
options: WebMediaOptions = {},
@@ -66,59 +127,25 @@ async function loadWebMediaInternal(
meta?: { contentType?: string; fileName?: string },
) => {
const originalSize = buffer.length;
const optimized = await optimizeImageWithFallback({ buffer, cap, meta });
logOptimizedImage({ originalSize, optimized });
const optimizeToJpeg = async () => {
const optimized = await optimizeImageToJpeg(buffer, cap, meta);
const fileName = meta && isHeicSource(meta) ? toJpegFileName(meta.fileName) : meta?.fileName;
if (optimized.optimizedSize < originalSize && shouldLogVerbose()) {
logVerbose(
`Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`,
);
}
if (optimized.buffer.length > cap) {
throw new Error(
`Media could not be reduced below ${(cap / (1024 * 1024)).toFixed(0)}MB (got ${(
optimized.buffer.length /
(1024 * 1024)
).toFixed(2)}MB)`,
);
}
return {
buffer: optimized.buffer,
contentType: "image/jpeg",
kind: "image" as const,
fileName,
};
};
// Check if this is a PNG with alpha channel - preserve transparency when possible
const isPng =
meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png");
const hasAlpha = isPng && (await hasAlphaChannel(buffer));
if (hasAlpha) {
const optimized = await optimizeImageToPng(buffer, cap);
if (optimized.buffer.length <= cap) {
if (optimized.optimizedSize < originalSize && shouldLogVerbose()) {
logVerbose(
`Optimized PNG (preserving alpha) from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px)`,
);
}
return {
buffer: optimized.buffer,
contentType: "image/png",
kind: "image" as const,
fileName: meta?.fileName,
};
}
if (shouldLogVerbose()) {
logVerbose(
`PNG with alpha still exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB after optimization; falling back to JPEG`,
);
}
if (optimized.buffer.length > cap) {
throw new Error(formatCapReduce("Media", cap, optimized.buffer.length));
}
return await optimizeToJpeg();
const contentType = optimized.format === "png" ? "image/png" : "image/jpeg";
const fileName =
optimized.format === "jpeg" && meta && isHeicSource(meta)
? toJpegFileName(meta.fileName)
: meta?.fileName;
return {
buffer: optimized.buffer,
contentType,
kind: "image" as const,
fileName,
};
};
const clampAndFinalize = async (params: {
@@ -134,12 +161,7 @@ async function loadWebMediaInternal(
const isGif = params.contentType === "image/gif";
if (isGif || !optimizeImages) {
if (params.buffer.length > cap) {
throw new Error(
`${isGif ? "GIF" : "Media"} exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
params.buffer.length /
(1024 * 1024)
).toFixed(2)}MB)`,
);
throw new Error(formatCapLimit(isGif ? "GIF" : "Media", cap, params.buffer.length));
}
return {
buffer: params.buffer,
@@ -156,12 +178,7 @@ async function loadWebMediaInternal(
};
}
if (params.buffer.length > cap) {
throw new Error(
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
params.buffer.length /
(1024 * 1024)
).toFixed(2)}MB)`,
);
throw new Error(formatCapLimit("Media", cap, params.buffer.length));
}
return {
buffer: params.buffer,
@@ -284,61 +301,4 @@ export async function optimizeImageToJpeg(
throw new Error("Failed to optimize image");
}
export async function optimizeImageToPng(
buffer: Buffer,
maxBytes: number,
): Promise<{
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
compressionLevel: number;
}> {
// Try a grid of sizes/compression levels until under the limit.
// PNG uses compression levels 0-9 (higher = smaller but slower)
const sides = [2048, 1536, 1280, 1024, 800];
const compressionLevels = [6, 7, 8, 9];
let smallest: {
buffer: Buffer;
size: number;
resizeSide: number;
compressionLevel: number;
} | null = null;
for (const side of sides) {
for (const compressionLevel of compressionLevels) {
try {
const out = await resizeToPng({
buffer,
maxSide: side,
compressionLevel,
withoutEnlargement: true,
});
const size = out.length;
if (!smallest || size < smallest.size) {
smallest = { buffer: out, size, resizeSide: side, compressionLevel };
}
if (size <= maxBytes) {
return {
buffer: out,
optimizedSize: size,
resizeSide: side,
compressionLevel,
};
}
} catch {
// Continue trying other size/compression combinations
}
}
}
if (smallest) {
return {
buffer: smallest.buffer,
optimizedSize: smallest.size,
resizeSide: smallest.resizeSide,
compressionLevel: smallest.compressionLevel,
};
}
throw new Error("Failed to optimize PNG image");
}
export { optimizeImageToPng };

Some files were not shown because too many files have changed in this diff Show More