Compare commits
2 Commits
fix/direct
...
fix-messag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf4fc3867 | ||
|
|
fb7159be5e |
102
CHANGELOG.md
102
CHANGELOG.md
@@ -2,25 +2,24 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.22
|
||||
## 2026.1.22 (unreleased)
|
||||
|
||||
### Changes
|
||||
- Highlight: Mattermost plugin channel support with pairing + allowlist gating. (#1428) Thanks @damoahdominic.
|
||||
- Highlight: OpenProse plugin skill pack with `/prose` slash command, plugin-shipped skills, and docs. https://docs.clawd.bot/prose
|
||||
- TUI: run local shell commands with `!` after per-session consent, and warn when local exec stays disabled. (#1463) Thanks @vignesh07.
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
- macOS: add attach-only debug toggle + `--attach-only`/`--no-launchd` flag to skip launchd installs.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
@@ -30,28 +29,18 @@ Docs: https://docs.clawd.bot
|
||||
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
||||
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
||||
- Onboarding: honor `--auth-choice apiKey` token provider + token flags to skip prompts. (#1485) Thanks @iHildy.
|
||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
|
||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
||||
<<<<<<< Updated upstream
|
||||
- Agents: make tool summaries more readable and only show optional params when set.
|
||||
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
||||
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
|
||||
- CLI: prefer `~` for home paths in output.
|
||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||
||||||| Stash base
|
||||
=======
|
||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
||||
>>>>>>> Stashed changes
|
||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
||||
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
|
||||
|
||||
## 2026.1.21-2
|
||||
|
||||
@@ -61,38 +50,63 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
### Highlights
|
||||
- Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
||||
- Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning
|
||||
- Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated
|
||||
- Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams
|
||||
- `/models` UX refresh + `clawdbot update wizard`. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update
|
||||
|
||||
### Changes
|
||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
|
||||
- Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
|
||||
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
|
||||
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
|
||||
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
|
||||
- CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
|
||||
- Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
|
||||
- Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
|
||||
- Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
|
||||
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
|
||||
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
|
||||
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
|
||||
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
|
||||
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).
|
||||
- Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.
|
||||
- Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
- Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.
|
||||
- Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.
|
||||
- Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.
|
||||
- Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.
|
||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||
- Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)
|
||||
- Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
||||
- UI/config: export `SECTION_META` for config form modules. (#1418) Thanks @MaudeBot.
|
||||
- macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.
|
||||
- BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.
|
||||
- Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit `/model` list output. (#1376, #1416)
|
||||
- Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.
|
||||
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
||||
- Cache: restore the 1h cache TTL option and reset the pruning window.
|
||||
- Zalo Personal: tolerate ANSI/log-prefixed JSON output from `zca`. (#1379) Thanks @ptn1411.
|
||||
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
|
||||
- Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.
|
||||
- Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when `gateway.mode` is unset. (#900)
|
||||
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
|
||||
- Logs/Status: align rolling log filenames with local time and report sandboxed runtime in `clawdbot status`. (#1343)
|
||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
||||
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
|
||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||
- Model picker: list the full catalog when no model allowlist is configured.
|
||||
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
||||
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||
- Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
|
||||
37
README.md
37
README.md
@@ -477,29 +477,28 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
|
||||
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/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
|
||||
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/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/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/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/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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/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/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/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a>
|
||||
<a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/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/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/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/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/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/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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
|
||||
<a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/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=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/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/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a>
|
||||
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/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>
|
||||
|
||||
@@ -102,9 +102,7 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
|
||||
"It will only attach to an existing Gateway.")
|
||||
Text("When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). It will only attach to an existing Gateway.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
|
||||
@@ -248,11 +248,12 @@ final class GatewayProcessManager {
|
||||
guard let linkId else {
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
let linked = snap.channels[linkId]?.linked ?? false
|
||||
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
snap.channelLabels?[linkId] ??
|
||||
linkId.capitalized
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"channel"
|
||||
let linkText = linked ? "linked" : "not linked"
|
||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@ Available actions:
|
||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
|
||||
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
|
||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
|
||||
|
||||
### Message IDs (short vs full)
|
||||
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
||||
|
||||
@@ -304,8 +304,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
||||
"policy": "pairing",
|
||||
"allowFrom": ["U123", "U456", "*"],
|
||||
"groupEnabled": false,
|
||||
"groupChannels": ["G123"],
|
||||
"replyToMode": "all"
|
||||
"groupChannels": ["G123"]
|
||||
},
|
||||
"channels": {
|
||||
"C123": { "allow": true, "requireMention": true },
|
||||
@@ -362,73 +361,6 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
|
||||
|
||||
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||
|
||||
### Per-chat-type threading
|
||||
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off", // default for channels
|
||||
replyToModeByChatType: {
|
||||
direct: "all", // DMs always thread
|
||||
group: "first" // group DMs/MPIM thread first reply
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported chat types:
|
||||
- `direct`: 1:1 DMs (Slack `im`)
|
||||
- `group`: group DMs / MPIMs (Slack `mpim`)
|
||||
- `channel`: standard channels (public/private)
|
||||
|
||||
Precedence:
|
||||
1) `replyToModeByChatType.<chatType>`
|
||||
2) `replyToMode`
|
||||
3) Provider default (`off`)
|
||||
|
||||
Legacy `channels.slack.dm.replyToMode` is still accepted as a fallback for `direct` when no chat-type override is set.
|
||||
|
||||
Examples:
|
||||
|
||||
Thread DMs only:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Thread group DMs but keep channels in the root:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { group: "first" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Make channels thread, keep DMs in the root:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
replyToModeByChatType: { direct: "off", group: "off" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual threading tags
|
||||
For fine-grained control, use these tags in agent responses:
|
||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||
|
||||
@@ -295,8 +295,8 @@ Options:
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token` or `apiKey`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token` or `apiKey`)
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
- `--token-expires-in <duration>` (non-interactive; e.g. `365d`, `12h`)
|
||||
- `--anthropic-api-key <key>`
|
||||
|
||||
@@ -141,6 +141,14 @@
|
||||
"source": "/message/",
|
||||
"destination": "/cli/message"
|
||||
},
|
||||
{
|
||||
"source": "/mattermost",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/mattermost/",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/providers/discord",
|
||||
"destination": "/channels/discord"
|
||||
|
||||
@@ -129,7 +129,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
// Optional CLI fallback (Whisper binary):
|
||||
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
],
|
||||
|
||||
@@ -1865,7 +1865,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
||||
- Each `models[]` entry:
|
||||
- Provider entry (`type: "provider"` or omitted):
|
||||
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
|
||||
- `model`: model id override (required for image; defaults to `gpt-4o-mini-transcribe`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
|
||||
- `model`: model id override (required for image; defaults to `whisper-1`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
|
||||
- `profile` / `preferredProfile`: auth profile selection.
|
||||
- CLI entry (`type: "cli"`):
|
||||
- `command`: executable to run.
|
||||
@@ -1890,7 +1890,7 @@ Example:
|
||||
rules: [{ action: "allow", match: { chatType: "direct" } }]
|
||||
},
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ read_when:
|
||||
# Audio / Voice Notes — 2026-01-17
|
||||
|
||||
## What works
|
||||
- **Media understanding (audio)**: If audio understanding is enabled (or auto‑detected), Clawdbot:
|
||||
- **Media understanding (audio)**: If `tools.media.audio` is enabled (or a shared `tools.media.models` entry supports audio), Clawdbot:
|
||||
1) Locates the first audio attachment (local path or URL) and downloads it if needed.
|
||||
2) Enforces `maxBytes` before sending to each model entry.
|
||||
3) Runs the first eligible model entry in order (provider or CLI).
|
||||
@@ -15,21 +15,6 @@ read_when:
|
||||
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
|
||||
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
|
||||
|
||||
## Auto-detection (default)
|
||||
If you **don’t configure models** and `tools.media.audio.enabled` is **not** set to `false`,
|
||||
Clawdbot auto-detects in this order and stops at the first working option:
|
||||
|
||||
1) **Local CLIs** (if installed)
|
||||
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
|
||||
- `whisper-cli` (from `whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
|
||||
- `whisper` (Python CLI; downloads models automatically)
|
||||
2) **Gemini CLI** (`gemini`) using `read_many_files`
|
||||
3) **Provider keys** (OpenAI → Groq → Deepgram → Google)
|
||||
|
||||
To disable auto-detection, set `tools.media.audio.enabled: false`.
|
||||
To customize, set `tools.media.audio.models`.
|
||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
||||
|
||||
## Config examples
|
||||
|
||||
### Provider + CLI fallback (OpenAI + Whisper CLI)
|
||||
@@ -41,7 +26,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
@@ -69,7 +54,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
]
|
||||
},
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" }
|
||||
{ provider: "openai", model: "whisper-1" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -98,7 +83,6 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
||||
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
||||
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
||||
- OpenAI auto default is `gpt-4o-mini-transcribe`; set `model: "gpt-4o-transcribe"` for higher accuracy.
|
||||
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||
- Transcript is available to templates as `{{Transcript}}`.
|
||||
- CLI stdout is capped (5MB); keep CLI output concise.
|
||||
|
||||
@@ -6,7 +6,7 @@ read_when:
|
||||
---
|
||||
# Media Understanding (Inbound) — 2026-01-17
|
||||
|
||||
Clawdbot can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual.
|
||||
Clawdbot can optionally **summarize inbound media** (image/audio/video) before the reply pipeline runs. This is **opt-in** and separate from the base attachment flow—if understanding is off, models still receive the original files/URLs as usual.
|
||||
|
||||
## Goals
|
||||
- Optional: pre‑digest inbound media into short text for faster routing + better command parsing.
|
||||
@@ -88,11 +88,6 @@ Each `models[]` entry can be **provider** or **CLI**:
|
||||
}
|
||||
```
|
||||
|
||||
CLI templates can also use:
|
||||
- `{{MediaDir}}` (directory containing the media file)
|
||||
- `{{OutputDir}}` (scratch dir created for this run)
|
||||
- `{{OutputBase}}` (scratch file base path, no extension)
|
||||
|
||||
## Defaults and limits
|
||||
Recommended defaults:
|
||||
- `maxChars`: **500** for image/video (short, command‑friendly)
|
||||
@@ -109,22 +104,17 @@ Rules:
|
||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||
**active reply model** when its provider supports the capability.
|
||||
|
||||
### Auto-detect media understanding (default)
|
||||
If `tools.media.<capability>.enabled` is **not** set to `false` and you haven’t
|
||||
configured models, Clawdbot auto-detects in this order and **stops at the first
|
||||
working option**:
|
||||
### Auto-enable audio (when keys exist)
|
||||
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
||||
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
||||
even when you haven’t listed models explicitly.
|
||||
|
||||
1) **Local CLIs** (audio only; if installed)
|
||||
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
|
||||
- `whisper-cli` (`whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
|
||||
- `whisper` (Python CLI; downloads models automatically)
|
||||
2) **Gemini CLI** (`gemini`) using `read_many_files`
|
||||
3) **Provider keys**
|
||||
- Audio: OpenAI → Groq → Deepgram → Google
|
||||
- Image: OpenAI → Anthropic → Google → MiniMax
|
||||
- Video: Google
|
||||
Providers checked (in order):
|
||||
1) OpenAI
|
||||
2) Groq
|
||||
3) Deepgram
|
||||
|
||||
To disable auto-detection, set:
|
||||
To disable this behavior, set:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
@@ -136,7 +126,6 @@ To disable auto-detection, set:
|
||||
}
|
||||
}
|
||||
```
|
||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
||||
|
||||
## Capabilities (optional)
|
||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||
@@ -153,7 +142,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
| Capability | Provider integration | Notes |
|
||||
|------------|----------------------|-------|
|
||||
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
||||
| Audio | OpenAI, Groq, Deepgram, Google | Provider transcription (Whisper/Deepgram/Gemini). |
|
||||
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
|
||||
| Video | Google (Gemini API) | Provider video understanding. |
|
||||
|
||||
## Recommended providers
|
||||
@@ -162,8 +151,8 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
||||
|
||||
**Audio**
|
||||
- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`.
|
||||
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper` binary.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
@@ -220,7 +209,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
|
||||
@@ -128,4 +128,4 @@ If your tool allowlist blocks these tools, OpenProse programs will fail. See [Sk
|
||||
|
||||
Treat `.prose` files like code. Review before running. Use Clawdbot tool allowlists and approval gates to control side effects.
|
||||
|
||||
For deterministic, approval-gated workflows, compare with [Lobster](/tools/lobster).
|
||||
For deterministic, approval-gated workflows, compare with [Lobster](/lobster).
|
||||
|
||||
@@ -16,9 +16,9 @@ provider in two different ways.
|
||||
|
||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||
|
||||
Use the native device-login flow to obtain a GitHub token and use it directly
|
||||
against the Copilot API. This is the **default** and simplest path because it
|
||||
does not require VS Code. Enterprise domains are supported.
|
||||
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
||||
because it does not require VS Code.
|
||||
|
||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||
|
||||
@@ -39,8 +39,6 @@ clawdbot models auth login-github-copilot
|
||||
|
||||
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||
open until it completes.
|
||||
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
|
||||
domain (for example `company.ghe.com`).
|
||||
|
||||
### Optional flags
|
||||
|
||||
@@ -68,7 +66,5 @@ clawdbot models set github-copilot/gpt-4o
|
||||
- Requires an interactive TTY; run it directly in a terminal.
|
||||
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||
another ID (for example `github-copilot/gpt-4.1`).
|
||||
- The login stores a GitHub token in the auth profile store and uses it directly
|
||||
for Copilot API calls.
|
||||
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
|
||||
for GitHub Enterprise.
|
||||
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
||||
Copilot API token when Clawdbot runs.
|
||||
|
||||
@@ -155,7 +155,7 @@ tool usage guidance is injected into prompts. Some plugins ship their own skills
|
||||
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).
|
||||
- [Lobster](/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
|
||||
|
||||
## Tool inventory
|
||||
|
||||
|
||||
@@ -11,10 +11,6 @@ read_when:
|
||||
|
||||
Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints.
|
||||
|
||||
## Hook
|
||||
|
||||
Your assistant can build the tools that manage itself. Ask for a workflow, and 30 minutes later you have a CLI plus pipelines that run as one call. Lobster is the missing piece: deterministic pipelines, explicit approvals, and resumable state.
|
||||
|
||||
## Why
|
||||
|
||||
Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime:
|
||||
@@ -28,73 +24,6 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs
|
||||
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
|
||||
If the pipeline pauses for approval, the tool returns a `resumeToken` so you can continue later.
|
||||
|
||||
## Pattern: small CLI + JSON pipes + approvals
|
||||
|
||||
Build tiny commands that speak JSON, then chain them into a single Lobster call. (Example command names below — swap in your own.)
|
||||
|
||||
```bash
|
||||
inbox list --json
|
||||
inbox categorize --json
|
||||
inbox apply --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "run",
|
||||
"pipeline": "exec --json --shell 'inbox list --json' | exec --stdin json --shell 'inbox categorize --json' | exec --stdin json --shell 'inbox apply --json' | approve --preview-from-stdin --limit 5 --prompt 'Apply changes?'",
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
```
|
||||
|
||||
If the pipeline requests approval, resume with the token:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "resume",
|
||||
"token": "<resumeToken>",
|
||||
"approve": true
|
||||
}
|
||||
```
|
||||
|
||||
AI triggers the workflow; Lobster executes the steps. Approval gates keep side effects explicit and auditable.
|
||||
|
||||
Example: map input items into tool calls:
|
||||
|
||||
```bash
|
||||
gog.gmail.search --query 'newer_than:1d' \
|
||||
| clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
```yaml
|
||||
name: inbox-triage
|
||||
args:
|
||||
tag:
|
||||
default: "family"
|
||||
steps:
|
||||
- id: collect
|
||||
command: inbox list --json
|
||||
- id: categorize
|
||||
command: inbox categorize --json
|
||||
stdin: $collect.stdout
|
||||
- id: approve
|
||||
command: inbox apply --approve
|
||||
stdin: $categorize.stdout
|
||||
approval: required
|
||||
- id: execute
|
||||
command: inbox apply --execute
|
||||
stdin: $categorize.stdout
|
||||
condition: $approve.approved
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `stdin: $step.stdout` and `stdin: $step.json` pass a prior step’s output.
|
||||
- `condition` (or `when`) can gate steps on `$step.approved`.
|
||||
|
||||
## Install Lobster
|
||||
|
||||
Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/clawdbot/lobster)), and ensure `lobster` is on `PATH`.
|
||||
@@ -186,16 +115,6 @@ Run a pipeline in tool mode.
|
||||
}
|
||||
```
|
||||
|
||||
Run a workflow file with args:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "run",
|
||||
"pipeline": "/path/to/inbox-triage.lobster",
|
||||
"argsJson": "{\"tag\":\"family\"}"
|
||||
}
|
||||
```
|
||||
|
||||
### `resume`
|
||||
|
||||
Continue a halted workflow after approval.
|
||||
@@ -214,7 +133,6 @@ Continue a halted workflow after approval.
|
||||
- `cwd`: Working directory for the pipeline (defaults to the current process working directory).
|
||||
- `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000).
|
||||
- `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000).
|
||||
- `argsJson`: JSON string passed to `lobster run --args-json` (workflow files only).
|
||||
|
||||
## Output envelope
|
||||
|
||||
@@ -233,8 +151,6 @@ If `requiresApproval` is present, inspect the prompt and decide:
|
||||
- `approve: true` → resume and continue side effects
|
||||
- `approve: false` → cancel and finalize the workflow
|
||||
|
||||
Use `approve --preview-from-stdin --limit N` to attach a JSON preview to approval requests without custom jq/heredoc glue. Resume tokens are now compact: Lobster stores workflow resume state under its state dir and hands back a small token key.
|
||||
|
||||
## OpenProse
|
||||
|
||||
OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, then run a Lobster pipeline for deterministic approvals. If a Prose program needs Lobster, allow the `lobster` tool for sub-agents via `tools.subagents.tools`. See [OpenProse](/prose).
|
||||
@@ -257,10 +173,3 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep,
|
||||
|
||||
- [Plugins](/plugin)
|
||||
- [Plugin tool authoring](/plugins/agent-tools)
|
||||
|
||||
## Case study: community workflows
|
||||
|
||||
One public example: a “second brain” CLI + Lobster pipelines that manage three Markdown vaults (personal, partner, shared). The CLI emits JSON for stats, inbox listings, and stale scans; Lobster chains those commands into workflows like `weekly-review`, `inbox-triage`, `memory-consolidation`, and `shared-task-sync`, each with approval gates. AI handles judgment (categorization) when available and falls back to deterministic rules when not.
|
||||
|
||||
- Thread: https://x.com/plattenschieber/status/2014508656335770033
|
||||
- Repo: https://github.com/bloomedai/brain-cli
|
||||
|
||||
@@ -521,42 +521,6 @@ describe("bluebubblesMessageActions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes asVoice through sendAttachment", async () => {
|
||||
const { sendBlueBubblesAttachment } = await import("./attachments.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const base64Buffer = Buffer.from("voice").toString("base64");
|
||||
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "sendAttachment",
|
||||
params: {
|
||||
to: "+15551234567",
|
||||
filename: "voice.mp3",
|
||||
buffer: base64Buffer,
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: "voice.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when buffer is missing for setGroupIcon", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BLUEBUBBLES_ACTIONS,
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readBooleanParam,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
@@ -50,17 +51,6 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
||||
}
|
||||
|
||||
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const raw = params[key];
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Supported action names for BlueBubbles */
|
||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||
|
||||
@@ -366,7 +356,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
const caption = readStringParam(params, "caption");
|
||||
const contentType =
|
||||
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
||||
const asVoice = readBooleanParam(params, "asVoice");
|
||||
|
||||
// Buffer can come from params.buffer (base64) or params.path (file path)
|
||||
const base64Buffer = readStringParam(params, "buffer");
|
||||
@@ -391,7 +380,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
filename,
|
||||
contentType: contentType ?? undefined,
|
||||
caption: caption ?? undefined,
|
||||
asVoice: asVoice ?? undefined,
|
||||
opts,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
@@ -238,109 +238,3 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
expect(result.buffer).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function decodeBody(body: Uint8Array) {
|
||||
return Buffer.from(body).toString("utf8");
|
||||
}
|
||||
|
||||
it("marks voice memos when asVoice is true and mp3 is provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "voice.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('name="isAudioMessage"');
|
||||
expect(bodyText).toContain("true");
|
||||
expect(bodyText).toContain('filename="voice.mp3"');
|
||||
});
|
||||
|
||||
it("normalizes mp3 filenames for voice memos", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "voice",
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('filename="voice.mp3"');
|
||||
expect(bodyText).toContain('name="voice.mp3"');
|
||||
});
|
||||
|
||||
it("throws when asVoice is true but media is not audio", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "image.png",
|
||||
contentType: "image/png",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
}),
|
||||
).rejects.toThrow("voice messages require audio");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when asVoice is true but audio is not mp3 or caf", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "voice.wav",
|
||||
contentType: "audio/wav",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
}),
|
||||
).rejects.toThrow("require mp3 or caf");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitizes filenames before sending", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "../evil.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('filename="evil.mp3"');
|
||||
expect(bodyText).toContain('name="evil.mp3"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
@@ -20,30 +19,6 @@ export type BlueBubblesAttachmentOpts = {
|
||||
};
|
||||
|
||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
|
||||
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
||||
|
||||
function sanitizeFilename(input: string | undefined, fallback: string): string {
|
||||
const trimmed = input?.trim() ?? "";
|
||||
const base = trimmed ? path.basename(trimmed) : "";
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
||||
const currentExt = path.extname(filename);
|
||||
if (currentExt.toLowerCase() === extension) return filename;
|
||||
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
|
||||
return `${base || fallbackBase}${extension}`;
|
||||
}
|
||||
|
||||
function resolveVoiceInfo(filename: string, contentType?: string) {
|
||||
const normalizedType = contentType?.trim().toLowerCase();
|
||||
const extension = path.extname(filename).toLowerCase();
|
||||
const isMp3 = extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
|
||||
const isCaf = extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
|
||||
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
|
||||
return { isAudio, isMp3, isCaf };
|
||||
}
|
||||
|
||||
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
@@ -129,7 +104,6 @@ function extractMessageId(payload: unknown): string {
|
||||
/**
|
||||
* Send an attachment via BlueBubbles API.
|
||||
* Supports sending media files (images, videos, audio, documents) to a chat.
|
||||
* When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo.
|
||||
*/
|
||||
export async function sendBlueBubblesAttachment(params: {
|
||||
to: string;
|
||||
@@ -139,37 +113,12 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
caption?: string;
|
||||
replyToMessageGuid?: string;
|
||||
replyToPartIndex?: number;
|
||||
asVoice?: boolean;
|
||||
opts?: BlueBubblesAttachmentOpts;
|
||||
}): Promise<SendBlueBubblesAttachmentResult> {
|
||||
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
|
||||
let { buffer, filename, contentType } = params;
|
||||
const wantsVoice = asVoice === true;
|
||||
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
||||
filename = sanitizeFilename(filename, fallbackName);
|
||||
contentType = contentType?.trim() || undefined;
|
||||
const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
|
||||
params;
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
|
||||
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
||||
const isAudioMessage = wantsVoice;
|
||||
if (isAudioMessage) {
|
||||
const voiceInfo = resolveVoiceInfo(filename, contentType);
|
||||
if (!voiceInfo.isAudio) {
|
||||
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
|
||||
}
|
||||
if (voiceInfo.isMp3) {
|
||||
filename = ensureExtension(filename, ".mp3", fallbackName);
|
||||
contentType = contentType ?? "audio/mpeg";
|
||||
} else if (voiceInfo.isCaf) {
|
||||
filename = ensureExtension(filename, ".caf", fallbackName);
|
||||
contentType = contentType ?? "audio/x-caf";
|
||||
} else {
|
||||
throw new Error(
|
||||
"BlueBubbles voice messages require mp3 or caf audio (convert before sending).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const target = resolveSendTarget(to);
|
||||
const chatGuid = await resolveChatGuidForTarget({
|
||||
baseUrl,
|
||||
@@ -221,11 +170,6 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
||||
addField("method", "private-api");
|
||||
|
||||
// Add isAudioMessage flag for voice memos
|
||||
if (isAudioMessage) {
|
||||
addField("isAudioMessage", "true");
|
||||
}
|
||||
|
||||
const trimmedReplyTo = replyToMessageGuid?.trim();
|
||||
if (trimmedReplyTo) {
|
||||
addField("selectedMessageGuid", trimmedReplyTo);
|
||||
|
||||
@@ -59,7 +59,6 @@ export async function sendBlueBubblesMedia(params: {
|
||||
caption?: string;
|
||||
replyToId?: string | null;
|
||||
accountId?: string;
|
||||
asVoice?: boolean;
|
||||
}) {
|
||||
const {
|
||||
cfg,
|
||||
@@ -72,7 +71,6 @@ export async function sendBlueBubblesMedia(params: {
|
||||
caption,
|
||||
replyToId,
|
||||
accountId,
|
||||
asVoice,
|
||||
} = params;
|
||||
const core = getBlueBubblesRuntime();
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
@@ -148,7 +146,6 @@ export async function sendBlueBubblesMedia(params: {
|
||||
filename: resolvedFilename ?? "attachment",
|
||||
contentType: resolvedContentType ?? undefined,
|
||||
replyToMessageGuid,
|
||||
asVoice,
|
||||
opts: {
|
||||
cfg,
|
||||
accountId,
|
||||
|
||||
@@ -159,7 +159,6 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
|
||||
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
|
||||
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
|
||||
pipeline: Type.Optional(Type.String()),
|
||||
argsJson: Type.Optional(Type.String()),
|
||||
token: Type.Optional(Type.String()),
|
||||
approve: Type.Optional(Type.Boolean()),
|
||||
lobsterPath: Type.Optional(Type.String()),
|
||||
@@ -182,12 +181,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
|
||||
if (action === "run") {
|
||||
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
||||
if (!pipeline.trim()) throw new Error("pipeline required");
|
||||
const argv = ["run", "--mode", "tool", pipeline];
|
||||
const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
|
||||
if (argsJson.trim()) {
|
||||
argv.push("--args-json", argsJson);
|
||||
}
|
||||
return argv;
|
||||
return ["run", "--mode", "tool", pipeline];
|
||||
}
|
||||
if (action === "resume") {
|
||||
const token = typeof params.token === "string" ? params.token : "";
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("../credentials.js");
|
||||
} = await import("./credentials.js");
|
||||
|
||||
const cached = loadMatrixCredentials(env);
|
||||
const cachedCredentials =
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MatrixClient } from "matrix-bot-sdk";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
|
||||
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
|
||||
});
|
||||
|
||||
describe("resolveMatrixRoomId", () => {
|
||||
it("uses m.direct when available", async () => {
|
||||
const userId = "@user:example.org";
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockResolvedValue({
|
||||
[userId]: ["!room:example.org"],
|
||||
}),
|
||||
getJoinedRooms: vi.fn(),
|
||||
getJoinedRoomMembers: vi.fn(),
|
||||
setAccountData: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const roomId = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(roomId).toBe("!room:example.org");
|
||||
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to joined rooms and persists m.direct", async () => {
|
||||
const userId = "@fallback:example.org";
|
||||
const roomId = "!room:example.org";
|
||||
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||
"@bot:example.org",
|
||||
userId,
|
||||
]),
|
||||
setAccountData,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
expect(setAccountData).toHaveBeenCalledWith(
|
||||
EventType.Direct,
|
||||
expect.objectContaining({ [userId]: [roomId] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues when a room member lookup fails", async () => {
|
||||
const userId = "@continue:example.org";
|
||||
const roomId = "!good:example.org";
|
||||
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||
const getJoinedRoomMembers = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce(["@bot:example.org", userId]);
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
|
||||
getJoinedRoomMembers,
|
||||
setAccountData,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
expect(setAccountData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows larger rooms when no 1:1 match exists", async () => {
|
||||
const userId = "@group:example.org";
|
||||
const roomId = "!group:example.org";
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||
"@bot:example.org",
|
||||
userId,
|
||||
"@extra:example.org",
|
||||
]),
|
||||
setAccountData: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeThreadId", () => {
|
||||
it("returns null for empty thread ids", () => {
|
||||
expect(normalizeThreadId(" ")).toBeNull();
|
||||
expect(normalizeThreadId("$thread")).toBe("$thread");
|
||||
});
|
||||
});
|
||||
@@ -16,100 +16,22 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
const directRoomCache = new Map<string, string>();
|
||||
|
||||
async function persistDirectRoom(
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
): Promise<void> {
|
||||
let directContent: MatrixDirectAccountData | null = null;
|
||||
try {
|
||||
directContent = (await client.getAccountData(
|
||||
EventType.Direct,
|
||||
)) as MatrixDirectAccountData | null;
|
||||
} catch {
|
||||
// Ignore fetch errors and fall back to an empty map.
|
||||
}
|
||||
const existing =
|
||||
directContent && !Array.isArray(directContent) ? directContent : {};
|
||||
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
||||
if (current[0] === roomId) return;
|
||||
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
||||
try {
|
||||
await client.setAccountData(EventType.Direct, {
|
||||
...existing,
|
||||
[userId]: next,
|
||||
});
|
||||
} catch {
|
||||
// Ignore persistence errors.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectRoomId(
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
|
||||
const trimmed = userId.trim();
|
||||
if (!trimmed.startsWith("@")) {
|
||||
throw new Error(
|
||||
`Matrix user IDs must be fully qualified (got "${trimmed}")`,
|
||||
);
|
||||
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
|
||||
}
|
||||
|
||||
const cached = directRoomCache.get(trimmed);
|
||||
if (cached) return cached;
|
||||
|
||||
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
||||
// matrix-bot-sdk: use getAccountData to retrieve m.direct
|
||||
try {
|
||||
const directContent = (await client.getAccountData(
|
||||
EventType.Direct,
|
||||
)) as MatrixDirectAccountData | null;
|
||||
const list = Array.isArray(directContent?.[trimmed])
|
||||
? directContent[trimmed]
|
||||
: [];
|
||||
if (list.length > 0) {
|
||||
directRoomCache.set(trimmed, list[0]);
|
||||
return list[0];
|
||||
}
|
||||
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
|
||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
||||
if (list.length > 0) return list[0];
|
||||
} catch {
|
||||
// Ignore and fall back.
|
||||
// Ignore errors, try fetching from server
|
||||
}
|
||||
|
||||
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
|
||||
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
|
||||
let fallbackRoom: string | null = null;
|
||||
try {
|
||||
const rooms = await client.getJoinedRooms();
|
||||
for (const roomId of rooms) {
|
||||
let members: string[];
|
||||
try {
|
||||
members = await client.getJoinedRoomMembers(roomId);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!members.includes(trimmed)) continue;
|
||||
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
||||
if (members.length === 2) {
|
||||
directRoomCache.set(trimmed, roomId);
|
||||
await persistDirectRoom(client, trimmed, roomId);
|
||||
return roomId;
|
||||
}
|
||||
if (!fallbackRoom) {
|
||||
fallbackRoom = roomId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back.
|
||||
}
|
||||
|
||||
if (fallbackRoom) {
|
||||
directRoomCache.set(trimmed, fallbackRoom);
|
||||
await persistDirectRoom(client, trimmed, fallbackRoom);
|
||||
return fallbackRoom;
|
||||
}
|
||||
|
||||
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
|
||||
throw new Error(
|
||||
`No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveMatrixRoomId(
|
||||
|
||||
@@ -14,12 +14,11 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key";
|
||||
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
|
||||
const liveEnabled = HAS_OPENAI_KEY && process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const describeLive = liveEnabled ? describe : describe.skip;
|
||||
// Skip if no OpenAI API key
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
|
||||
|
||||
describe("memory plugin e2e", () => {
|
||||
describeWithKey("memory plugin e2e", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
@@ -160,7 +159,7 @@ describe("memory plugin e2e", () => {
|
||||
});
|
||||
|
||||
// Live tests that require OpenAI API key and actually use LanceDB
|
||||
describeLive("memory plugin live tests", () => {
|
||||
describeWithKey("memory plugin live tests", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
@@ -177,7 +176,6 @@ describeLive("memory plugin live tests", () => {
|
||||
|
||||
test("memory tools work end-to-end", async () => {
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
const liveApiKey = process.env.OPENAI_API_KEY ?? "";
|
||||
|
||||
// Mock plugin API
|
||||
const registeredTools: any[] = [];
|
||||
@@ -193,7 +191,7 @@ describeLive("memory plugin live tests", () => {
|
||||
config: {},
|
||||
pluginConfig: {
|
||||
embedding: {
|
||||
apiKey: liveApiKey,
|
||||
apiKey: OPENAI_API_KEY,
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dbPath,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 OpenProse
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
readStringParam,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
resolveSlackGroupRequireMention,
|
||||
buildSlackThreadingToolContext,
|
||||
setAccountEnabledInConfigSection,
|
||||
@@ -163,8 +162,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||
allowTagsWhenOff: true,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ TRASH
|
||||
local needle="$1"
|
||||
local timeout_s="${2:-45}"
|
||||
local needle_compact
|
||||
needle_compact="$(printf "%s" "$needle" | tr -cd "[:alnum:]")"
|
||||
needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
|
||||
local start_s
|
||||
start_s="$(date +%s)"
|
||||
while true; do
|
||||
@@ -61,12 +61,17 @@ TRASH
|
||||
let text = \"\";
|
||||
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
||||
if (text.length > 20000) text = text.slice(-20000);
|
||||
const stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\");
|
||||
const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/g, \"\");
|
||||
const haystack = compact(text);
|
||||
const compactNeedle = compact(needle);
|
||||
if (!compactNeedle) process.exit(1);
|
||||
process.exit(haystack.includes(compactNeedle) ? 0 : 1);
|
||||
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
|
||||
const haystack = sanitize(text);
|
||||
const safeNeedle = sanitize(needle);
|
||||
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
|
||||
let escaped = \"\";
|
||||
for (const ch of safeNeedle) {
|
||||
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
|
||||
}
|
||||
const pattern = escaped.split(\"\").join(\".*\");
|
||||
const re = new RegExp(pattern, \"i\");
|
||||
process.exit(re.test(haystack) ? 0 : 1);
|
||||
"; then
|
||||
return 0
|
||||
fi
|
||||
@@ -255,11 +260,9 @@ TRASH
|
||||
|
||||
send_skills_flow() {
|
||||
# Select skills section and skip optional installs.
|
||||
wait_for_log "Where will the Gateway run?" 60 || true
|
||||
send $'"'"'\r'"'"' 0.6
|
||||
send $'"'"'\r'"'"' 1.2
|
||||
# Configure skills now? -> No
|
||||
wait_for_log "Configure skills now?" 60 || true
|
||||
send $'"'"'n\r'"'"' 0.8
|
||||
send $'"'"'n\r'"'"' 1.5
|
||||
send "" 1.0
|
||||
}
|
||||
|
||||
|
||||
@@ -115,11 +115,7 @@ if (!shouldBuild()) {
|
||||
runNode();
|
||||
} else {
|
||||
logRunner("Building TypeScript (dist is stale).");
|
||||
const pnpmArgs = ["exec", compiler, ...projectArgs];
|
||||
const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm";
|
||||
const buildArgs =
|
||||
process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs;
|
||||
const build = spawn(buildCmd, buildArgs, {
|
||||
const build = spawn("pnpm", ["exec", compiler, ...projectArgs], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
resolveApiKeyForProfile,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
getOAuthApiKey: vi.fn(() => {
|
||||
throw new Error("refresh should not be called");
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("auth-profiles (github-copilot)", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
});
|
||||
|
||||
it("treats copilot oauth tokens with expires=0 as non-expiring", async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-copilot-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
|
||||
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:github": {
|
||||
type: "oauth",
|
||||
provider: "github-copilot",
|
||||
refresh: "gh-token",
|
||||
access: "gh-token",
|
||||
expires: 0,
|
||||
enterpriseUrl: "company.ghe.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
|
||||
|
||||
const loaded = ensureAuthProfileStore();
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
store: loaded,
|
||||
profileId: "github-copilot:github",
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("gh-token");
|
||||
});
|
||||
});
|
||||
@@ -39,15 +39,6 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
const cred = store.profiles[params.profileId];
|
||||
if (!cred || cred.type !== "oauth") return null;
|
||||
|
||||
if (
|
||||
cred.provider === "github-copilot" &&
|
||||
(!Number.isFinite(cred.expires) || cred.expires <= 0)
|
||||
) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
newCredentials: cred,
|
||||
};
|
||||
}
|
||||
if (Date.now() < cred.expires) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
@@ -112,20 +103,6 @@ async function tryResolveOAuthProfile(params: {
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||
|
||||
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
provider: cred.provider,
|
||||
email: cred.email,
|
||||
};
|
||||
}
|
||||
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
provider: cred.provider,
|
||||
email: cred.email,
|
||||
};
|
||||
}
|
||||
if (Date.now() < cred.expires) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
|
||||
@@ -19,7 +19,6 @@ export type TokenCredential = {
|
||||
token: string;
|
||||
/** Optional expiry timestamp (ms since epoch). */
|
||||
expires?: number;
|
||||
enterpriseUrl?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("exec falls back when PTY spawn fails", async () => {
|
||||
vi.doMock("@lydell/node-pty", () => ({
|
||||
spawn: () => {
|
||||
const err = new Error("spawn EBADF");
|
||||
(err as NodeJS.ErrnoException).code = "EBADF";
|
||||
throw err;
|
||||
},
|
||||
}));
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec");
|
||||
const tool = createExecTool({ allowBackground: false });
|
||||
const result = await tool.execute("toolcall", {
|
||||
command: "printf ok",
|
||||
pty: true,
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
const text = result.content?.[0]?.text ?? "";
|
||||
expect(text).toContain("ok");
|
||||
expect(text).toContain("PTY spawn failed");
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
resolveShellEnvFallbackTimeoutMs,
|
||||
} from "../infra/shell-env.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { logInfo, logWarn } from "../logger.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import {
|
||||
type ProcessSession,
|
||||
type SessionStdin,
|
||||
@@ -381,56 +381,41 @@ async function runExecProcess(opts: {
|
||||
) as ChildProcessWithoutNullStreams;
|
||||
stdin = child.stdin;
|
||||
} else if (opts.usePty) {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
try {
|
||||
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
|
||||
spawn?: PtySpawn;
|
||||
default?: { spawn?: PtySpawn };
|
||||
};
|
||||
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
|
||||
if (!spawnPty) {
|
||||
throw new Error("PTY support is unavailable (node-pty spawn not found).");
|
||||
}
|
||||
pty = spawnPty(shell, [...shellArgs, opts.command], {
|
||||
cwd: opts.workdir,
|
||||
env: opts.env,
|
||||
name: process.env.TERM ?? "xterm-256color",
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
stdin = {
|
||||
destroyed: false,
|
||||
write: (data, cb) => {
|
||||
try {
|
||||
pty?.write(data);
|
||||
cb?.(null);
|
||||
} catch (err) {
|
||||
cb?.(err as Error);
|
||||
}
|
||||
},
|
||||
end: () => {
|
||||
try {
|
||||
const eof = process.platform === "win32" ? "\x1a" : "\x04";
|
||||
pty?.write(eof);
|
||||
} catch {
|
||||
// ignore EOF errors
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const errText = String(err);
|
||||
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
|
||||
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
|
||||
opts.warnings.push(warning);
|
||||
child = spawn(shell, [...shellArgs, opts.command], {
|
||||
cwd: opts.workdir,
|
||||
env: opts.env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
stdin = child.stdin;
|
||||
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
|
||||
spawn?: PtySpawn;
|
||||
default?: { spawn?: PtySpawn };
|
||||
};
|
||||
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
|
||||
if (!spawnPty) {
|
||||
throw new Error("PTY support is unavailable (node-pty spawn not found).");
|
||||
}
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
pty = spawnPty(shell, [...shellArgs, opts.command], {
|
||||
cwd: opts.workdir,
|
||||
env: opts.env,
|
||||
name: process.env.TERM ?? "xterm-256color",
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
stdin = {
|
||||
destroyed: false,
|
||||
write: (data, cb) => {
|
||||
try {
|
||||
pty?.write(data);
|
||||
cb?.(null);
|
||||
} catch (err) {
|
||||
cb?.(err as Error);
|
||||
}
|
||||
},
|
||||
end: () => {
|
||||
try {
|
||||
const eof = process.platform === "win32" ? "\x1a" : "\x04";
|
||||
pty?.write(eof);
|
||||
} catch {
|
||||
// ignore EOF errors
|
||||
}
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
child = spawn(shell, [...shellArgs, opts.command], {
|
||||
@@ -1335,7 +1320,7 @@ export function createExecTool(
|
||||
|
||||
const effectiveTimeout =
|
||||
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
|
||||
const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : "");
|
||||
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
|
||||
const usePty = params.pty === true && !sandbox;
|
||||
const run = await runExecProcess({
|
||||
command: params.command,
|
||||
@@ -1375,7 +1360,7 @@ export function createExecTool(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${getWarningText()}` +
|
||||
`${warningText}` +
|
||||
`Command still running (session ${run.session.id}, pid ${
|
||||
run.session.pid ?? "n/a"
|
||||
}). ` +
|
||||
@@ -1425,7 +1410,7 @@ export function createExecTool(
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${getWarningText()}${outcome.aggregated || "(no output)"}`,
|
||||
text: `${warningText}${outcome.aggregated || "(no output)"}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => configOverride,
|
||||
resolveGatewayPort: () => 18789,
|
||||
};
|
||||
});
|
||||
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
describe("clawdbot-tools: subagents", () => {
|
||||
beforeEach(() => {
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("sessions_spawn runs cleanup via lifecycle events", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let deletedKey: string | undefined;
|
||||
let childRunId: string | undefined;
|
||||
let childSessionKey: string | undefined;
|
||||
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
calls.push(request);
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as {
|
||||
message?: string;
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
timeout?: number;
|
||||
lane?: string;
|
||||
};
|
||||
// Only capture the first agent call (subagent spawn, not main agent trigger)
|
||||
if (params?.lane === "subagent") {
|
||||
childRunId = runId;
|
||||
childSessionKey = params?.sessionKey ?? "";
|
||||
expect(params?.channel).toBe("discord");
|
||||
expect(params?.timeout).toBe(1);
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: 1000 + agentCallCount,
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
waitCalls.push(params ?? {});
|
||||
// Return "ok" with timing info for the child run
|
||||
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||
}
|
||||
if (request.method === "sessions.delete") {
|
||||
const params = request.params as { key?: string } | undefined;
|
||||
deletedKey = params?.key;
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
task: "do thing",
|
||||
runTimeoutSeconds: 1,
|
||||
cleanup: "delete",
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
status: "accepted",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
if (!childRunId) throw new Error("missing child runId");
|
||||
emitAgentEvent({
|
||||
runId: childRunId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt: 1234,
|
||||
endedAt: 2345,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
||||
expect(childWait?.timeoutMs).toBe(1000);
|
||||
|
||||
// Two agent calls: subagent spawn + main agent trigger
|
||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||
expect(agentCalls).toHaveLength(2);
|
||||
|
||||
// First call: subagent spawn
|
||||
const first = agentCalls[0]?.params as
|
||||
| {
|
||||
lane?: string;
|
||||
deliver?: boolean;
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(first?.lane).toBe("subagent");
|
||||
expect(first?.deliver).toBe(false);
|
||||
expect(first?.channel).toBe("discord");
|
||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
|
||||
// Second call: main agent trigger with announce message
|
||||
const second = agentCalls[1]?.params as
|
||||
| {
|
||||
sessionKey?: string;
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
expect(second?.sessionKey).toBe("discord:group:req");
|
||||
expect(second?.deliver).toBe(true);
|
||||
expect(second?.message).toContain("background task");
|
||||
|
||||
// No direct send to external channel (main agent handles delivery)
|
||||
const sendCalls = calls.filter((c) => c.method === "send");
|
||||
expect(sendCalls.length).toBe(0);
|
||||
|
||||
// Session should be deleted since cleanup=delete
|
||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
|
||||
it("sessions_spawn announces with requester accountId", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let childRunId: string | undefined;
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
calls.push(request);
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
|
||||
if (params?.lane === "subagent") {
|
||||
childRunId = runId;
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: 4000 + agentCallCount,
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||
}
|
||||
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
agentAccountId: "kev",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||
|
||||
const result = await tool.execute("call2", {
|
||||
task: "do thing",
|
||||
runTimeoutSeconds: 1,
|
||||
cleanup: "keep",
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
status: "accepted",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
if (!childRunId) throw new Error("missing child runId");
|
||||
emitAgentEvent({
|
||||
runId: childRunId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||
expect(agentCalls).toHaveLength(2);
|
||||
const announceParams = agentCalls[1]?.params as
|
||||
| { accountId?: string; channel?: string; deliver?: boolean }
|
||||
| undefined;
|
||||
expect(announceParams?.deliver).toBe(true);
|
||||
expect(announceParams?.channel).toBe("whatsapp");
|
||||
expect(announceParams?.accountId).toBe("kev");
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
@@ -121,195 +120,4 @@ describe("clawdbot-tools: subagents", () => {
|
||||
});
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sessions_spawn runs cleanup via lifecycle events", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let deletedKey: string | undefined;
|
||||
let childRunId: string | undefined;
|
||||
let childSessionKey: string | undefined;
|
||||
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
calls.push(request);
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as {
|
||||
message?: string;
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
timeout?: number;
|
||||
lane?: string;
|
||||
};
|
||||
if (params?.lane === "subagent") {
|
||||
childRunId = runId;
|
||||
childSessionKey = params?.sessionKey ?? "";
|
||||
expect(params?.channel).toBe("discord");
|
||||
expect(params?.timeout).toBe(1);
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: 1000 + agentCallCount,
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
waitCalls.push(params ?? {});
|
||||
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||
}
|
||||
if (request.method === "sessions.delete") {
|
||||
const params = request.params as { key?: string } | undefined;
|
||||
deletedKey = params?.key;
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
task: "do thing",
|
||||
runTimeoutSeconds: 1,
|
||||
cleanup: "delete",
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
status: "accepted",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
if (!childRunId) throw new Error("missing child runId");
|
||||
emitAgentEvent({
|
||||
runId: childRunId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt: 1234,
|
||||
endedAt: 2345,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
||||
expect(childWait?.timeoutMs).toBe(1000);
|
||||
|
||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||
expect(agentCalls).toHaveLength(2);
|
||||
|
||||
const first = agentCalls[0]?.params as
|
||||
| {
|
||||
lane?: string;
|
||||
deliver?: boolean;
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(first?.lane).toBe("subagent");
|
||||
expect(first?.deliver).toBe(false);
|
||||
expect(first?.channel).toBe("discord");
|
||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
|
||||
const second = agentCalls[1]?.params as
|
||||
| {
|
||||
sessionKey?: string;
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
expect(second?.sessionKey).toBe("discord:group:req");
|
||||
expect(second?.deliver).toBe(true);
|
||||
expect(second?.message).toContain("background task");
|
||||
|
||||
const sendCalls = calls.filter((c) => c.method === "send");
|
||||
expect(sendCalls.length).toBe(0);
|
||||
|
||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
|
||||
it("sessions_spawn announces with requester accountId", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let childRunId: string | undefined;
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
calls.push(request);
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
|
||||
if (params?.lane === "subagent") {
|
||||
childRunId = runId;
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: 4000 + agentCallCount,
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||
}
|
||||
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
agentAccountId: "kev",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||
|
||||
const result = await tool.execute("call2", {
|
||||
task: "do thing",
|
||||
runTimeoutSeconds: 1,
|
||||
cleanup: "keep",
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
status: "accepted",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
if (!childRunId) throw new Error("missing child runId");
|
||||
emitAgentEvent({
|
||||
runId: childRunId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||
expect(agentCalls).toHaveLength(2);
|
||||
const announceParams = agentCalls[1]?.params as
|
||||
| { accountId?: string; channel?: string; deliver?: boolean }
|
||||
| undefined;
|
||||
expect(announceParams?.deliver).toBe(true);
|
||||
expect(announceParams?.channel).toBe("whatsapp");
|
||||
expect(announceParams?.accountId).toBe("kev");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||
@@ -52,6 +51,16 @@ describe("models-config", () => {
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
|
||||
const agentDir = path.join(home, "agent-default-base-url");
|
||||
@@ -62,55 +71,48 @@ describe("models-config", () => {
|
||||
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
||||
} finally {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
it("uses enterprise URL from auth profiles to derive base URL", async () => {
|
||||
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
|
||||
await withTempHome(async () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const previousGh = process.env.GH_TOKEN;
|
||||
const previousGithub = process.env.GITHUB_TOKEN;
|
||||
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
|
||||
process.env.GH_TOKEN = "gh-token";
|
||||
process.env.GITHUB_TOKEN = "github-token";
|
||||
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:github": {
|
||||
type: "oauth",
|
||||
provider: "github-copilot",
|
||||
refresh: "gh-token",
|
||||
access: "gh-token",
|
||||
expires: 0,
|
||||
enterpriseUrl: "company.ghe.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
});
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } });
|
||||
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||
"https://copilot-api.company.ghe.com",
|
||||
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubToken: "copilot-token" }),
|
||||
);
|
||||
} finally {
|
||||
// no-op
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
process.env.GH_TOKEN = previousGh;
|
||||
process.env.GITHUB_TOKEN = previousGithub;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||
@@ -44,7 +43,7 @@ describe("models-config", () => {
|
||||
process.env.HOME = previousHome;
|
||||
});
|
||||
|
||||
it("uses default baseUrl when env token is present", async () => {
|
||||
it("falls back to default baseUrl when token exchange fails", async () => {
|
||||
await withTempHome(async () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||
@@ -52,6 +51,11 @@ describe("models-config", () => {
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
|
||||
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
@@ -63,13 +67,13 @@ describe("models-config", () => {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
|
||||
} finally {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
it("normalizes enterprise URL when deriving base URL", async () => {
|
||||
it("uses agentDir override auth profiles for copilot injection", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const previousGh = process.env.GH_TOKEN;
|
||||
@@ -90,12 +94,9 @@ describe("models-config", () => {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:github": {
|
||||
type: "oauth",
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
refresh: "gh-profile-token",
|
||||
access: "gh-profile-token",
|
||||
expires: 0,
|
||||
enterpriseUrl: "https://company.ghe.com/",
|
||||
token: "gh-profile-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -104,6 +105,16 @@ describe("models-config", () => {
|
||||
),
|
||||
);
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
@@ -113,9 +124,7 @@ describe("models-config", () => {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||
"https://copilot-api.company.ghe.com",
|
||||
);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
normalizeGithubCopilotDomain,
|
||||
resolveGithubCopilotBaseUrl,
|
||||
} from "../providers/github-copilot-utils.js";
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "../providers/github-copilot-token.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||
import {
|
||||
@@ -331,18 +331,29 @@ export async function resolveImplicitCopilotProvider(params: {
|
||||
|
||||
if (!hasProfile && !githubToken) return null;
|
||||
|
||||
let enterpriseDomain: string | null = null;
|
||||
if (hasProfile) {
|
||||
let selectedGithubToken = githubToken;
|
||||
if (!selectedGithubToken && hasProfile) {
|
||||
// Use the first available profile as a default for discovery (it will be
|
||||
// re-resolved per-run by the embedded runner).
|
||||
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
||||
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
||||
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") {
|
||||
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl);
|
||||
if (profile && profile.type === "token") {
|
||||
selectedGithubToken = profile.token;
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain);
|
||||
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
if (selectedGithubToken) {
|
||||
try {
|
||||
const token = await resolveCopilotApiToken({
|
||||
githubToken: selectedGithubToken,
|
||||
env,
|
||||
});
|
||||
baseUrl = token.baseUrl;
|
||||
} catch {
|
||||
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
||||
@@ -353,7 +364,7 @@ export async function resolveImplicitCopilotProvider(params: {
|
||||
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||
// patterns for OAuth-like providers in pi-coding-agent.
|
||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
||||
// Clawdbot uses its own auth store and passes the GitHub token at runtime.
|
||||
// Clawdbot uses its own auth store and exchanges tokens at runtime.
|
||||
// `models list` uses Clawdbot's auth heuristics for availability.
|
||||
|
||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||
@@ -81,16 +80,25 @@ describe("models-config", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
});
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
||||
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubToken: "alpha-token" }),
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
@@ -109,6 +117,16 @@ describe("models-config", () => {
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
|
||||
@@ -87,25 +86,10 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
||||
});
|
||||
|
||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
||||
let tempRoot: string | undefined;
|
||||
let agentDir: string;
|
||||
let workspaceDir: string;
|
||||
let sessionCounter = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-embedded-agent-"));
|
||||
agentDir = path.join(tempRoot, "agent");
|
||||
workspaceDir = path.join(tempRoot, "workspace");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!tempRoot) return;
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
tempRoot = undefined;
|
||||
});
|
||||
|
||||
const makeOpenAiConfig = (modelIds: string[]) =>
|
||||
@@ -130,14 +114,10 @@ const makeOpenAiConfig = (modelIds: string[]) =>
|
||||
},
|
||||
}) satisfies ClawdbotConfig;
|
||||
|
||||
const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir);
|
||||
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
|
||||
ensureClawdbotModelsJson(cfg, agentDir);
|
||||
|
||||
const nextSessionFile = () => {
|
||||
sessionCounter += 1;
|
||||
return path.join(workspaceDir, `session-${sessionCounter}.jsonl`);
|
||||
};
|
||||
|
||||
const testSessionKey = "agent:test:embedded";
|
||||
const testSessionKey = "agent:test:embedded-ordering";
|
||||
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
||||
|
||||
const textFromContent = (content: unknown) => {
|
||||
@@ -165,114 +145,15 @@ const readSessionMessages = async (sessionFile: string) => {
|
||||
};
|
||||
|
||||
describe("runEmbeddedPiAgent", () => {
|
||||
it("writes models.json into the provided agentDir", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
apiKey: "sk-minimax-test",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: "hi",
|
||||
provider: "definitely-not-a-provider",
|
||||
model: "definitely-not-a-model",
|
||||
timeoutMs: 1,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
}),
|
||||
).rejects.toThrow(/Unknown model:/);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
it("persists the user message when prompt fails before assistant output", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const cfg = makeOpenAiConfig(["mock-error"]);
|
||||
await ensureModels(cfg);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: "boom",
|
||||
provider: "openai",
|
||||
model: "mock-error",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
expect(result.payloads[0]?.isError).toBe(true);
|
||||
|
||||
const messages = await readSessionMessages(sessionFile);
|
||||
const userIndex = messages.findIndex(
|
||||
(message) => message?.role === "user" && textFromContent(message.content) === "boom",
|
||||
);
|
||||
expect(userIndex).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it(
|
||||
"appends new user + assistant after existing transcript entries",
|
||||
{ timeout: 90_000 },
|
||||
async () => {
|
||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||
const sessionFile = nextSessionFile();
|
||||
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
@@ -304,7 +185,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
});
|
||||
|
||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||
await ensureModels(cfg);
|
||||
await ensureModels(cfg, agentDir);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
@@ -340,11 +221,13 @@ describe("runEmbeddedPiAgent", () => {
|
||||
expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
|
||||
},
|
||||
);
|
||||
|
||||
it("persists multi-turn user/assistant ordering across runs", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||
|
||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||
await ensureModels(cfg);
|
||||
await ensureModels(cfg, agentDir);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
@@ -382,33 +265,58 @@ describe("runEmbeddedPiAgent", () => {
|
||||
(message, index) => index > firstUserIndex && message?.role === "assistant",
|
||||
);
|
||||
const secondUserIndex = messages.findIndex(
|
||||
(message, index) =>
|
||||
index > firstAssistantIndex &&
|
||||
message?.role === "user" &&
|
||||
textFromContent(message.content) === "second",
|
||||
(message) => message?.role === "user" && textFromContent(message.content) === "second",
|
||||
);
|
||||
const secondAssistantIndex = messages.findIndex(
|
||||
(message, index) => index > secondUserIndex && message?.role === "assistant",
|
||||
);
|
||||
|
||||
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex);
|
||||
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
||||
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
||||
});
|
||||
|
||||
}, 90_000);
|
||||
it("repairs orphaned user messages and continues", async () => {
|
||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||
const sessionFile = nextSessionFile();
|
||||
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "orphaned user" }],
|
||||
content: [{ type: "text", text: "seed user 1" }],
|
||||
});
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "seed assistant" }],
|
||||
stopReason: "stop",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "seed user 2" }],
|
||||
});
|
||||
|
||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||
await ensureModels(cfg);
|
||||
await ensureModels(cfg, agentDir);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
@@ -430,16 +338,19 @@ describe("runEmbeddedPiAgent", () => {
|
||||
|
||||
it("repairs orphaned single-user sessions and continues", async () => {
|
||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||
const sessionFile = nextSessionFile();
|
||||
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "solo user" }],
|
||||
content: [{ type: "text", text: "seed user only" }],
|
||||
});
|
||||
|
||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||
await ensureModels(cfg);
|
||||
await ensureModels(cfg, agentDir);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
@@ -210,74 +210,6 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("honors user-pinned profiles even when in cooldown", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
try {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const payload = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
||||
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
||||
},
|
||||
usageStats: {
|
||||
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
|
||||
"openai:p2": { lastUsed: 2 },
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authPath, JSON.stringify(payload));
|
||||
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildAssistant({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:user-cooldown",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileId: "openai:p1",
|
||||
authProfileIdSource: "user",
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:user-cooldown",
|
||||
});
|
||||
|
||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const stored = JSON.parse(
|
||||
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
|
||||
) as {
|
||||
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
|
||||
};
|
||||
expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBeUndefined();
|
||||
expect(stored.usageStats?.["openai:p1"]?.lastUsed).not.toBe(1);
|
||||
expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores user-locked profile when provider mismatches", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
@@ -316,149 +248,4 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips profiles in cooldown during initial selection", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
try {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const payload = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
||||
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
||||
},
|
||||
usageStats: {
|
||||
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, // p1 in cooldown for 1 hour
|
||||
"openai:p2": { lastUsed: 2 },
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authPath, JSON.stringify(payload));
|
||||
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildAssistant({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:skip-cooldown",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileId: undefined,
|
||||
authProfileIdSource: "auto",
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:skip-cooldown",
|
||||
});
|
||||
|
||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const stored = JSON.parse(
|
||||
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
|
||||
) as { usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }> };
|
||||
expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
|
||||
expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number");
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips profiles in cooldown when rotating after failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
try {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const payload = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
||||
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
||||
"openai:p3": { type: "api_key", provider: "openai", key: "sk-three" },
|
||||
},
|
||||
usageStats: {
|
||||
"openai:p1": { lastUsed: 1 },
|
||||
"openai:p2": { cooldownUntil: now + 60 * 60 * 1000 }, // p2 in cooldown
|
||||
"openai:p3": { lastUsed: 3 },
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authPath, JSON.stringify(payload));
|
||||
|
||||
runEmbeddedAttemptMock
|
||||
.mockResolvedValueOnce(
|
||||
makeAttempt({
|
||||
assistantTexts: [],
|
||||
lastAssistant: buildAssistant({
|
||||
stopReason: "error",
|
||||
errorMessage: "rate limit",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildAssistant({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:rotate-skip-cooldown",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileId: "openai:p1",
|
||||
authProfileIdSource: "auto",
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:rotate-skip-cooldown",
|
||||
});
|
||||
|
||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const stored = JSON.parse(
|
||||
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
|
||||
) as {
|
||||
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
|
||||
};
|
||||
expect(typeof stored.usageStats?.["openai:p1"]?.lastUsed).toBe("number");
|
||||
expect(typeof stored.usageStats?.["openai:p3"]?.lastUsed).toBe("number");
|
||||
expect(stored.usageStats?.["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
|
||||
const buildAssistantMessage = (model: { api: string; provider: string; id: string }) => ({
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text: "ok" }],
|
||||
stopReason: "stop" as const,
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const buildAssistantErrorMessage = (model: { api: string; provider: string; id: string }) => ({
|
||||
role: "assistant" as const,
|
||||
content: [] as const,
|
||||
stopReason: "error" as const,
|
||||
errorMessage: "boom",
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const mockPiAi = () => {
|
||||
vi.doMock("@mariozechner/pi-ai", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
||||
return {
|
||||
...actual,
|
||||
complete: async (model: { api: string; provider: string; id: string }) => {
|
||||
if (model.id === "mock-error") return buildAssistantErrorMessage(model);
|
||||
return buildAssistantMessage(model);
|
||||
},
|
||||
completeSimple: async (model: { api: string; provider: string; id: string }) => {
|
||||
if (model.id === "mock-error") return buildAssistantErrorMessage(model);
|
||||
return buildAssistantMessage(model);
|
||||
},
|
||||
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
||||
const stream = new actual.AssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message:
|
||||
model.id === "mock-error"
|
||||
? buildAssistantErrorMessage(model)
|
||||
: buildAssistantMessage(model),
|
||||
});
|
||||
stream.end();
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useRealTimers();
|
||||
mockPiAi();
|
||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||
}, 20_000);
|
||||
|
||||
const makeOpenAiConfig = (modelIds: string[]) =>
|
||||
({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-responses",
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://example.com",
|
||||
models: modelIds.map((id) => ({
|
||||
id,
|
||||
name: `Mock ${id}`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 16_000,
|
||||
maxTokens: 2048,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}) satisfies ClawdbotConfig;
|
||||
|
||||
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
|
||||
ensureClawdbotModelsJson(cfg, agentDir);
|
||||
|
||||
const testSessionKey = "agent:test:embedded-models";
|
||||
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
||||
|
||||
const textFromContent = (content: unknown) => {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content) && content[0]?.type === "text") {
|
||||
return (content[0] as { text?: string }).text;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const readSessionMessages = async (sessionFile: string) => {
|
||||
const raw = await fs.readFile(sessionFile, "utf-8");
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as {
|
||||
type?: string;
|
||||
message?: { role?: string; content?: unknown };
|
||||
},
|
||||
)
|
||||
.filter((entry) => entry.type === "message")
|
||||
.map((entry) => entry.message as { role?: string; content?: unknown });
|
||||
};
|
||||
|
||||
describe("runEmbeddedPiAgent", () => {
|
||||
it("writes models.json into the provided agentDir", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
apiKey: "sk-minimax-test",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: "hi",
|
||||
provider: "definitely-not-a-provider",
|
||||
model: "definitely-not-a-model",
|
||||
timeoutMs: 1,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
}),
|
||||
).rejects.toThrow(/Unknown model:/);
|
||||
|
||||
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 agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||
|
||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||
await ensureModels(cfg, agentDir);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
it("persists the user message when prompt fails before assistant output", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||
|
||||
const cfg = makeOpenAiConfig(["mock-error"]);
|
||||
await ensureModels(cfg, agentDir);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: "boom",
|
||||
provider: "openai",
|
||||
model: "mock-error",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
expect(result.payloads[0]?.isError).toBe(true);
|
||||
|
||||
const messages = await readSessionMessages(sessionFile);
|
||||
const userIndex = messages.findIndex(
|
||||
(message) => message?.role === "user" && textFromContent(message.content) === "boom",
|
||||
);
|
||||
expect(userIndex).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,13 @@ export async function compactEmbeddedPiSession(params: {
|
||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
||||
);
|
||||
}
|
||||
} else if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } =
|
||||
await import("../../providers/github-copilot-token.js");
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||
} else {
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
@@ -186,28 +184,10 @@ export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider:
|
||||
}
|
||||
}
|
||||
|
||||
// Event emitter for unhandled compaction failures that escape try-catch blocks.
|
||||
// Listeners can use this to trigger session recovery with retry.
|
||||
const compactionFailureEmitter = new EventEmitter();
|
||||
|
||||
export type CompactionFailureListener = (reason: string) => void;
|
||||
|
||||
/**
|
||||
* Register a listener for unhandled compaction failures.
|
||||
* Called when auto-compaction fails in a way that escapes the normal try-catch,
|
||||
* e.g., when the summarization request itself exceeds the model's token limit.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export function onUnhandledCompactionFailure(cb: CompactionFailureListener): () => void {
|
||||
compactionFailureEmitter.on("failure", cb);
|
||||
return () => compactionFailureEmitter.off("failure", cb);
|
||||
}
|
||||
|
||||
registerUnhandledRejectionHandler((reason) => {
|
||||
const message = describeUnknownError(reason);
|
||||
if (!isCompactionFailureError(message)) return false;
|
||||
log.error(`Auto-compaction failed (unhandled): ${message}`);
|
||||
compactionFailureEmitter.emit("failure", message);
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -7,20 +7,9 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
import { normalizeModelCompat } from "../model-compat.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
|
||||
|
||||
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
|
||||
|
||||
function applyProviderModelOverrides(model: Model<Api>): Model<Api> {
|
||||
if (model.provider === "github-copilot") {
|
||||
const headers = model.headers
|
||||
? { ...model.headers, "User-Agent": resolveGithubCopilotUserAgent() }
|
||||
: { "User-Agent": resolveGithubCopilotUserAgent() };
|
||||
return { ...model, headers };
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
export function buildInlineProviderModels(
|
||||
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
||||
): InlineModelEntry[] {
|
||||
@@ -71,7 +60,7 @@ export function resolveModel(
|
||||
if (inlineMatch) {
|
||||
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
||||
return {
|
||||
model: applyProviderModelOverrides(normalized),
|
||||
model: normalized,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
};
|
||||
@@ -89,7 +78,7 @@ export function resolveModel(
|
||||
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
||||
} as Model<Api>);
|
||||
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry };
|
||||
return { model: fallbackModel, authStorage, modelRegistry };
|
||||
}
|
||||
return {
|
||||
error: `Unknown model: ${provider}/${modelId}`,
|
||||
@@ -97,9 +86,5 @@ export function resolveModel(
|
||||
modelRegistry,
|
||||
};
|
||||
}
|
||||
return {
|
||||
model: applyProviderModelOverrides(normalizeModelCompat(model)),
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
};
|
||||
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { resolveUserPath } from "../../utils.js";
|
||||
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||
import {
|
||||
isProfileInCooldown,
|
||||
markAuthProfileFailure,
|
||||
markAuthProfileGood,
|
||||
markAuthProfileUsed,
|
||||
@@ -149,11 +148,7 @@ export async function runEmbeddedPiAgent(
|
||||
if (lockedProfileId && !profileOrder.includes(lockedProfileId)) {
|
||||
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
|
||||
}
|
||||
const profileCandidates = lockedProfileId
|
||||
? [lockedProfileId]
|
||||
: profileOrder.length > 0
|
||||
? profileOrder
|
||||
: [undefined];
|
||||
const profileCandidates = profileOrder.length > 0 ? profileOrder : [undefined];
|
||||
let profileIndex = 0;
|
||||
|
||||
const initialThinkLevel = params.thinkLevel ?? "off";
|
||||
@@ -174,18 +169,26 @@ export async function runEmbeddedPiAgent(
|
||||
|
||||
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
|
||||
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
|
||||
const resolvedProfileId = apiKeyInfo.profileId ?? candidate;
|
||||
if (!apiKeyInfo.apiKey) {
|
||||
if (apiKeyInfo.mode !== "aws-sdk") {
|
||||
throw new Error(
|
||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
||||
);
|
||||
}
|
||||
lastProfileId = resolvedProfileId;
|
||||
lastProfileId = apiKeyInfo.profileId;
|
||||
return;
|
||||
}
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
lastProfileId = resolvedProfileId;
|
||||
if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } =
|
||||
await import("../../providers/github-copilot-token.js");
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||
} else {
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
}
|
||||
lastProfileId = apiKeyInfo.profileId;
|
||||
};
|
||||
|
||||
const advanceAuthProfile = async (): Promise<boolean> => {
|
||||
@@ -193,10 +196,6 @@ export async function runEmbeddedPiAgent(
|
||||
let nextIndex = profileIndex + 1;
|
||||
while (nextIndex < profileCandidates.length) {
|
||||
const candidate = profileCandidates[nextIndex];
|
||||
if (candidate && isProfileInCooldown(authStore, candidate)) {
|
||||
nextIndex += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await applyApiKeyInfo(candidate);
|
||||
profileIndex = nextIndex;
|
||||
@@ -212,24 +211,7 @@ export async function runEmbeddedPiAgent(
|
||||
};
|
||||
|
||||
try {
|
||||
while (profileIndex < profileCandidates.length) {
|
||||
const candidate = profileCandidates[profileIndex];
|
||||
if (
|
||||
candidate &&
|
||||
candidate !== lockedProfileId &&
|
||||
isProfileInCooldown(authStore, candidate)
|
||||
) {
|
||||
profileIndex += 1;
|
||||
continue;
|
||||
}
|
||||
await applyApiKeyInfo(profileCandidates[profileIndex]);
|
||||
break;
|
||||
}
|
||||
if (profileIndex >= profileCandidates.length) {
|
||||
throw new Error(
|
||||
`No available auth profile for ${provider} (all in cooldown or unavailable).`,
|
||||
);
|
||||
}
|
||||
await applyApiKeyInfo(profileCandidates[profileIndex]);
|
||||
} catch (err) {
|
||||
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
|
||||
const advanced = await advanceAuthProfile();
|
||||
@@ -520,12 +502,10 @@ export async function runEmbeddedPiAgent(
|
||||
store: authStore,
|
||||
provider,
|
||||
profileId: lastProfileId,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
await markAuthProfileUsed({
|
||||
store: authStore,
|
||||
profileId: lastProfileId,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
|
||||
* - <invoke name="...">...</invoke> blocks
|
||||
* - </minimax:tool_call> closing tags
|
||||
*/
|
||||
export function stripMinimaxToolCallXml(text: string): string {
|
||||
function stripMinimaxToolCallXml(text: string): string {
|
||||
if (!text) return text;
|
||||
if (!/minimax:tool_call/i.test(text)) return text;
|
||||
|
||||
@@ -28,7 +28,7 @@ export function stripMinimaxToolCallXml(text: string): string {
|
||||
* downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should
|
||||
* not be shown to users.
|
||||
*/
|
||||
export function stripDowngradedToolCallText(text: string): string {
|
||||
function stripDowngradedToolCallText(text: string): string {
|
||||
if (!text) return text;
|
||||
if (!/\[Tool (?:Call|Result)/i.test(text)) return text;
|
||||
|
||||
@@ -165,7 +165,7 @@ export function stripDowngradedToolCallText(text: string): string {
|
||||
* This is a safety net for cases where the model outputs <think> tags
|
||||
* that slip through other filtering mechanisms.
|
||||
*/
|
||||
export function stripThinkingTagsFromText(text: string): string {
|
||||
function stripThinkingTagsFromText(text: string): string {
|
||||
if (!text) return text;
|
||||
// Quick check to avoid regex overhead when no tags present.
|
||||
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
|
||||
|
||||
@@ -3,15 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { __testing } from "./compaction-safeguard.js";
|
||||
|
||||
const {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
BASE_CHUNK_RATIO,
|
||||
MIN_CHUNK_RATIO,
|
||||
SAFETY_MARGIN,
|
||||
} = __testing;
|
||||
const { collectToolFailures, formatToolFailuresSection } = __testing;
|
||||
|
||||
describe("compaction-safeguard tool failures", () => {
|
||||
it("formats tool failures with meta and summary", () => {
|
||||
@@ -104,107 +96,3 @@ describe("compaction-safeguard tool failures", () => {
|
||||
expect(section).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAdaptiveChunkRatio", () => {
|
||||
const CONTEXT_WINDOW = 200_000;
|
||||
|
||||
it("returns BASE_CHUNK_RATIO for normal messages", () => {
|
||||
// Small messages: 1000 tokens each, well under 10% of context
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "x".repeat(1000), timestamp: Date.now() },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "y".repeat(1000) }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
||||
expect(ratio).toBe(BASE_CHUNK_RATIO);
|
||||
});
|
||||
|
||||
it("reduces ratio when average message > 10% of context", () => {
|
||||
// Large messages: ~50K tokens each (25% of context)
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "x".repeat(50_000 * 4), timestamp: Date.now() },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "y".repeat(50_000 * 4) }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
||||
expect(ratio).toBeLessThan(BASE_CHUNK_RATIO);
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
|
||||
});
|
||||
|
||||
it("respects MIN_CHUNK_RATIO floor", () => {
|
||||
// Very large messages that would push ratio below minimum
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "x".repeat(150_000 * 4), timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
|
||||
});
|
||||
|
||||
it("handles empty message array", () => {
|
||||
const ratio = computeAdaptiveChunkRatio([], CONTEXT_WINDOW);
|
||||
expect(ratio).toBe(BASE_CHUNK_RATIO);
|
||||
});
|
||||
|
||||
it("handles single huge message", () => {
|
||||
// Single massive message
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "x".repeat(180_000 * 4), timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
|
||||
expect(ratio).toBeLessThanOrEqual(BASE_CHUNK_RATIO);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOversizedForSummary", () => {
|
||||
const CONTEXT_WINDOW = 200_000;
|
||||
|
||||
it("returns false for small messages", () => {
|
||||
const msg: AgentMessage = {
|
||||
role: "user",
|
||||
content: "Hello, world!",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for messages > 50% of context", () => {
|
||||
// Message with ~120K tokens (60% of 200K context)
|
||||
// After safety margin (1.2x), effective is 144K which is > 100K (50%)
|
||||
const msg: AgentMessage = {
|
||||
role: "user",
|
||||
content: "x".repeat(120_000 * 4),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies safety margin", () => {
|
||||
// Message at exactly 50% of context before margin
|
||||
// After SAFETY_MARGIN (1.2), it becomes 60% which is > 50%
|
||||
const halfContextChars = (CONTEXT_WINDOW * 0.5) / SAFETY_MARGIN;
|
||||
const msg: AgentMessage = {
|
||||
role: "user",
|
||||
content: "x".repeat(Math.floor(halfContextChars * 4)),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// With safety margin applied, this should be at the boundary
|
||||
// The function checks if tokens * SAFETY_MARGIN > contextWindow * 0.5
|
||||
const isOversized = isOversizedForSummary(msg, CONTEXT_WINDOW);
|
||||
// Due to token estimation, this could be either true or false at the boundary
|
||||
expect(typeof isOversized).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,7 @@ import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
|
||||
const BASE_CHUNK_RATIO = 0.4;
|
||||
const MIN_CHUNK_RATIO = 0.15;
|
||||
const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
|
||||
const MAX_CHUNK_RATIO = 0.4;
|
||||
const FALLBACK_SUMMARY =
|
||||
"Summary unavailable due to context limits. Older messages were truncated.";
|
||||
const TURN_PREFIX_INSTRUCTIONS =
|
||||
@@ -162,38 +160,6 @@ function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessag
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute adaptive chunk ratio based on average message size.
|
||||
* When messages are large, we use smaller chunks to avoid exceeding model limits.
|
||||
*/
|
||||
function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
|
||||
if (messages.length === 0) return BASE_CHUNK_RATIO;
|
||||
|
||||
const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0);
|
||||
const avgTokens = totalTokens / messages.length;
|
||||
|
||||
// Apply safety margin to account for estimation inaccuracy
|
||||
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
|
||||
const avgRatio = safeAvgTokens / contextWindow;
|
||||
|
||||
// If average message is > 10% of context, reduce chunk ratio
|
||||
if (avgRatio > 0.1) {
|
||||
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
|
||||
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
|
||||
}
|
||||
|
||||
return BASE_CHUNK_RATIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a single message is too large to summarize.
|
||||
* If single message > 50% of context, it can't be summarized safely.
|
||||
*/
|
||||
function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
|
||||
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
|
||||
return tokens > contextWindow * 0.5;
|
||||
}
|
||||
|
||||
async function summarizeChunks(params: {
|
||||
messages: AgentMessage[];
|
||||
model: NonNullable<ExtensionContext["model"]>;
|
||||
@@ -226,78 +192,6 @@ async function summarizeChunks(params: {
|
||||
return summary ?? "No prior history.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize with progressive fallback for handling oversized messages.
|
||||
* If full summarization fails, tries partial summarization excluding oversized messages.
|
||||
*/
|
||||
async function summarizeWithFallback(params: {
|
||||
messages: AgentMessage[];
|
||||
model: NonNullable<ExtensionContext["model"]>;
|
||||
apiKey: string;
|
||||
signal: AbortSignal;
|
||||
reserveTokens: number;
|
||||
maxChunkTokens: number;
|
||||
contextWindow: number;
|
||||
customInstructions?: string;
|
||||
previousSummary?: string;
|
||||
}): Promise<string> {
|
||||
const { messages, contextWindow } = params;
|
||||
|
||||
if (messages.length === 0) {
|
||||
return params.previousSummary ?? "No prior history.";
|
||||
}
|
||||
|
||||
// Try full summarization first
|
||||
try {
|
||||
return await summarizeChunks(params);
|
||||
} catch (fullError) {
|
||||
console.warn(
|
||||
`Full summarization failed, trying partial: ${
|
||||
fullError instanceof Error ? fullError.message : String(fullError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback 1: Summarize only small messages, note oversized ones
|
||||
const smallMessages: AgentMessage[] = [];
|
||||
const oversizedNotes: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (isOversizedForSummary(msg, contextWindow)) {
|
||||
const role = (msg as { role?: string }).role ?? "message";
|
||||
const tokens = estimateTokens(msg);
|
||||
oversizedNotes.push(
|
||||
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
|
||||
);
|
||||
} else {
|
||||
smallMessages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (smallMessages.length > 0) {
|
||||
try {
|
||||
const partialSummary = await summarizeChunks({
|
||||
...params,
|
||||
messages: smallMessages,
|
||||
});
|
||||
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
|
||||
return partialSummary + notes;
|
||||
} catch (partialError) {
|
||||
console.warn(
|
||||
`Partial summarization also failed: ${
|
||||
partialError instanceof Error ? partialError.message : String(partialError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: Just note what was there
|
||||
return (
|
||||
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
|
||||
`Summary unavailable due to size limits.`
|
||||
);
|
||||
}
|
||||
|
||||
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
api.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, customInstructions, signal } = event;
|
||||
@@ -339,35 +233,29 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
1,
|
||||
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
|
||||
);
|
||||
|
||||
// Use adaptive chunk ratio based on message sizes
|
||||
const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
|
||||
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
|
||||
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
|
||||
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * MAX_CHUNK_RATIO));
|
||||
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
||||
|
||||
const historySummary = await summarizeWithFallback({
|
||||
const historySummary = await summarizeChunks({
|
||||
messages: preparation.messagesToSummarize,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions,
|
||||
previousSummary: preparation.previousSummary,
|
||||
});
|
||||
|
||||
let summary = historySummary;
|
||||
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
|
||||
const prefixSummary = await summarizeWithFallback({
|
||||
const prefixSummary = await summarizeChunks({
|
||||
messages: preparation.turnPrefixMessages,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
||||
});
|
||||
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||
@@ -405,9 +293,4 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
export const __testing = {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
BASE_CHUNK_RATIO,
|
||||
MIN_CHUNK_RATIO,
|
||||
SAFETY_MARGIN,
|
||||
} as const;
|
||||
|
||||
@@ -237,20 +237,6 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).toContain("Bravo");
|
||||
});
|
||||
|
||||
it("adds SOUL guidance when a soul file is present", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
contextFiles: [
|
||||
{ path: "./SOUL.md", content: "Persona" },
|
||||
{ path: "dir\\SOUL.md", content: "Persona Windows" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(prompt).toContain(
|
||||
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes the message tool when available", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
|
||||
@@ -517,18 +517,12 @@ export function buildAgentSystemPrompt(params: {
|
||||
|
||||
const contextFiles = params.contextFiles ?? [];
|
||||
if (contextFiles.length > 0) {
|
||||
const hasSoulFile = contextFiles.some((file) => {
|
||||
const normalizedPath = file.path.trim().replace(/\\/g, "/");
|
||||
const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
|
||||
return baseName.toLowerCase() === "soul.md";
|
||||
});
|
||||
lines.push("# Project Context", "", "The following project context files have been loaded:");
|
||||
if (hasSoulFile) {
|
||||
lines.push(
|
||||
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"# Project Context",
|
||||
"",
|
||||
"The following project context files have been loaded:",
|
||||
"",
|
||||
);
|
||||
for (const file of contextFiles) {
|
||||
lines.push(`## ${file.path}`, "", file.content, "");
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js";
|
||||
|
||||
describe("sanitizeTextContent", () => {
|
||||
it("strips minimax tool call XML and downgraded markers", () => {
|
||||
const input =
|
||||
'Hello <invoke name="tool">payload</invoke></minimax:tool_call> ' +
|
||||
"[Tool Call: foo (ID: 1)] world";
|
||||
const result = sanitizeTextContent(input).trim();
|
||||
expect(result).toBe("Hello world");
|
||||
expect(result).not.toContain("invoke");
|
||||
expect(result).not.toContain("Tool Call");
|
||||
});
|
||||
|
||||
it("strips thinking tags", () => {
|
||||
const input = "Before <think>secret</think> after";
|
||||
const result = sanitizeTextContent(input).trim();
|
||||
expect(result).toBe("Before after");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAssistantText", () => {
|
||||
it("sanitizes blocks without injecting newlines", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Hi " },
|
||||
{ type: "text", text: "<think>secret</think>there" },
|
||||
],
|
||||
};
|
||||
expect(extractAssistantText(message)).toBe("Hi there");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,4 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { sanitizeUserFacingText } from "../pi-embedded-helpers.js";
|
||||
import {
|
||||
stripDowngradedToolCallText,
|
||||
stripMinimaxToolCallXml,
|
||||
stripThinkingTagsFromText,
|
||||
} from "../pi-embedded-utils.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
|
||||
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
|
||||
@@ -106,15 +100,6 @@ export function stripToolMessages(messages: unknown[]): unknown[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text content to strip tool call markers and thinking tags.
|
||||
* This ensures user-facing text doesn't leak internal tool representations.
|
||||
*/
|
||||
export function sanitizeTextContent(text: string): string {
|
||||
if (!text) return text;
|
||||
return stripThinkingTagsFromText(stripDowngradedToolCallText(stripMinimaxToolCallXml(text)));
|
||||
}
|
||||
|
||||
export function extractAssistantText(message: unknown): string | undefined {
|
||||
if (!message || typeof message !== "object") return undefined;
|
||||
if ((message as { role?: unknown }).role !== "assistant") return undefined;
|
||||
@@ -125,13 +110,10 @@ export function extractAssistantText(message: unknown): string | undefined {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if ((block as { type?: unknown }).type !== "text") continue;
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
const sanitized = sanitizeTextContent(text);
|
||||
if (sanitized.trim()) {
|
||||
chunks.push(sanitized);
|
||||
}
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
chunks.push(text);
|
||||
}
|
||||
}
|
||||
const joined = chunks.join("").trim();
|
||||
return joined ? sanitizeUserFacingText(joined) : undefined;
|
||||
return joined ? joined : undefined;
|
||||
}
|
||||
|
||||
@@ -357,20 +357,6 @@ describe("handleSlackAction", () => {
|
||||
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("passes threadId through to readSlackMessages", async () => {
|
||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
readSlackMessages.mockClear();
|
||||
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
|
||||
|
||||
await handleSlackAction(
|
||||
{ action: "readMessages", channelId: "C1", threadId: "12345.6789" },
|
||||
cfg,
|
||||
);
|
||||
|
||||
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
|
||||
expect(opts?.threadId).toBe("12345.6789");
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to pin payloads", async () => {
|
||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
listSlackPins.mockResolvedValueOnce([
|
||||
|
||||
@@ -214,13 +214,11 @@ export async function handleSlackAction(
|
||||
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
||||
const before = readStringParam(params, "before");
|
||||
const after = readStringParam(params, "after");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const result = await readSlackMessages(channelId, {
|
||||
...readOpts,
|
||||
limit,
|
||||
before: before ?? undefined,
|
||||
after: after ?? undefined,
|
||||
threadId: threadId ?? undefined,
|
||||
});
|
||||
const messages = result.messages.map((message) =>
|
||||
withNormalizedTimestamp(
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("block streaming", () => {
|
||||
});
|
||||
|
||||
async function waitForCalls(fn: () => number, calls: number) {
|
||||
const deadline = Date.now() + 5000;
|
||||
const deadline = Date.now() + 1500;
|
||||
while (fn() < calls) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`Expected ${calls} call(s), got ${fn()}`);
|
||||
|
||||
@@ -136,7 +136,6 @@ export async function runReplyAgent(params: {
|
||||
followupRun.run.config,
|
||||
replyToChannel,
|
||||
sessionCtx.AccountId,
|
||||
sessionCtx.ChatType,
|
||||
);
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||
const cfg = followupRun.run.config;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractMessageText } from "./commands-subagents.js";
|
||||
|
||||
describe("extractMessageText", () => {
|
||||
it("preserves user text that looks like tool call markers", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
||||
});
|
||||
|
||||
it("sanitizes assistant tool call markers", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toBe("Here ok");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
extractAssistantText,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
sanitizeTextContent,
|
||||
stripToolMessages,
|
||||
} from "../../agents/tools/sessions-helpers.js";
|
||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||
@@ -107,14 +106,11 @@ function normalizeMessageText(text: string) {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
|
||||
function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
|
||||
const role = typeof message.role === "string" ? message.role : "";
|
||||
const shouldSanitize = role === "assistant";
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
const normalized = normalizeMessageText(
|
||||
shouldSanitize ? sanitizeTextContent(content) : content,
|
||||
);
|
||||
const normalized = normalizeMessageText(content);
|
||||
return normalized ? { role, text: normalized } : null;
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
@@ -123,11 +119,8 @@ export function extractMessageText(message: ChatMessage): { role: string; text:
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if ((block as { type?: unknown }).type !== "text") continue;
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
const value = shouldSanitize ? sanitizeTextContent(text) : text;
|
||||
if (value.trim()) {
|
||||
chunks.push(value);
|
||||
}
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
chunks.push(text);
|
||||
}
|
||||
}
|
||||
const joined = normalizeMessageText(chunks.join(" "));
|
||||
|
||||
@@ -204,7 +204,6 @@ export function createFollowupRunner(params: {
|
||||
queued.run.config,
|
||||
replyToChannel,
|
||||
queued.originatingAccountId,
|
||||
queued.originatingChatType,
|
||||
);
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||
|
||||
@@ -358,7 +358,6 @@ export async function runPreparedReply(
|
||||
originatingTo: ctx.OriginatingTo,
|
||||
originatingAccountId: ctx.AccountId,
|
||||
originatingThreadId: ctx.MessageThreadId,
|
||||
originatingChatType: ctx.ChatType,
|
||||
run: {
|
||||
agentId,
|
||||
agentDir,
|
||||
|
||||
@@ -39,8 +39,6 @@ export type FollowupRun = {
|
||||
originatingAccountId?: string;
|
||||
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
|
||||
originatingThreadId?: string | number;
|
||||
/** Chat type for context-aware threading (e.g., DM vs channel). */
|
||||
originatingChatType?: string;
|
||||
run: {
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
|
||||
@@ -31,46 +31,6 @@ describe("resolveReplyToMode", () => {
|
||||
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
||||
});
|
||||
|
||||
it("uses chat-type replyToMode overrides for Slack when configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all", group: "first" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
|
||||
});
|
||||
|
||||
it("falls back to top-level replyToMode when no chat-type override is set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
|
||||
});
|
||||
|
||||
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
dm: { replyToMode: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReplyToModeFilter", () => {
|
||||
|
||||
@@ -9,14 +9,12 @@ export function resolveReplyToMode(
|
||||
cfg: ClawdbotConfig,
|
||||
channel?: OriginatingChannelType,
|
||||
accountId?: string | null,
|
||||
chatType?: string | null,
|
||||
): ReplyToMode {
|
||||
const provider = normalizeChannelId(channel);
|
||||
if (!provider) return "all";
|
||||
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId,
|
||||
chatType,
|
||||
});
|
||||
return resolved ?? "all";
|
||||
}
|
||||
|
||||
@@ -60,12 +60,9 @@ export type MsgContext = {
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
MediaDir?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
OutputDir?: string;
|
||||
OutputBase?: string;
|
||||
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
||||
MediaRemoteHost?: string;
|
||||
Transcript?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
|
||||
import { resolveSlackAccount } from "../slack/accounts.js";
|
||||
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
@@ -224,8 +224,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||
allowTagsWhenOff: true,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
|
||||
@@ -47,7 +47,7 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
"1) Discord Developer Portal → Applications → New Application",
|
||||
"2) Bot → Add Bot → Reset Token → copy token",
|
||||
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
|
||||
"Tip: enable Message Content Intent if you need message text. (Bot → Privileged Gateway Intents → Message Content Intent)",
|
||||
"Tip: enable Message Content Intent if you need message text.",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
].join("\n"),
|
||||
"Discord bot token",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { createSlackActions } from "./slack.actions.js";
|
||||
|
||||
const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
|
||||
|
||||
vi.mock("../../agents/tools/slack-actions.js", () => ({
|
||||
handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
|
||||
}));
|
||||
|
||||
describe("slack actions adapter", () => {
|
||||
it("forwards threadId for read", async () => {
|
||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
const actions = createSlackActions("slack");
|
||||
|
||||
await actions.handleAction?.({
|
||||
channel: "slack",
|
||||
action: "read",
|
||||
cfg,
|
||||
params: {
|
||||
channelId: "C1",
|
||||
threadId: "171234.567",
|
||||
},
|
||||
});
|
||||
|
||||
const [params] = handleSlackAction.mock.calls[0] ?? [];
|
||||
expect(params).toMatchObject({
|
||||
action: "readMessages",
|
||||
channelId: "C1",
|
||||
threadId: "171234.567",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,7 +133,6 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
|
||||
limit,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
threadId: readStringParam(params, "threadId"),
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
|
||||
@@ -198,7 +198,6 @@ export type ChannelThreadingAdapter = {
|
||||
resolveReplyToMode?: (params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
chatType?: string | null;
|
||||
}) => "off" | "first" | "all";
|
||||
allowTagsWhenOff?: boolean;
|
||||
buildToolContext?: (params: {
|
||||
|
||||
@@ -94,13 +94,9 @@ export function buildParseArgv(params: {
|
||||
: baseArgv[0]?.endsWith("clawdbot")
|
||||
? baseArgv.slice(1)
|
||||
: baseArgv;
|
||||
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
|
||||
const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? "";
|
||||
const looksLikeNode =
|
||||
normalizedArgv.length >= 2 &&
|
||||
(executable === "node" ||
|
||||
executable === "node.exe" ||
|
||||
executable === "bun" ||
|
||||
executable === "bun.exe");
|
||||
normalizedArgv.length >= 2 && (executable === "node" || executable === "bun");
|
||||
if (looksLikeNode) return normalizedArgv;
|
||||
return ["node", programName || "clawdbot", ...normalizedArgv];
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { resolveBrowserActionContext } from "./shared.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
|
||||
export function registerBrowserFilesAndDownloadsCommands(
|
||||
browser: Command,
|
||||
@@ -74,7 +73,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`downloaded: ${shortenHomePath(result.download.path)}`);
|
||||
defaultRuntime.log(`downloaded: ${result.download.path}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -106,7 +105,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`downloaded: ${shortenHomePath(result.download.path)}`);
|
||||
defaultRuntime.log(`downloaded: ${result.download.path}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
|
||||
function runBrowserObserve(action: () => Promise<void>) {
|
||||
return runCommandWithRuntime(defaultRuntime, action, (err) => {
|
||||
@@ -62,7 +61,7 @@ export function registerBrowserActionObserveCommands(
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`PDF: ${shortenHomePath(result.path)}`);
|
||||
defaultRuntime.log(`PDF: ${result.path}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
|
||||
function runBrowserDebug(action: () => Promise<void>) {
|
||||
return runCommandWithRuntime(defaultRuntime, action, (err) => {
|
||||
@@ -165,7 +164,7 @@ export function registerBrowserDebugCommands(
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`TRACE:${shortenHomePath(result.path)}`);
|
||||
defaultRuntime.log(`TRACE:${result.path}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { movePathToTrash } from "../browser/trash.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
|
||||
function bundledExtensionRootDir() {
|
||||
@@ -78,8 +77,7 @@ export function registerBrowserExtensionCommands(
|
||||
defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2));
|
||||
return;
|
||||
}
|
||||
const displayPath = shortenHomePath(installed.path);
|
||||
defaultRuntime.log(displayPath);
|
||||
defaultRuntime.log(installed.path);
|
||||
const copied = await copyToClipboard(installed.path).catch(() => false);
|
||||
defaultRuntime.error(
|
||||
info(
|
||||
@@ -87,7 +85,7 @@ export function registerBrowserExtensionCommands(
|
||||
copied ? "Copied to clipboard." : "Copy to clipboard unavailable.",
|
||||
"Next:",
|
||||
`- Chrome → chrome://extensions → enable “Developer mode”`,
|
||||
`- “Load unpacked” → select: ${displayPath}`,
|
||||
`- “Load unpacked” → select: ${installed.path}`,
|
||||
`- Pin “Clawdbot Browser Relay”, then click it on the tab (badge shows ON)`,
|
||||
"",
|
||||
`${theme.muted("Docs:")} ${formatDocsLink("/tools/chrome-extension", "docs.clawd.bot/tools/chrome-extension")}`,
|
||||
@@ -117,8 +115,7 @@ export function registerBrowserExtensionCommands(
|
||||
defaultRuntime.log(JSON.stringify({ path: dir }, null, 2));
|
||||
return;
|
||||
}
|
||||
const displayPath = shortenHomePath(dir);
|
||||
defaultRuntime.log(displayPath);
|
||||
defaultRuntime.log(dir);
|
||||
const copied = await copyToClipboard(dir).catch(() => false);
|
||||
if (copied) defaultRuntime.error(info("Copied to clipboard."));
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { browserScreenshotAction } from "../browser/client-actions.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
|
||||
export function registerBrowserInspectCommands(
|
||||
@@ -37,7 +36,7 @@ export function registerBrowserInspectCommands(
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`MEDIA:${shortenHomePath(result.path)}`);
|
||||
defaultRuntime.log(`MEDIA:${result.path}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -107,9 +106,9 @@ export function registerBrowserInspectCommands(
|
||||
),
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.log(shortenHomePath(opts.out));
|
||||
defaultRuntime.log(opts.out);
|
||||
if (result.format === "ai" && result.imagePath) {
|
||||
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
|
||||
defaultRuntime.log(`MEDIA:${result.imagePath}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -123,7 +122,7 @@ export function registerBrowserInspectCommands(
|
||||
if (result.format === "ai") {
|
||||
defaultRuntime.log(result.snapshot);
|
||||
if (result.imagePath) {
|
||||
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
|
||||
defaultRuntime.log(`MEDIA:${result.imagePath}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { browserAct } from "../browser/client-actions-core.js";
|
||||
import { danger, info } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||
|
||||
@@ -47,8 +46,6 @@ export function registerBrowserManageCommands(
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
const detectedPath = status.detectedExecutablePath ?? status.executablePath;
|
||||
const detectedDisplay = detectedPath ? shortenHomePath(detectedPath) : "auto";
|
||||
defaultRuntime.log(
|
||||
[
|
||||
`profile: ${status.profile ?? "clawd"}`,
|
||||
@@ -59,7 +56,7 @@ export function registerBrowserManageCommands(
|
||||
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
||||
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
||||
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
|
||||
`detectedPath: ${detectedDisplay}`,
|
||||
`detectedPath: ${status.detectedExecutablePath ?? status.executablePath ?? "auto"}`,
|
||||
`profileColor: ${status.color}`,
|
||||
...(status.detectError ? [`detectError: ${status.detectError}`] : []),
|
||||
].join("\n"),
|
||||
|
||||
@@ -7,7 +7,6 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
|
||||
type PathSegment = string;
|
||||
|
||||
@@ -169,7 +168,7 @@ function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolea
|
||||
async function loadValidConfig() {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.valid) return snapshot;
|
||||
defaultRuntime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
|
||||
defaultRuntime.error(`Config invalid at ${snapshot.path}.`);
|
||||
for (const issue of snapshot.issues) {
|
||||
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { isWSLEnv } from "../../infra/wsl.js";
|
||||
import { getResolvedLoggerSettings } from "../../logging.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import {
|
||||
filterDaemonEnv,
|
||||
@@ -67,7 +66,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`);
|
||||
try {
|
||||
const logFile = getResolvedLoggerSettings().file;
|
||||
defaultRuntime.log(`${label("File logs:")} ${infoText(shortenHomePath(logFile))}`);
|
||||
defaultRuntime.log(`${label("File logs:")} ${infoText(logFile)}`);
|
||||
} catch {
|
||||
// ignore missing config/log resolution
|
||||
}
|
||||
@@ -77,14 +76,10 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
);
|
||||
}
|
||||
if (service.command?.sourcePath) {
|
||||
defaultRuntime.log(
|
||||
`${label("Service file:")} ${infoText(shortenHomePath(service.command.sourcePath))}`,
|
||||
);
|
||||
defaultRuntime.log(`${label("Service file:")} ${infoText(service.command.sourcePath)}`);
|
||||
}
|
||||
if (service.command?.workingDirectory) {
|
||||
defaultRuntime.log(
|
||||
`${label("Working dir:")} ${infoText(shortenHomePath(service.command.workingDirectory))}`,
|
||||
);
|
||||
defaultRuntime.log(`${label("Working dir:")} ${infoText(service.command.workingDirectory)}`);
|
||||
}
|
||||
const daemonEnvLines = safeDaemonEnv(service.command?.environment);
|
||||
if (daemonEnvLines.length > 0) {
|
||||
@@ -106,7 +101,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
}
|
||||
|
||||
if (status.config) {
|
||||
const cliCfg = `${shortenHomePath(status.config.cli.path)}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
||||
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
||||
defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`);
|
||||
if (!status.config.cli.valid && status.config.cli.issues?.length) {
|
||||
for (const issue of status.config.cli.issues.slice(0, 5)) {
|
||||
@@ -116,7 +111,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
}
|
||||
}
|
||||
if (status.config.daemon) {
|
||||
const daemonCfg = `${shortenHomePath(status.config.daemon.path)}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
|
||||
const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
|
||||
defaultRuntime.log(`${label("Config (service):")} ${infoText(daemonCfg)}`);
|
||||
if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
|
||||
for (const issue of status.config.daemon.issues.slice(0, 5)) {
|
||||
@@ -281,8 +276,8 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
const logs = resolveGatewayLogPaths(
|
||||
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
|
||||
);
|
||||
defaultRuntime.error(`${errorText("Logs:")} ${shortenHomePath(logs.stdoutPath)}`);
|
||||
defaultRuntime.error(`${errorText("Errors:")} ${shortenHomePath(logs.stderrPath)}`);
|
||||
defaultRuntime.error(`${errorText("Logs:")} ${logs.stdoutPath}`);
|
||||
defaultRuntime.error(`${errorText("Errors:")} ${logs.stderrPath}`);
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
||||
import { handleReset } from "../../commands/onboard-helpers.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../../utils.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
|
||||
const DEV_IDENTITY_NAME = "C3-PO";
|
||||
const DEV_IDENTITY_THEME = "protocol droid";
|
||||
@@ -117,6 +117,6 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
|
||||
},
|
||||
});
|
||||
await ensureDevWorkspace(workspace);
|
||||
defaultRuntime.log(`Dev config ready: ${shortenHomePath(CONFIG_PATH_CLAWDBOT)}`);
|
||||
defaultRuntime.log(`Dev workspace ready: ${shortenHomePath(resolveUserPath(workspace))}`);
|
||||
defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`);
|
||||
defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const waitForReady = async (
|
||||
const waitForPortOpen = async (
|
||||
proc: ReturnType<typeof spawn>,
|
||||
chunksOut: string[],
|
||||
chunksErr: string[],
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (proc.exitCode !== null) {
|
||||
const stdout = chunksOut.join("");
|
||||
const stderr = chunksErr.join("");
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`timeout waiting for gateway to start\n` +
|
||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||
),
|
||||
throw new Error(
|
||||
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
|
||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||
);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
proc.off("exit", onExit);
|
||||
proc.off("message", onMessage);
|
||||
proc.stdout?.off("data", onStdout);
|
||||
};
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", (err) => {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
const stdout = chunksOut.join("");
|
||||
const stderr = chunksErr.join("");
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`gateway exited before ready (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
|
||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
const stdout = chunksOut.join("");
|
||||
const stderr = chunksErr.join("");
|
||||
throw new Error(
|
||||
`timeout waiting for gateway to listen on port ${port}\n` +
|
||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||
);
|
||||
};
|
||||
|
||||
const onMessage = (msg: unknown) => {
|
||||
if (msg && typeof msg === "object" && "ready" in msg) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const onStdout = (chunk: unknown) => {
|
||||
if (String(chunk).includes("READY")) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
proc.once("exit", onExit);
|
||||
proc.on("message", onMessage);
|
||||
proc.stdout?.on("data", onStdout);
|
||||
});
|
||||
const getFreePort = async () => {
|
||||
const srv = net.createServer();
|
||||
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
srv.close();
|
||||
throw new Error("failed to bind ephemeral port");
|
||||
}
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
return addr.port;
|
||||
};
|
||||
|
||||
describe("gateway SIGTERM", () => {
|
||||
@@ -77,50 +77,67 @@ describe("gateway SIGTERM", () => {
|
||||
});
|
||||
|
||||
it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gateway-test-"));
|
||||
const configPath = path.join(stateDir, "clawdbot.json");
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ gateway: { mode: "local", port } }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
|
||||
const nodeBin = process.execPath;
|
||||
const entryArgs = [
|
||||
"gateway",
|
||||
"--port",
|
||||
String(port),
|
||||
"--bind",
|
||||
"loopback",
|
||||
"--allow-unconfigured",
|
||||
];
|
||||
const env = {
|
||||
...process.env,
|
||||
CLAWDBOT_NO_RESPAWN: "1",
|
||||
CLAWDBOT_STATE_DIR: stateDir,
|
||||
CLAWDBOT_CONFIG_PATH: configPath,
|
||||
CLAWDBOT_SKIP_CHANNELS: "1",
|
||||
CLAWDBOT_SKIP_GMAIL_WATCHER: "1",
|
||||
CLAWDBOT_SKIP_CRON: "1",
|
||||
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||
CLAWDBOT_SKIP_CANVAS_HOST: "1",
|
||||
// Avoid port collisions with other test processes that may also start a gateway server.
|
||||
CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
|
||||
CLAWDBOT_BRIDGE_PORT: "0",
|
||||
};
|
||||
const bootstrapPath = path.join(stateDir, "clawdbot-entry-bootstrap.mjs");
|
||||
const runLoopPath = path.resolve("src/cli/gateway-cli/run-loop.ts");
|
||||
const runtimePath = path.resolve("src/runtime.ts");
|
||||
const runMainPath = path.resolve("src/cli/run-main.ts");
|
||||
fs.writeFileSync(
|
||||
bootstrapPath,
|
||||
[
|
||||
'import { pathToFileURL } from "node:url";',
|
||||
`const runLoopUrl = ${JSON.stringify(pathToFileURL(runLoopPath).href)};`,
|
||||
`const runtimeUrl = ${JSON.stringify(pathToFileURL(runtimePath).href)};`,
|
||||
"const { runGatewayLoop } = await import(runLoopUrl);",
|
||||
"const { defaultRuntime } = await import(runtimeUrl);",
|
||||
"await runGatewayLoop({",
|
||||
" start: async () => {",
|
||||
' process.stdout.write("READY\\\\n");',
|
||||
" if (process.send) process.send({ ready: true });",
|
||||
" const keepAlive = setInterval(() => {}, 1000);",
|
||||
" return { close: async () => clearInterval(keepAlive) };",
|
||||
" },",
|
||||
" runtime: defaultRuntime,",
|
||||
"});",
|
||||
'const rawArgs = process.env.CLAWDBOT_ENTRY_ARGS ?? "[]";',
|
||||
"let entryArgs = [];",
|
||||
"try {",
|
||||
" entryArgs = JSON.parse(rawArgs);",
|
||||
"} catch (err) {",
|
||||
' console.error("Failed to parse CLAWDBOT_ENTRY_ARGS", err);',
|
||||
" process.exit(1);",
|
||||
"}",
|
||||
"if (!Array.isArray(entryArgs)) entryArgs = [];",
|
||||
'entryArgs = entryArgs.filter((arg) => typeof arg === "string" && !arg.toLowerCase().includes("node.exe"));',
|
||||
`const runMainUrl = ${JSON.stringify(pathToFileURL(runMainPath).href)};`,
|
||||
"const { runCli } = await import(runMainUrl);",
|
||||
'await runCli(["node", "clawdbot", ...entryArgs]);',
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const childArgs = ["--import", "tsx", bootstrapPath];
|
||||
env.CLAWDBOT_ENTRY_ARGS = JSON.stringify(entryArgs);
|
||||
|
||||
child = spawn(nodeBin, childArgs, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const proc = child;
|
||||
@@ -131,7 +148,7 @@ describe("gateway SIGTERM", () => {
|
||||
child.stdout?.on("data", (d) => out.push(String(d)));
|
||||
child.stderr?.on("data", (d) => err.push(String(d)));
|
||||
|
||||
await waitForReady(proc, out, err, 150_000);
|
||||
await waitForPortOpen(proc, out, err, port, 150_000);
|
||||
|
||||
proc.kill("SIGTERM");
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export type HooksListOptions = {
|
||||
json?: boolean;
|
||||
@@ -224,8 +224,8 @@ export function formatHookInfo(
|
||||
} else {
|
||||
lines.push(`${theme.muted(" Source:")} ${hook.source}`);
|
||||
}
|
||||
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(hook.filePath)}`);
|
||||
lines.push(`${theme.muted(" Handler:")} ${shortenHomePath(hook.handlerPath)}`);
|
||||
lines.push(`${theme.muted(" Path:")} ${hook.filePath}`);
|
||||
lines.push(`${theme.muted(" Handler:")} ${hook.handlerPath}`);
|
||||
if (hook.homepage) {
|
||||
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`);
|
||||
}
|
||||
@@ -577,7 +577,7 @@ export function registerHooksCli(program: Command): void {
|
||||
});
|
||||
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(`Linked hook path: ${shortenHomePath(resolved)}`);
|
||||
defaultRuntime.log(`Linked hook path: ${resolved}`);
|
||||
defaultRuntime.log(`Restart the gateway to load hooks.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { shortenHomeInString, shortenHomePath } from "../utils.js";
|
||||
|
||||
type MemoryCommandOptions = {
|
||||
agent?: string;
|
||||
@@ -45,15 +44,11 @@ type MemorySourceScan = {
|
||||
|
||||
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
|
||||
if (source === "memory") {
|
||||
return shortenHomeInString(
|
||||
`memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`,
|
||||
);
|
||||
return `memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`;
|
||||
}
|
||||
if (source === "sessions") {
|
||||
const stateDir = resolveStateDir(process.env, os.homedir);
|
||||
return shortenHomeInString(
|
||||
`sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`,
|
||||
);
|
||||
return `sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
@@ -81,10 +76,7 @@ async function checkReadableFile(pathname: string): Promise<{ exists: boolean; i
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") return { exists: false };
|
||||
return {
|
||||
exists: true,
|
||||
issue: `${shortenHomePath(pathname)} not readable (${code ?? "error"})`,
|
||||
};
|
||||
return { exists: true, issue: `${pathname} not readable (${code ?? "error"})` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +92,10 @@ async function scanSessionFiles(agentId: string): Promise<SourceScan> {
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
issues.push(`sessions directory missing (${shortenHomePath(sessionsDir)})`);
|
||||
issues.push(`sessions directory missing (${sessionsDir})`);
|
||||
return { source: "sessions", totalFiles: 0, issues };
|
||||
}
|
||||
issues.push(
|
||||
`sessions directory not accessible (${shortenHomePath(sessionsDir)}): ${code ?? "error"}`,
|
||||
);
|
||||
issues.push(`sessions directory not accessible (${sessionsDir}): ${code ?? "error"}`);
|
||||
return { source: "sessions", totalFiles: null, issues };
|
||||
}
|
||||
}
|
||||
@@ -128,12 +118,10 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
issues.push(`memory directory missing (${shortenHomePath(memoryDir)})`);
|
||||
issues.push(`memory directory missing (${memoryDir})`);
|
||||
dirReadable = false;
|
||||
} else {
|
||||
issues.push(
|
||||
`memory directory not accessible (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
|
||||
);
|
||||
issues.push(`memory directory not accessible (${memoryDir}): ${code ?? "error"}`);
|
||||
dirReadable = null;
|
||||
}
|
||||
}
|
||||
@@ -146,9 +134,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (dirReadable !== null) {
|
||||
issues.push(
|
||||
`memory directory scan failed (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
|
||||
);
|
||||
issues.push(`memory directory scan failed (${memoryDir}): ${code ?? "error"}`);
|
||||
dirReadable = null;
|
||||
}
|
||||
}
|
||||
@@ -166,7 +152,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
|
||||
}
|
||||
|
||||
if ((totalFiles ?? 0) === 0 && issues.length === 0) {
|
||||
issues.push(`no memory files found in ${shortenHomePath(workspaceDir)}`);
|
||||
issues.push(`no memory files found in ${workspaceDir}`);
|
||||
}
|
||||
|
||||
return { source: "memory", totalFiles, issues };
|
||||
@@ -308,8 +294,8 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
||||
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
|
||||
`${label("Indexed")} ${success(indexedLabel)}`,
|
||||
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
||||
`${label("Store")} ${info(shortenHomePath(status.dbPath))}`,
|
||||
`${label("Workspace")} ${info(shortenHomePath(status.workspaceDir))}`,
|
||||
`${label("Store")} ${info(status.dbPath)}`,
|
||||
`${label("Workspace")} ${info(status.workspaceDir)}`,
|
||||
].filter(Boolean) as string[];
|
||||
if (embeddingProbe) {
|
||||
const state = embeddingProbe.ok ? "ready" : "unavailable";
|
||||
@@ -354,7 +340,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
||||
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
||||
}
|
||||
if (status.vector.extensionPath) {
|
||||
lines.push(`${label("Vector path")} ${info(shortenHomePath(status.vector.extensionPath))}`);
|
||||
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
|
||||
}
|
||||
if (status.vector.loadError) {
|
||||
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
|
||||
@@ -608,7 +594,7 @@ export function registerMemoryCli(program: Command) {
|
||||
`${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize(
|
||||
rich,
|
||||
theme.accent,
|
||||
`${shortenHomePath(result.path)}:${result.startLine}-${result.endLine}`,
|
||||
`${result.path}:${result.startLine}-${result.endLine}`,
|
||||
)}`,
|
||||
);
|
||||
lines.push(colorize(rich, theme.muted, result.snippet));
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
import { renderTable } from "../../terminal/table.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
|
||||
const parseFacing = (value: string): CameraFacing => {
|
||||
const v = String(value ?? "")
|
||||
@@ -166,7 +165,7 @@ export function registerNodesCameraCommands(nodes: Command) {
|
||||
defaultRuntime.log(JSON.stringify({ files: results }, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(results.map((r) => `MEDIA:${shortenHomePath(r.path)}`).join("\n"));
|
||||
defaultRuntime.log(results.map((r) => `MEDIA:${r.path}`).join("\n"));
|
||||
});
|
||||
}),
|
||||
{ timeoutMs: 60_000 },
|
||||
@@ -240,7 +239,7 @@ export function registerNodesCameraCommands(nodes: Command) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`);
|
||||
defaultRuntime.log(`MEDIA:${filePath}`);
|
||||
});
|
||||
}),
|
||||
{ timeoutMs: 90_000 },
|
||||
|
||||
@@ -9,7 +9,6 @@ import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
|
||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
|
||||
async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record<string, unknown>) {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
@@ -86,7 +85,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`);
|
||||
defaultRuntime.log(`MEDIA:${filePath}`);
|
||||
});
|
||||
}),
|
||||
{ timeoutMs: 60_000 },
|
||||
|
||||
@@ -10,7 +10,6 @@ import { parseDurationMs } from "../parse-duration.js";
|
||||
import { runNodesCommand } from "./cli-utils.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
|
||||
export function registerNodesScreenCommands(nodes: Command) {
|
||||
const screen = nodes
|
||||
@@ -78,7 +77,7 @@ export function registerNodesScreenCommands(nodes: Command) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`MEDIA:${shortenHomePath(written.path)}`);
|
||||
defaultRuntime.log(`MEDIA:${written.path}`);
|
||||
});
|
||||
}),
|
||||
{ timeoutMs: 180_000 },
|
||||
|
||||
@@ -6,7 +6,6 @@ import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
import { renderTable } from "../../terminal/table.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
import { shortenHomeInString } from "../../utils.js";
|
||||
|
||||
function formatVersionLabel(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
@@ -50,9 +49,8 @@ function formatPathEnv(raw?: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const parts = trimmed.split(":").filter(Boolean);
|
||||
const display =
|
||||
parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`;
|
||||
return shortenHomeInString(display);
|
||||
if (parts.length <= 3) return trimmed;
|
||||
return `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`;
|
||||
}
|
||||
|
||||
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export type PluginsListOptions = {
|
||||
json?: boolean;
|
||||
@@ -55,7 +55,7 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
||||
|
||||
const parts = [
|
||||
`${name}${idSuffix} ${status}`,
|
||||
` source: ${theme.muted(shortenHomeInString(plugin.source))}`,
|
||||
` source: ${theme.muted(plugin.source)}`,
|
||||
` origin: ${plugin.origin}`,
|
||||
];
|
||||
if (plugin.version) parts.push(` version: ${plugin.version}`);
|
||||
@@ -135,22 +135,19 @@ export function registerPluginsCli(program: Command) {
|
||||
|
||||
if (!opts.verbose) {
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const rows = list.map((plugin) => {
|
||||
const desc = plugin.description ? theme.muted(plugin.description) : "";
|
||||
const sourceLine = desc ? `${plugin.source}\n${desc}` : plugin.source;
|
||||
return {
|
||||
Name: plugin.name || plugin.id,
|
||||
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
|
||||
Status:
|
||||
plugin.status === "loaded"
|
||||
? theme.success("loaded")
|
||||
: plugin.status === "disabled"
|
||||
? theme.warn("disabled")
|
||||
: theme.error("error"),
|
||||
Source: sourceLine,
|
||||
Version: plugin.version ?? "",
|
||||
};
|
||||
});
|
||||
const rows = list.map((plugin) => ({
|
||||
Name: plugin.name || plugin.id,
|
||||
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
|
||||
Status:
|
||||
plugin.status === "loaded"
|
||||
? theme.success("loaded")
|
||||
: plugin.status === "disabled"
|
||||
? theme.warn("disabled")
|
||||
: theme.error("error"),
|
||||
Source: plugin.source,
|
||||
Version: plugin.version ?? "",
|
||||
Description: plugin.description ?? "",
|
||||
}));
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
@@ -158,8 +155,9 @@ export function registerPluginsCli(program: Command) {
|
||||
{ key: "Name", header: "Name", minWidth: 14, flex: true },
|
||||
{ key: "ID", header: "ID", minWidth: 10, flex: true },
|
||||
{ key: "Status", header: "Status", minWidth: 10 },
|
||||
{ key: "Source", header: "Source", minWidth: 26, flex: true },
|
||||
{ key: "Source", header: "Source", minWidth: 10 },
|
||||
{ key: "Version", header: "Version", minWidth: 8 },
|
||||
{ key: "Description", header: "Description", minWidth: 18, flex: true },
|
||||
],
|
||||
rows,
|
||||
}).trimEnd(),
|
||||
@@ -203,7 +201,7 @@ export function registerPluginsCli(program: Command) {
|
||||
if (plugin.description) lines.push(plugin.description);
|
||||
lines.push("");
|
||||
lines.push(`${theme.muted("Status:")} ${plugin.status}`);
|
||||
lines.push(`${theme.muted("Source:")} ${shortenHomeInString(plugin.source)}`);
|
||||
lines.push(`${theme.muted("Source:")} ${plugin.source}`);
|
||||
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`);
|
||||
if (plugin.version) lines.push(`${theme.muted("Version:")} ${plugin.version}`);
|
||||
if (plugin.toolNames.length > 0) {
|
||||
@@ -229,10 +227,9 @@ export function registerPluginsCli(program: Command) {
|
||||
lines.push("");
|
||||
lines.push(`${theme.muted("Install:")} ${install.source}`);
|
||||
if (install.spec) lines.push(`${theme.muted("Spec:")} ${install.spec}`);
|
||||
if (install.sourcePath)
|
||||
lines.push(`${theme.muted("Source path:")} ${shortenHomePath(install.sourcePath)}`);
|
||||
if (install.sourcePath) lines.push(`${theme.muted("Source path:")} ${install.sourcePath}`);
|
||||
if (install.installPath)
|
||||
lines.push(`${theme.muted("Install path:")} ${shortenHomePath(install.installPath)}`);
|
||||
lines.push(`${theme.muted("Install path:")} ${install.installPath}`);
|
||||
if (install.version) lines.push(`${theme.muted("Recorded version:")} ${install.version}`);
|
||||
if (install.installedAt)
|
||||
lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`);
|
||||
@@ -336,7 +333,7 @@ export function registerPluginsCli(program: Command) {
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(`Linked plugin path: ${shortenHomePath(resolved)}`);
|
||||
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-fl
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
|
||||
const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]);
|
||||
const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([
|
||||
@@ -61,7 +60,7 @@ export async function ensureConfigReady(params: {
|
||||
const commandText = (value: string) => colorize(rich, theme.command, value);
|
||||
|
||||
params.runtime.error(heading("Config invalid"));
|
||||
params.runtime.error(`${muted("File:")} ${muted(shortenHomePath(snapshot.path))}`);
|
||||
params.runtime.error(`${muted("File:")} ${muted(snapshot.path)}`);
|
||||
if (issues.length > 0) {
|
||||
params.runtime.error(muted("Problem:"));
|
||||
params.runtime.error(issues.map((issue) => ` ${error(issue)}`).join("\n"));
|
||||
|
||||
@@ -56,12 +56,9 @@ export function registerOnboardCommand(program: Command) {
|
||||
)
|
||||
.option(
|
||||
"--token-provider <id>",
|
||||
"Token provider id (non-interactive; used with --auth-choice token or apiKey)",
|
||||
)
|
||||
.option(
|
||||
"--token <token>",
|
||||
"Token value (non-interactive; used with --auth-choice token or apiKey)",
|
||||
"Token provider id (non-interactive; used with --auth-choice token)",
|
||||
)
|
||||
.option("--token <token>", "Token value (non-interactive; used with --auth-choice token)")
|
||||
.option(
|
||||
"--token-profile-id <id>",
|
||||
"Auth profile id (non-interactive; default: <provider>:manual)",
|
||||
|
||||
@@ -6,7 +6,6 @@ import { runSecurityAudit } from "../security/audit.js";
|
||||
import { fixSecurityFootguns } from "../security/fix.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { shortenHomeInString, shortenHomePath } from "../utils.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
|
||||
type SecurityAuditOptions = {
|
||||
@@ -84,24 +83,18 @@ export function registerSecurityCli(program: Command) {
|
||||
lines.push("");
|
||||
lines.push(heading("FIX"));
|
||||
for (const change of fixResult.changes) {
|
||||
lines.push(muted(` ${shortenHomeInString(change)}`));
|
||||
lines.push(muted(` ${change}`));
|
||||
}
|
||||
for (const action of fixResult.actions) {
|
||||
const mode = action.mode.toString(8).padStart(3, "0");
|
||||
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
|
||||
if (action.ok) lines.push(muted(` chmod ${mode} ${action.path}`));
|
||||
else if (action.skipped)
|
||||
lines.push(
|
||||
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
|
||||
);
|
||||
lines.push(muted(` skip chmod ${mode} ${action.path} (${action.skipped})`));
|
||||
else if (action.error)
|
||||
lines.push(
|
||||
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
|
||||
);
|
||||
lines.push(muted(` chmod ${mode} ${action.path} failed: ${action.error}`));
|
||||
}
|
||||
if (fixResult.errors.length > 0) {
|
||||
for (const err of fixResult.errors) {
|
||||
lines.push(muted(` error: ${shortenHomeInString(err)}`));
|
||||
}
|
||||
for (const err of fixResult.errors) lines.push(muted(` error: ${err}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
|
||||
export type SkillsListOptions = {
|
||||
@@ -177,7 +176,7 @@ export function formatSkillInfo(
|
||||
// Details
|
||||
lines.push(theme.heading("Details:"));
|
||||
lines.push(`${theme.muted(" Source:")} ${skill.source}`);
|
||||
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(skill.filePath)}`);
|
||||
lines.push(`${theme.muted(" Path:")} ${skill.filePath}`);
|
||||
if (skill.homepage) {
|
||||
lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,11 @@ import {
|
||||
} from "../agents/agent-scope.js";
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
|
||||
import { writeConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
||||
import {
|
||||
@@ -127,7 +126,7 @@ export async function agentsAddCommand(
|
||||
: { config: nextConfig, added: [], skipped: [], conflicts: [] };
|
||||
|
||||
await writeConfigFile(bindingResult.config);
|
||||
if (!opts.json) logConfigUpdated(runtime);
|
||||
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
|
||||
await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, {
|
||||
skipBootstrap: Boolean(bindingResult.config.agents?.defaults?.skipBootstrap),
|
||||
@@ -152,8 +151,8 @@ export async function agentsAddCommand(
|
||||
runtime.log(JSON.stringify(payload, null, 2));
|
||||
} else {
|
||||
runtime.log(`Agent: ${agentId}`);
|
||||
runtime.log(`Workspace: ${shortenHomePath(workspaceDir)}`);
|
||||
runtime.log(`Agent dir: ${shortenHomePath(agentDir)}`);
|
||||
runtime.log(`Workspace: ${workspaceDir}`);
|
||||
runtime.log(`Agent dir: ${agentDir}`);
|
||||
if (model) runtime.log(`Model: ${model}`);
|
||||
if (bindingResult.conflicts.length > 0) {
|
||||
runtime.error(
|
||||
@@ -335,7 +334,7 @@ export async function agentsAddCommand(
|
||||
}
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
logConfigUpdated(runtime);
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
|
||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||
agentId,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { writeConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -70,7 +69,7 @@ export async function agentsDeleteCommand(
|
||||
|
||||
const result = pruneAgentConfig(cfg, agentId);
|
||||
await writeConfigFile(result.config);
|
||||
if (!opts.json) logConfigUpdated(runtime);
|
||||
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
|
||||
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
|
||||
await moveToTrash(workspaceDir, quietRuntime);
|
||||
|
||||
@@ -4,13 +4,12 @@ import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js";
|
||||
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
||||
import { writeConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||
import type { IdentityConfig } from "../config/types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { requireValidConfig } from "./agents.command-shared.js";
|
||||
import {
|
||||
type AgentIdentity,
|
||||
@@ -106,14 +105,14 @@ export async function agentsSetIdentityCommand(
|
||||
const matches = resolveAgentIdByWorkspace(cfg, workspaceDir);
|
||||
if (matches.length === 0) {
|
||||
runtime.error(
|
||||
`No agent workspace matches ${shortenHomePath(workspaceDir)}. Pass --agent to target a specific agent.`,
|
||||
`No agent workspace matches ${workspaceDir}. Pass --agent to target a specific agent.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
runtime.error(
|
||||
`Multiple agents match ${shortenHomePath(workspaceDir)}: ${matches.join(", ")}. Pass --agent to choose one.`,
|
||||
`Multiple agents match ${workspaceDir}: ${matches.join(", ")}. Pass --agent to choose one.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
@@ -132,7 +131,7 @@ export async function agentsSetIdentityCommand(
|
||||
const targetPath =
|
||||
identityFilePath ??
|
||||
(workspaceDir ? path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME) : "IDENTITY.md");
|
||||
runtime.error(`No identity data found in ${shortenHomePath(targetPath)}.`);
|
||||
runtime.error(`No identity data found in ${targetPath}.`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
@@ -212,11 +211,11 @@ export async function agentsSetIdentityCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
logConfigUpdated(runtime);
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(`Agent: ${agentId}`);
|
||||
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
|
||||
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
|
||||
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
|
||||
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
|
||||
if (workspaceDir) runtime.log(`Workspace: ${shortenHomePath(workspaceDir)}`);
|
||||
if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import { describeBinding } from "./agents.bindings.js";
|
||||
import { requireValidConfig } from "./agents.command-shared.js";
|
||||
import type { AgentSummary } from "./agents.config.js";
|
||||
@@ -41,8 +40,8 @@ function formatSummary(summary: AgentSummary) {
|
||||
if (identityLine) {
|
||||
lines.push(` Identity: ${identityLine}${identitySource ? ` (${identitySource})` : ""}`);
|
||||
}
|
||||
lines.push(` Workspace: ${shortenHomePath(summary.workspace)}`);
|
||||
lines.push(` Agent dir: ${shortenHomePath(summary.agentDir)}`);
|
||||
lines.push(` Workspace: ${summary.workspace}`);
|
||||
lines.push(` Agent dir: ${summary.agentDir}`);
|
||||
if (summary.model) lines.push(` Model: ${summary.model}`);
|
||||
lines.push(` Routing rules: ${summary.bindings}`);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
||||
value: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "Claude Code CLI + API key",
|
||||
choices: ["token", "claude-cli", "apiKey"],
|
||||
choices: ["claude-cli", "token", "apiKey"],
|
||||
},
|
||||
{
|
||||
value: "minimax",
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
|
||||
import { applyAuthChoice } from "./auth-choice.js";
|
||||
|
||||
const noopAsync = async () => {};
|
||||
const noop = () => {};
|
||||
const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json");
|
||||
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
const previousOpenaiKey = process.env.OPENAI_API_KEY;
|
||||
let tempStateDir: string | null = null;
|
||||
|
||||
async function setupTempState() {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
await fs.mkdir(process.env.CLAWDBOT_AGENT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function buildPrompter() {
|
||||
const text = vi.fn(async () => "");
|
||||
const confirm = vi.fn(async () => false);
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(noopAsync),
|
||||
outro: vi.fn(noopAsync),
|
||||
note: vi.fn(noopAsync),
|
||||
select: vi.fn(async () => "" as never),
|
||||
multiselect: vi.fn(async () => []),
|
||||
text,
|
||||
confirm,
|
||||
progress: vi.fn(() => ({ update: noop, stop: noop })),
|
||||
};
|
||||
return { prompter, text, confirm };
|
||||
}
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}),
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempStateDir) {
|
||||
await fs.rm(tempStateDir, { recursive: true, force: true });
|
||||
tempStateDir = null;
|
||||
}
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
if (previousAnthropicKey === undefined) {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY = previousAnthropicKey;
|
||||
}
|
||||
if (previousOpenaiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousOpenaiKey;
|
||||
}
|
||||
});
|
||||
|
||||
describe("applyAuthChoice with apiKey flags", () => {
|
||||
it("uses provided openrouter token when authChoice=apiKey", async () => {
|
||||
await setupTempState();
|
||||
const agentDir = process.env.CLAWDBOT_AGENT_DIR ?? "";
|
||||
const authProfilePath = authProfilePathFor(agentDir);
|
||||
await fs.writeFile(
|
||||
authProfilePath,
|
||||
JSON.stringify({
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
"openrouter:legacy": {
|
||||
type: "oauth",
|
||||
provider: "openrouter",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const { prompter, text, confirm } = buildPrompter();
|
||||
const result = await applyAuthChoice({
|
||||
authChoice: "apiKey",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
opts: {
|
||||
tokenProvider: "openrouter",
|
||||
token: "sk-openrouter-flag",
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(result.config.auth?.profiles?.["openrouter:default"]).toMatchObject({
|
||||
provider: "openrouter",
|
||||
mode: "api_key",
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(authProfilePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { profiles?: Record<string, { key?: string }> };
|
||||
expect(parsed.profiles?.["openrouter:default"]?.key).toBe("sk-openrouter-flag");
|
||||
});
|
||||
|
||||
it("uses provided openai token when authChoice=apiKey", async () => {
|
||||
await setupTempState();
|
||||
const { prompter, text, confirm } = buildPrompter();
|
||||
|
||||
await applyAuthChoice({
|
||||
authChoice: "apiKey",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
opts: {
|
||||
tokenProvider: "openai",
|
||||
token: "sk-openai-flag",
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(process.env.OPENAI_API_KEY).toBe("sk-openai-flag");
|
||||
const envPath = path.join(process.env.CLAWDBOT_STATE_DIR ?? "", ".env");
|
||||
const envContents = await fs.readFile(envPath, "utf8");
|
||||
expect(envContents).toContain("OPENAI_API_KEY=sk-openai-flag");
|
||||
});
|
||||
|
||||
it("uses provided anthropic token when authChoice=apiKey", async () => {
|
||||
await setupTempState();
|
||||
process.env.ANTHROPIC_API_KEY = "sk-env-test";
|
||||
const { prompter, text, confirm } = buildPrompter();
|
||||
|
||||
await applyAuthChoice({
|
||||
authChoice: "apiKey",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
opts: {
|
||||
tokenProvider: "anthropic",
|
||||
token: "sk-anthropic-flag",
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
|
||||
const authProfilePath = authProfilePathFor(process.env.CLAWDBOT_AGENT_DIR ?? "");
|
||||
const raw = await fs.readFile(authProfilePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { profiles?: Record<string, { key?: string }> };
|
||||
expect(parsed.profiles?.["anthropic:default"]?.key).toBe("sk-anthropic-flag");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user