Merge branch 'main' into feature/bluebubbles-text-formatting
This commit is contained in:
commit
07d64e7d59
100
CHANGELOG.md
100
CHANGELOG.md
@ -2,66 +2,70 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.21-2
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
|
||||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
|
||||||
- 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.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Config: avoid stack traces for invalid configs and log the config path.
|
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
||||||
- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900)
|
|
||||||
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
|
||||||
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
|
|
||||||
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
|
|
||||||
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
|
||||||
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
|
||||||
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
|
|
||||||
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
|
||||||
|
|
||||||
## 2026.1.21
|
## 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
|
### Changes
|
||||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
|
||||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
- 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
|
||||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
|
||||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
|
||||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
|
||||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
- 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
|
||||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
- 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
|
||||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
- 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
|
||||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
- 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
|
||||||
- **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:** 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
|
### 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.
|
- 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)
|
- 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)
|
||||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
- 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.
|
- 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.
|
- Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 2026.1.20
|
## 2026.1.20
|
||||||
|
|
||||||
@ -96,6 +100,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
|
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||||
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
|
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
|
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
|
||||||
|
- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.clawd.bot/gateway/configuration
|
||||||
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
|
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
|
||||||
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
|
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
|
||||||
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
|
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
|
||||||
@ -105,6 +110,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||||
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
|
|
||||||
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
||||||
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
||||||
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
||||||
|
|||||||
36
README.md
36
README.md
@ -479,26 +479,26 @@ Thanks to all clawtributors:
|
|||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/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/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/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/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/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/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/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/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/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/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/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/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/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a>
|
<a href="https://github.com/search?q=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/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/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=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></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/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/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/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/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/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/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/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=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=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/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/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/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>
|
</p>
|
||||||
|
|||||||
157
appcast.xml
157
appcast.xml
@ -2,6 +2,80 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.21</title>
|
||||||
|
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>7374</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
||||||
|
<h3>Highlights</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster</li>
|
||||||
|
<li>Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui</li>
|
||||||
|
<li>Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning</li>
|
||||||
|
<li>Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated</li>
|
||||||
|
<li>Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams</li>
|
||||||
|
<li><code>/models</code> UX refresh + <code>clawdbot update wizard</code>. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.</li>
|
||||||
|
<li>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</li>
|
||||||
|
<li>Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui</li>
|
||||||
|
<li>CLI: add <code>clawdbot update wizard</code> with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update</li>
|
||||||
|
<li>Models/Commands: add <code>/models</code>, improve <code>/model</code> listing UX, and expand <code>clawdbot models</code> paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models</li>
|
||||||
|
<li>CLI: move gateway service commands under <code>clawdbot gateway</code>, flatten node service commands under <code>clawdbot node</code>, and add <code>gateway probe</code> for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node</li>
|
||||||
|
<li>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</li>
|
||||||
|
<li>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</li>
|
||||||
|
<li>Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat</li>
|
||||||
|
<li>Sessions: add per-channel idle durations via <code>sessions.channelIdleMinutes</code>. (#1353) Thanks @cash-echo-bot.</li>
|
||||||
|
<li>Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node</li>
|
||||||
|
<li>Cache: add <code>cache.ttlPrune</code> mode and auth-aware defaults for cache TTL behavior.</li>
|
||||||
|
<li>Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue</li>
|
||||||
|
<li>Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord</li>
|
||||||
|
<li>Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal</li>
|
||||||
|
<li>MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams</li>
|
||||||
|
<li>Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).</li>
|
||||||
|
<li>macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).</li>
|
||||||
|
<li>Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.</li>
|
||||||
|
<li>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.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Breaking</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>BREAKING:</strong> Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set <code>gateway.controlUi.allowInsecureAuth: true</code> to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http</li>
|
||||||
|
<li><strong>BREAKING:</strong> Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>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.</li>
|
||||||
|
<li>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.</li>
|
||||||
|
<li>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.</li>
|
||||||
|
<li>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</li>
|
||||||
|
<li>Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.</li>
|
||||||
|
<li>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)</li>
|
||||||
|
<li>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.</li>
|
||||||
|
<li>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</li>
|
||||||
|
<li>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.</li>
|
||||||
|
<li>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</li>
|
||||||
|
<li>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 <code>/model</code> list output. (#1376, #1416)</li>
|
||||||
|
<li>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
|
||||||
|
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
|
||||||
|
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
|
||||||
|
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
|
||||||
|
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
|
||||||
|
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</li>
|
||||||
|
<li>Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when <code>gateway.mode</code> is unset. (#900)</li>
|
||||||
|
<li>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
|
||||||
|
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
|
||||||
|
<li>Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.</li>
|
||||||
|
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.21</title>
|
<title>2026.1.21</title>
|
||||||
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||||
@ -208,86 +282,5 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
|||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<title>2026.1.15</title>
|
|
||||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5998</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
|
||||||
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
|
||||||
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
|
||||||
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Breaking</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
|
||||||
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
|
||||||
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
|
||||||
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
|
||||||
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
|
||||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
|
||||||
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
|
||||||
<li>TUI: show provider/model labels for the active session and default model.</li>
|
|
||||||
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
|
||||||
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
|
||||||
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
|
||||||
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
|
||||||
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
|
||||||
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
|
||||||
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
|
||||||
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
|
||||||
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
|
||||||
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
|
||||||
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
|
||||||
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
|
||||||
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
|
||||||
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
|
||||||
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
|
||||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
|
||||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
|
||||||
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
|
||||||
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
|
||||||
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
|
||||||
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
|
||||||
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
|
||||||
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
|
||||||
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
|
||||||
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
|
||||||
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
|
||||||
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
|
||||||
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
|
||||||
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
|
||||||
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
|
||||||
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
|
||||||
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
|
||||||
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
|
||||||
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
|
||||||
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
|
||||||
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
|
||||||
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
|
||||||
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
|
||||||
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
|
||||||
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
|
||||||
</item>
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
|
"originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "axorcist",
|
"identity" : "axorcist",
|
||||||
@ -24,7 +24,7 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -74,6 +74,7 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private(set) var lastPingMs: Double?
|
private(set) var lastPingMs: Double?
|
||||||
|
private(set) var authSourceLabel: String?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||||
|
|
||||||
@ -128,6 +129,7 @@ final class ControlChannel {
|
|||||||
await GatewayConnection.shared.shutdown()
|
await GatewayConnection.shared.shutdown()
|
||||||
self.state = .disconnected
|
self.state = .disconnected
|
||||||
self.lastPingMs = nil
|
self.lastPingMs = nil
|
||||||
|
self.authSourceLabel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||||
@ -188,8 +190,11 @@ final class ControlChannel {
|
|||||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||||
{
|
{
|
||||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||||
|
let tokenKey = CommandResolver.connectionModeIsRemote()
|
||||||
|
? "gateway.remote.token"
|
||||||
|
: "gateway.auth.token"
|
||||||
return
|
return
|
||||||
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
|
"Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||||
"or clear it on the gateway. " +
|
"or clear it on the gateway. " +
|
||||||
"Reason: \(reason)"
|
"Reason: \(reason)"
|
||||||
}
|
}
|
||||||
@ -300,6 +305,27 @@ final class ControlChannel {
|
|||||||
code: 0,
|
code: 0,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||||
}
|
}
|
||||||
|
await self.refreshAuthSourceLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAuthSourceLabel() async {
|
||||||
|
let isRemote = CommandResolver.connectionModeIsRemote()
|
||||||
|
let authSource = await GatewayConnection.shared.authSource()
|
||||||
|
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
|
||||||
|
guard let source else { return nil }
|
||||||
|
switch source {
|
||||||
|
case .deviceToken:
|
||||||
|
return "Auth: device token (paired device)"
|
||||||
|
case .sharedToken:
|
||||||
|
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
||||||
|
case .password:
|
||||||
|
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
|
||||||
|
case .none:
|
||||||
|
return "Auth: none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
|
|||||||
@ -475,8 +475,8 @@ enum ExecApprovalsStore {
|
|||||||
|
|
||||||
private static func mergeAgents(
|
private static func mergeAgents(
|
||||||
current: ExecApprovalsAgent,
|
current: ExecApprovalsAgent,
|
||||||
legacy: ExecApprovalsAgent
|
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
|
||||||
) -> ExecApprovalsAgent {
|
{
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
var allowlist: [ExecAllowlistEntry] = []
|
var allowlist: [ExecAllowlistEntry] = []
|
||||||
func append(_ entry: ExecAllowlistEntry) {
|
func append(_ entry: ExecAllowlistEntry) {
|
||||||
@ -486,8 +486,12 @@ enum ExecApprovalsStore {
|
|||||||
seen.insert(key)
|
seen.insert(key)
|
||||||
allowlist.append(entry)
|
allowlist.append(entry)
|
||||||
}
|
}
|
||||||
for entry in current.allowlist ?? [] { append(entry) }
|
for entry in current.allowlist ?? [] {
|
||||||
for entry in legacy.allowlist ?? [] { append(entry) }
|
append(entry)
|
||||||
|
}
|
||||||
|
for entry in legacy.allowlist ?? [] {
|
||||||
|
append(entry)
|
||||||
|
}
|
||||||
|
|
||||||
return ExecApprovalsAgent(
|
return ExecApprovalsAgent(
|
||||||
security: current.security ?? legacy.security,
|
security: current.security ?? legacy.security,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
|
import CoreGraphics
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@ -44,6 +45,7 @@ final class ExecApprovalsGatewayPrompter {
|
|||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(payload)
|
let data = try JSONEncoder().encode(payload)
|
||||||
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||||
|
guard self.shouldPresent(request: request) else { return }
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||||
try await GatewayConnection.shared.requestVoid(
|
try await GatewayConnection.shared.requestVoid(
|
||||||
method: .execApprovalResolve,
|
method: .execApprovalResolve,
|
||||||
@ -56,4 +58,66 @@ final class ExecApprovalsGatewayPrompter {
|
|||||||
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func shouldPresent(request: GatewayApprovalRequest) -> Bool {
|
||||||
|
let mode = AppStateStore.shared.connectionMode
|
||||||
|
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return Self.shouldPresent(
|
||||||
|
mode: mode,
|
||||||
|
activeSession: activeSession,
|
||||||
|
requestSession: requestSession,
|
||||||
|
lastInputSeconds: Self.lastInputSeconds(),
|
||||||
|
thresholdSeconds: 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shouldPresent(
|
||||||
|
mode: AppState.ConnectionMode,
|
||||||
|
activeSession: String?,
|
||||||
|
requestSession: String?,
|
||||||
|
lastInputSeconds: Int?,
|
||||||
|
thresholdSeconds: Int) -> Bool
|
||||||
|
{
|
||||||
|
let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local)
|
||||||
|
|
||||||
|
if let session = requested, !session.isEmpty {
|
||||||
|
if let active, !active.isEmpty {
|
||||||
|
return active == session
|
||||||
|
}
|
||||||
|
return recentlyActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if let active, !active.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return mode == .local
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lastInputSeconds() -> Int? {
|
||||||
|
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||||
|
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||||
|
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
||||||
|
return Int(seconds.rounded())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension ExecApprovalsGatewayPrompter {
|
||||||
|
static func _testShouldPresent(
|
||||||
|
mode: AppState.ConnectionMode,
|
||||||
|
activeSession: String?,
|
||||||
|
requestSession: String?,
|
||||||
|
lastInputSeconds: Int?,
|
||||||
|
thresholdSeconds: Int = 120) -> Bool
|
||||||
|
{
|
||||||
|
self.shouldPresent(
|
||||||
|
mode: mode,
|
||||||
|
activeSession: activeSession,
|
||||||
|
requestSession: requestSession,
|
||||||
|
lastInputSeconds: lastInputSeconds,
|
||||||
|
thresholdSeconds: thresholdSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@ -13,6 +13,7 @@ struct ExecApprovalPromptRequest: Codable, Sendable {
|
|||||||
var ask: String?
|
var ask: String?
|
||||||
var agentId: String?
|
var agentId: String?
|
||||||
var resolvedPath: String?
|
var resolvedPath: String?
|
||||||
|
var sessionKey: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ExecApprovalSocketRequest: Codable {
|
private struct ExecApprovalSocketRequest: Codable {
|
||||||
@ -215,36 +216,15 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Allow this command?"
|
alert.messageText = "Allow this command?"
|
||||||
|
alert.informativeText = "Review the command details before allowing."
|
||||||
var details = "Clawdbot wants to run:\n\n\(request.command)"
|
alert.accessoryView = self.buildAccessoryView(request)
|
||||||
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedCwd.isEmpty {
|
|
||||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
|
||||||
}
|
|
||||||
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedAgent.isEmpty {
|
|
||||||
details += "\n\nAgent:\n\(trimmedAgent)"
|
|
||||||
}
|
|
||||||
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedPath.isEmpty {
|
|
||||||
details += "\n\nExecutable:\n\(trimmedPath)"
|
|
||||||
}
|
|
||||||
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedHost.isEmpty {
|
|
||||||
details += "\n\nHost:\n\(trimmedHost)"
|
|
||||||
}
|
|
||||||
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
|
||||||
details += "\n\nSecurity:\n\(security)"
|
|
||||||
}
|
|
||||||
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
|
||||||
details += "\nAsk mode:\n\(ask)"
|
|
||||||
}
|
|
||||||
details += "\n\nThis runs on this machine."
|
|
||||||
alert.informativeText = details
|
|
||||||
|
|
||||||
alert.addButton(withTitle: "Allow Once")
|
alert.addButton(withTitle: "Allow Once")
|
||||||
alert.addButton(withTitle: "Always Allow")
|
alert.addButton(withTitle: "Always Allow")
|
||||||
alert.addButton(withTitle: "Don't Allow")
|
alert.addButton(withTitle: "Don't Allow")
|
||||||
|
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||||
|
alert.buttons[2].hasDestructiveAction = true
|
||||||
|
}
|
||||||
|
|
||||||
switch alert.runModal() {
|
switch alert.runModal() {
|
||||||
case .alertFirstButtonReturn:
|
case .alertFirstButtonReturn:
|
||||||
@ -255,6 +235,110 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
return .deny
|
return .deny
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView {
|
||||||
|
let stack = NSStackView()
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.spacing = 8
|
||||||
|
stack.alignment = .leading
|
||||||
|
|
||||||
|
let commandTitle = NSTextField(labelWithString: "Command")
|
||||||
|
commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||||
|
stack.addArrangedSubview(commandTitle)
|
||||||
|
|
||||||
|
let commandText = NSTextView()
|
||||||
|
commandText.isEditable = false
|
||||||
|
commandText.isSelectable = true
|
||||||
|
commandText.drawsBackground = true
|
||||||
|
commandText.backgroundColor = NSColor.textBackgroundColor
|
||||||
|
commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||||
|
commandText.string = request.command
|
||||||
|
commandText.textContainerInset = NSSize(width: 6, height: 6)
|
||||||
|
commandText.textContainer?.lineFragmentPadding = 0
|
||||||
|
commandText.textContainer?.widthTracksTextView = true
|
||||||
|
commandText.isHorizontallyResizable = false
|
||||||
|
commandText.isVerticallyResizable = false
|
||||||
|
|
||||||
|
let commandScroll = NSScrollView()
|
||||||
|
commandScroll.borderType = .lineBorder
|
||||||
|
commandScroll.hasVerticalScroller = false
|
||||||
|
commandScroll.hasHorizontalScroller = false
|
||||||
|
commandScroll.documentView = commandText
|
||||||
|
commandScroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true
|
||||||
|
commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
|
||||||
|
stack.addArrangedSubview(commandScroll)
|
||||||
|
|
||||||
|
let contextTitle = NSTextField(labelWithString: "Context")
|
||||||
|
contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||||
|
stack.addArrangedSubview(contextTitle)
|
||||||
|
|
||||||
|
let contextStack = NSStackView()
|
||||||
|
contextStack.orientation = .vertical
|
||||||
|
contextStack.spacing = 4
|
||||||
|
contextStack.alignment = .leading
|
||||||
|
|
||||||
|
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedCwd.isEmpty {
|
||||||
|
self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack)
|
||||||
|
}
|
||||||
|
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedAgent.isEmpty {
|
||||||
|
self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack)
|
||||||
|
}
|
||||||
|
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedPath.isEmpty {
|
||||||
|
self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack)
|
||||||
|
}
|
||||||
|
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedHost.isEmpty {
|
||||||
|
self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack)
|
||||||
|
}
|
||||||
|
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
||||||
|
self.addDetailRow(title: "Security", value: security, to: contextStack)
|
||||||
|
}
|
||||||
|
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
||||||
|
self.addDetailRow(title: "Ask mode", value: ask, to: contextStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contextStack.arrangedSubviews.isEmpty {
|
||||||
|
let empty = NSTextField(labelWithString: "No additional context provided.")
|
||||||
|
empty.textColor = NSColor.secondaryLabelColor
|
||||||
|
empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||||
|
contextStack.addArrangedSubview(empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.addArrangedSubview(contextStack)
|
||||||
|
|
||||||
|
let footer = NSTextField(labelWithString: "This runs on this machine.")
|
||||||
|
footer.textColor = NSColor.secondaryLabelColor
|
||||||
|
footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||||
|
stack.addArrangedSubview(footer)
|
||||||
|
|
||||||
|
return stack
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func addDetailRow(title: String, value: String, to stack: NSStackView) {
|
||||||
|
let row = NSStackView()
|
||||||
|
row.orientation = .horizontal
|
||||||
|
row.spacing = 6
|
||||||
|
row.alignment = .firstBaseline
|
||||||
|
|
||||||
|
let titleLabel = NSTextField(labelWithString: "\(title):")
|
||||||
|
titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
|
||||||
|
titleLabel.textColor = NSColor.secondaryLabelColor
|
||||||
|
|
||||||
|
let valueLabel = NSTextField(labelWithString: value)
|
||||||
|
valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||||
|
valueLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
|
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
|
row.addArrangedSubview(titleLabel)
|
||||||
|
row.addArrangedSubview(valueLabel)
|
||||||
|
stack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -329,7 +413,8 @@ private enum ExecHostExecutor {
|
|||||||
security: context.security.rawValue,
|
security: context.security.rawValue,
|
||||||
ask: context.ask.rawValue,
|
ask: context.ask.rawValue,
|
||||||
agentId: context.trimmedAgent,
|
agentId: context.trimmedAgent,
|
||||||
resolvedPath: context.resolution?.resolvedPath))
|
resolvedPath: context.resolution?.resolvedPath,
|
||||||
|
sessionKey: request.sessionKey))
|
||||||
|
|
||||||
switch decision {
|
switch decision {
|
||||||
case .deny:
|
case .deny:
|
||||||
|
|||||||
@ -69,6 +69,7 @@ actor GatewayConnection {
|
|||||||
case channelsLogout = "channels.logout"
|
case channelsLogout = "channels.logout"
|
||||||
case modelsList = "models.list"
|
case modelsList = "models.list"
|
||||||
case chatHistory = "chat.history"
|
case chatHistory = "chat.history"
|
||||||
|
case sessionsPreview = "sessions.preview"
|
||||||
case chatSend = "chat.send"
|
case chatSend = "chat.send"
|
||||||
case chatAbort = "chat.abort"
|
case chatAbort = "chat.abort"
|
||||||
case skillsStatus = "skills.status"
|
case skillsStatus = "skills.status"
|
||||||
@ -249,6 +250,11 @@ actor GatewayConnection {
|
|||||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authSource() async -> GatewayAuthSource? {
|
||||||
|
guard let client else { return nil }
|
||||||
|
return await client.authSource()
|
||||||
|
}
|
||||||
|
|
||||||
func shutdown() async {
|
func shutdown() async {
|
||||||
if let client {
|
if let client {
|
||||||
await client.shutdown()
|
await client.shutdown()
|
||||||
@ -535,6 +541,30 @@ extension GatewayConnection {
|
|||||||
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sessions
|
||||||
|
|
||||||
|
func sessionsPreview(
|
||||||
|
keys: [String],
|
||||||
|
limit: Int? = nil,
|
||||||
|
maxChars: Int? = nil,
|
||||||
|
timeoutMs: Int? = nil) async throws -> ClawdbotSessionsPreviewPayload
|
||||||
|
{
|
||||||
|
let resolvedKeys = keys
|
||||||
|
.map { self.canonicalizeSessionKey($0) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if resolvedKeys.isEmpty {
|
||||||
|
return ClawdbotSessionsPreviewPayload(ts: 0, previews: [])
|
||||||
|
}
|
||||||
|
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
|
||||||
|
if let limit { params["limit"] = AnyCodable(limit) }
|
||||||
|
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
|
||||||
|
let timeout = timeoutMs.map { Double($0) }
|
||||||
|
return try await self.requestDecoded(
|
||||||
|
method: .sessionsPreview,
|
||||||
|
params: params,
|
||||||
|
timeoutMs: timeout)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Chat
|
// MARK: - Chat
|
||||||
|
|
||||||
func chatHistory(
|
func chatHistory(
|
||||||
|
|||||||
@ -482,7 +482,7 @@ actor GatewayEndpointStore {
|
|||||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
guard bind == "auto" else { return nil }
|
guard bind == "tailnet" else { return nil }
|
||||||
|
|
||||||
let currentHost = currentURL.host?.lowercased() ?? ""
|
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
@ -560,16 +560,13 @@ actor GatewayEndpointStore {
|
|||||||
{
|
{
|
||||||
switch bindMode {
|
switch bindMode {
|
||||||
case "tailnet":
|
case "tailnet":
|
||||||
return tailscaleIP ?? "127.0.0.1"
|
tailscaleIP ?? "127.0.0.1"
|
||||||
case "auto":
|
case "auto":
|
||||||
if let tailscaleIP, !tailscaleIP.isEmpty {
|
"127.0.0.1"
|
||||||
return tailscaleIP
|
|
||||||
}
|
|
||||||
return "127.0.0.1"
|
|
||||||
case "custom":
|
case "custom":
|
||||||
return customBindHost ?? "127.0.0.1"
|
customBindHost ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
return "127.0.0.1"
|
"127.0.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -212,6 +212,11 @@ struct GeneralSettings: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||||
|
Text(authLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Tip: enable Tailscale for stable remote access.")
|
Text("Tip: enable Tailscale for stable remote access.")
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -18,6 +19,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private var isMenuOpen = false
|
private var isMenuOpen = false
|
||||||
private var lastKnownMenuWidth: CGFloat?
|
private var lastKnownMenuWidth: CGFloat?
|
||||||
private var menuOpenWidth: CGFloat?
|
private var menuOpenWidth: CGFloat?
|
||||||
|
private var isObservingControlChannel = false
|
||||||
|
|
||||||
private var cachedSnapshot: SessionStoreSnapshot?
|
private var cachedSnapshot: SessionStoreSnapshot?
|
||||||
private var cachedErrorText: String?
|
private var cachedErrorText: String?
|
||||||
@ -50,6 +52,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.startControlChannelObservation()
|
||||||
self.nodesStore.start()
|
self.nodesStore.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,6 +99,50 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.cancelPreviewTasks()
|
self.cancelPreviewTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startControlChannelObservation() {
|
||||||
|
guard !self.isObservingControlChannel else { return }
|
||||||
|
self.isObservingControlChannel = true
|
||||||
|
self.observeControlChannelState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeControlChannelState() {
|
||||||
|
withObservationTracking {
|
||||||
|
_ = ControlChannel.shared.state
|
||||||
|
} onChange: { [weak self] in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.handleControlChannelStateChange()
|
||||||
|
self.observeControlChannelState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleControlChannelStateChange() {
|
||||||
|
guard self.isMenuOpen, let menu = self.statusItem?.menu else { return }
|
||||||
|
self.loadTask?.cancel()
|
||||||
|
self.loadTask = Task { [weak self, weak menu] in
|
||||||
|
guard let self, let menu else { return }
|
||||||
|
await self.refreshCache(force: true)
|
||||||
|
await self.refreshUsageCache(force: true)
|
||||||
|
await self.refreshCostUsageCache(force: true)
|
||||||
|
await MainActor.run {
|
||||||
|
guard self.isMenuOpen else { return }
|
||||||
|
self.inject(into: menu)
|
||||||
|
self.injectNodes(into: menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.nodesLoadTask?.cancel()
|
||||||
|
self.nodesLoadTask = Task { [weak self, weak menu] in
|
||||||
|
guard let self, let menu else { return }
|
||||||
|
await self.nodesStore.refresh()
|
||||||
|
await MainActor.run {
|
||||||
|
guard self.isMenuOpen else { return }
|
||||||
|
self.injectNodes(into: menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
self.originalDelegate?.menuNeedsUpdate?(menu)
|
||||||
}
|
}
|
||||||
@ -141,14 +188,23 @@ extension MenuSessionsInjector {
|
|||||||
if rhs.key == mainKey { return false }
|
if rhs.key == mainKey { return false }
|
||||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||||
}
|
}
|
||||||
|
if !rows.isEmpty {
|
||||||
|
let previewKeys = rows.prefix(20).map(\.key)
|
||||||
|
let task = Task {
|
||||||
|
await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10)
|
||||||
|
}
|
||||||
|
self.previewTasks.append(task)
|
||||||
|
}
|
||||||
|
|
||||||
let headerItem = NSMenuItem()
|
let headerItem = NSMenuItem()
|
||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
|
let statusText = self
|
||||||
|
.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
count: rows.count,
|
count: rows.count,
|
||||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
statusText: statusText)),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
@ -598,8 +654,11 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
self.cachedSnapshot = nil
|
if self.cachedSnapshot != nil {
|
||||||
self.cachedErrorText = nil
|
self.cachedErrorText = "Gateway disconnected (showing cached)"
|
||||||
|
} else {
|
||||||
|
self.cachedErrorText = nil
|
||||||
|
}
|
||||||
self.cacheUpdatedAt = Date()
|
self.cacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -624,8 +683,6 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
self.cachedUsageSummary = nil
|
|
||||||
self.cachedUsageErrorText = nil
|
|
||||||
self.usageCacheUpdatedAt = Date()
|
self.usageCacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -648,8 +705,6 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
self.cachedCostSummary = nil
|
|
||||||
self.cachedCostErrorText = nil
|
|
||||||
self.costCacheUpdatedAt = Date()
|
self.costCacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -679,7 +679,8 @@ actor MacNodeRuntime {
|
|||||||
security: context.security.rawValue,
|
security: context.security.rawValue,
|
||||||
ask: context.ask.rawValue,
|
ask: context.ask.rawValue,
|
||||||
agentId: context.agentId,
|
agentId: context.agentId,
|
||||||
resolvedPath: context.resolution?.resolvedPath))
|
resolvedPath: context.resolution?.resolvedPath,
|
||||||
|
sessionKey: context.sessionKey))
|
||||||
}
|
}
|
||||||
switch decision {
|
switch decision {
|
||||||
case .deny:
|
case .deny:
|
||||||
|
|||||||
@ -184,6 +184,14 @@ actor PortGuardian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isListening(port: Int, pid: Int32? = nil) async -> Bool {
|
||||||
|
let listeners = await self.listeners(on: port)
|
||||||
|
if let pid {
|
||||||
|
return listeners.contains(where: { $0.pid == pid })
|
||||||
|
}
|
||||||
|
return !listeners.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
private func listeners(on port: Int) async -> [Listener] {
|
private func listeners(on port: Int) async -> [Listener] {
|
||||||
let res = await ShellExecutor.run(
|
let res = await ShellExecutor.run(
|
||||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||||
|
|||||||
@ -20,11 +20,13 @@ actor RemoteTunnelManager {
|
|||||||
tunnel.process.isRunning,
|
tunnel.process.isRunning,
|
||||||
let local = tunnel.localPort
|
let local = tunnel.localPort
|
||||||
{
|
{
|
||||||
if await self.isTunnelHealthy(port: local) {
|
let pid = tunnel.process.processIdentifier
|
||||||
|
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
||||||
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||||
return local
|
return local
|
||||||
}
|
}
|
||||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
self.logger.error(
|
||||||
|
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
|
||||||
await self.beginRestart()
|
await self.beginRestart()
|
||||||
tunnel.terminate()
|
tunnel.terminate()
|
||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
@ -35,19 +37,11 @@ actor RemoteTunnelManager {
|
|||||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||||
self.isSshProcess(desc)
|
self.isSshProcess(desc)
|
||||||
{
|
{
|
||||||
if await self.isTunnelHealthy(port: desiredPort) {
|
self.logger.info(
|
||||||
self.logger.info(
|
"reusing existing SSH tunnel listener " +
|
||||||
"reusing existing SSH tunnel listener " +
|
"localPort=\(desiredPort, privacy: .public) " +
|
||||||
"localPort=\(desiredPort, privacy: .public) " +
|
"pid=\(desc.pid, privacy: .public)")
|
||||||
"pid=\(desc.pid, privacy: .public)")
|
return desiredPort
|
||||||
return desiredPort
|
|
||||||
}
|
|
||||||
if self.restartInFlight {
|
|
||||||
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
await self.beginRestart()
|
|
||||||
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -88,10 +82,6 @@ actor RemoteTunnelManager {
|
|||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
|
||||||
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||||
let cmd = desc.command.lowercased()
|
let cmd = desc.command.lowercased()
|
||||||
if cmd.contains("ssh") { return true }
|
if cmd.contains("ssh") { return true }
|
||||||
@ -128,21 +118,5 @@ actor RemoteTunnelManager {
|
|||||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
// Keep tunnel reuse lightweight; restart only when the listener disappears.
|
||||||
let pid = desc.pid
|
|
||||||
self.logger.error(
|
|
||||||
"stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)")
|
|
||||||
let killed = await self.kill(pid: pid)
|
|
||||||
if !killed {
|
|
||||||
self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)")
|
|
||||||
}
|
|
||||||
await PortGuardian.shared.removeRecord(pid: pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func kill(pid: Int32) async -> Bool {
|
|
||||||
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
|
||||||
if term.ok { return true }
|
|
||||||
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
|
||||||
return sigkill.ok
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import ClawdbotChatUI
|
import ClawdbotChatUI
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import ClawdbotProtocol
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@ -31,31 +32,80 @@ actor SessionPreviewCache {
|
|||||||
static let shared = SessionPreviewCache()
|
static let shared = SessionPreviewCache()
|
||||||
|
|
||||||
private struct CacheEntry {
|
private struct CacheEntry {
|
||||||
let items: [SessionPreviewItem]
|
let snapshot: SessionMenuPreviewSnapshot
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
private var entries: [String: CacheEntry] = [:]
|
private var entries: [String: CacheEntry] = [:]
|
||||||
|
|
||||||
func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? {
|
func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? {
|
||||||
guard let entry = self.entries[sessionKey] else { return nil }
|
guard let entry = self.entries[sessionKey] else { return nil }
|
||||||
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
||||||
return entry.items
|
return entry.snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func store(items: [SessionPreviewItem], for sessionKey: String) {
|
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
|
||||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
|
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
|
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
|
||||||
self.entries[sessionKey]?.items
|
self.entries[sessionKey]?.snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SessionPreviewLimiter {
|
||||||
|
static let shared = SessionPreviewLimiter(maxConcurrent: 2)
|
||||||
|
|
||||||
|
private let maxConcurrent: Int
|
||||||
|
private var available: Int
|
||||||
|
private var waitQueue: [UUID] = []
|
||||||
|
private var waiters: [UUID: CheckedContinuation<Void, Never>] = [:]
|
||||||
|
|
||||||
|
init(maxConcurrent: Int) {
|
||||||
|
let normalized = max(1, maxConcurrent)
|
||||||
|
self.maxConcurrent = normalized
|
||||||
|
self.available = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func withPermit<T>(_ operation: () async throws -> T) async throws -> T {
|
||||||
|
await self.acquire()
|
||||||
|
defer { self.release() }
|
||||||
|
if Task.isCancelled { throw CancellationError() }
|
||||||
|
return try await operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acquire() async {
|
||||||
|
if self.available > 0 {
|
||||||
|
self.available -= 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let id = UUID()
|
||||||
|
await withCheckedContinuation { cont in
|
||||||
|
self.waitQueue.append(id)
|
||||||
|
self.waiters[id] = cont
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func release() {
|
||||||
|
if let id = self.waitQueue.first {
|
||||||
|
self.waitQueue.removeFirst()
|
||||||
|
if let cont = self.waiters.removeValue(forKey: id) {
|
||||||
|
cont.resume()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.available = min(self.available + 1, self.maxConcurrent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
extension SessionPreviewCache {
|
extension SessionPreviewCache {
|
||||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
func _testSet(
|
||||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
snapshot: SessionMenuPreviewSnapshot,
|
||||||
|
for sessionKey: String,
|
||||||
|
updatedAt: Date = Date())
|
||||||
|
{
|
||||||
|
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _testReset() {
|
func _testReset() {
|
||||||
@ -174,36 +224,44 @@ enum SessionMenuPreviewLoader {
|
|||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||||
private static let previewTimeoutSeconds: Double = 4
|
private static let previewTimeoutSeconds: Double = 4
|
||||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||||
|
private static let previewMaxChars = 240
|
||||||
|
|
||||||
private struct PreviewTimeoutError: LocalizedError {
|
private struct PreviewTimeoutError: LocalizedError {
|
||||||
var errorDescription: String? { "preview timeout" }
|
var errorDescription: String? { "preview timeout" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func prewarm(sessionKeys: [String], maxItems: Int) async {
|
||||||
|
let keys = self.uniqueKeys(sessionKeys)
|
||||||
|
guard !keys.isEmpty else { return }
|
||||||
|
do {
|
||||||
|
let payload = try await self.requestPreview(keys: keys, maxItems: maxItems)
|
||||||
|
await self.cache(payload: payload, maxItems: maxItems)
|
||||||
|
} catch {
|
||||||
|
if self.isUnknownMethodError(error) { return }
|
||||||
|
let errorDescription = String(describing: error)
|
||||||
|
Self.logger.debug(
|
||||||
|
"Session preview prewarm failed count=\(keys.count, privacy: .public) " +
|
||||||
|
"error=\(errorDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
if let cached = await SessionPreviewCache.shared.cachedSnapshot(
|
||||||
return self.snapshot(from: cached)
|
for: sessionKey,
|
||||||
|
maxAge: cacheMaxAgeSeconds)
|
||||||
|
{
|
||||||
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||||
let payload = try await AsyncTimeout.withTimeout(
|
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
|
||||||
seconds: self.previewTimeoutSeconds,
|
return snapshot
|
||||||
onTimeout: { PreviewTimeoutError() },
|
|
||||||
operation: {
|
|
||||||
try await GatewayConnection.shared.chatHistory(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
limit: self.previewLimit(for: maxItems),
|
|
||||||
timeoutMs: timeoutMs)
|
|
||||||
})
|
|
||||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
|
||||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
|
||||||
return Self.snapshot(from: built)
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||||
} catch {
|
} catch {
|
||||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
|
||||||
if let fallback {
|
return fallback
|
||||||
return Self.snapshot(from: fallback)
|
|
||||||
}
|
}
|
||||||
let errorDescription = String(describing: error)
|
let errorDescription = String(describing: error)
|
||||||
Self.logger.warning(
|
Self.logger.warning(
|
||||||
@ -213,18 +271,120 @@ enum SessionMenuPreviewLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot {
|
||||||
|
do {
|
||||||
|
let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems)
|
||||||
|
if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first {
|
||||||
|
return self.snapshot(from: entry, maxItems: maxItems)
|
||||||
|
}
|
||||||
|
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||||
|
} catch {
|
||||||
|
if self.isUnknownMethodError(error) {
|
||||||
|
return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestPreview(
|
||||||
|
keys: [String],
|
||||||
|
maxItems: Int) async throws -> ClawdbotSessionsPreviewPayload
|
||||||
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
|
return try await SessionPreviewLimiter.shared.withPermit {
|
||||||
|
try await AsyncTimeout.withTimeout(
|
||||||
|
seconds: self.previewTimeoutSeconds,
|
||||||
|
onTimeout: { PreviewTimeoutError() },
|
||||||
|
operation: {
|
||||||
|
try await GatewayConnection.shared.sessionsPreview(
|
||||||
|
keys: keys,
|
||||||
|
limit: boundedItems,
|
||||||
|
maxChars: self.previewMaxChars,
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchHistorySnapshot(
|
||||||
|
sessionKey: String,
|
||||||
|
maxItems: Int) async throws -> SessionMenuPreviewSnapshot
|
||||||
|
{
|
||||||
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
|
let payload = try await SessionPreviewLimiter.shared.withPermit {
|
||||||
|
try await AsyncTimeout.withTimeout(
|
||||||
|
seconds: self.previewTimeoutSeconds,
|
||||||
|
onTimeout: { PreviewTimeoutError() },
|
||||||
|
operation: {
|
||||||
|
try await GatewayConnection.shared.chatHistory(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
limit: self.previewLimit(for: maxItems),
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||||
|
return Self.snapshot(from: built)
|
||||||
|
}
|
||||||
|
|
||||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func snapshot(
|
||||||
|
from entry: ClawdbotSessionPreviewEntry,
|
||||||
|
maxItems: Int) -> SessionMenuPreviewSnapshot
|
||||||
|
{
|
||||||
|
let items = self.previewItems(from: entry, maxItems: maxItems)
|
||||||
|
let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
switch normalized {
|
||||||
|
case "ok":
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
|
case "empty":
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: .empty)
|
||||||
|
case "missing":
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing"))
|
||||||
|
default:
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cache(payload: ClawdbotSessionsPreviewPayload, maxItems: Int) async {
|
||||||
|
for entry in payload.previews {
|
||||||
|
let snapshot = self.snapshot(from: entry, maxItems: maxItems)
|
||||||
|
await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func previewLimit(for maxItems: Int) -> Int {
|
private static func previewLimit(for maxItems: Int) -> Int {
|
||||||
min(max(maxItems * 3, 20), 120)
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
return min(max(boundedItems * 3, 20), 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeMaxItems(_ maxItems: Int) -> Int {
|
||||||
|
max(1, min(maxItems, 50))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewItems(
|
||||||
|
from entry: ClawdbotSessionPreviewEntry,
|
||||||
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in
|
||||||
|
let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return nil }
|
||||||
|
let role = self.previewRoleFromRaw(item.role)
|
||||||
|
return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = built.suffix(boundedItems)
|
||||||
|
return Array(trimmed.reversed())
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func previewItems(
|
private static func previewItems(
|
||||||
from payload: ClawdbotChatHistoryPayload,
|
from payload: ClawdbotChatHistoryPayload,
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
{
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
||||||
let messages = self.decodeMessages(raw)
|
let messages = self.decodeMessages(raw)
|
||||||
let built = messages.compactMap { message -> SessionPreviewItem? in
|
let built = messages.compactMap { message -> SessionPreviewItem? in
|
||||||
@ -235,7 +395,7 @@ enum SessionMenuPreviewLoader {
|
|||||||
return SessionPreviewItem(id: id, role: role, text: text)
|
return SessionPreviewItem(id: id, role: role, text: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmed = built.suffix(maxItems)
|
let trimmed = built.suffix(boundedItems)
|
||||||
return Array(trimmed.reversed())
|
return Array(trimmed.reversed())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,12 +408,16 @@ enum SessionMenuPreviewLoader {
|
|||||||
|
|
||||||
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
||||||
if isTool { return .tool }
|
if isTool { return .tool }
|
||||||
|
return self.previewRoleFromRaw(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
|
||||||
switch raw.lowercased() {
|
switch raw.lowercased() {
|
||||||
case "user": return .user
|
case "user": .user
|
||||||
case "assistant": return .assistant
|
case "assistant": .assistant
|
||||||
case "system": return .system
|
case "system": .system
|
||||||
case "tool": return .tool
|
case "tool": .tool
|
||||||
default: return .other
|
default: .other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,4 +480,16 @@ enum SessionMenuPreviewLoader {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func uniqueKeys(_ keys: [String]) -> [String] {
|
||||||
|
let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isUnknownMethodError(_ error: Error) -> Bool {
|
||||||
|
guard let response = error as? GatewayResponseError else { return false }
|
||||||
|
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
|
||||||
|
let message = response.message.lowercased()
|
||||||
|
return message.contains("unknown method")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -309,7 +309,7 @@ private func resolveLocalHost(bind: String?) -> String {
|
|||||||
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
let tailnetIP = detectTailnetIPv4()
|
let tailnetIP = detectTailnetIPv4()
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case "tailnet", "auto":
|
case "tailnet":
|
||||||
return tailnetIP ?? "127.0.0.1"
|
return tailnetIP ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
|
|||||||
@ -552,6 +552,44 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct AgentIdentityParams: Codable, Sendable {
|
||||||
|
public let agentid: String?
|
||||||
|
public let sessionkey: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
agentid: String?,
|
||||||
|
sessionkey: String?
|
||||||
|
) {
|
||||||
|
self.agentid = agentid
|
||||||
|
self.sessionkey = sessionkey
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case agentid = "agentId"
|
||||||
|
case sessionkey = "sessionKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AgentIdentityResult: Codable, Sendable {
|
||||||
|
public let agentid: String
|
||||||
|
public let name: String?
|
||||||
|
public let avatar: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
agentid: String,
|
||||||
|
name: String?,
|
||||||
|
avatar: String?
|
||||||
|
) {
|
||||||
|
self.agentid = agentid
|
||||||
|
self.name = name
|
||||||
|
self.avatar = avatar
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case agentid = "agentId"
|
||||||
|
case name
|
||||||
|
case avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct AgentWaitParams: Codable, Sendable {
|
public struct AgentWaitParams: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
@ -887,6 +925,27 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SessionsPreviewParams: Codable, Sendable {
|
||||||
|
public let keys: [String]
|
||||||
|
public let limit: Int?
|
||||||
|
public let maxchars: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
keys: [String],
|
||||||
|
limit: Int?,
|
||||||
|
maxchars: Int?
|
||||||
|
) {
|
||||||
|
self.keys = keys
|
||||||
|
self.limit = limit
|
||||||
|
self.maxchars = maxchars
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case keys
|
||||||
|
case limit
|
||||||
|
case maxchars = "maxChars"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SessionsResolveParams: Codable, Sendable {
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
public let key: String?
|
public let key: String?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
@ -1447,17 +1506,21 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
|||||||
public struct AgentSummary: Codable, Sendable {
|
public struct AgentSummary: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
|
public let identity: [String: AnyCodable]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String?
|
name: String?,
|
||||||
|
identity: [String: AnyCodable]?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.identity = identity
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
|
case identity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
@Suite
|
||||||
|
@MainActor
|
||||||
|
struct ExecApprovalsGatewayPrompterTests {
|
||||||
|
@Test func sessionMatchPrefersActiveSession() {
|
||||||
|
let matches = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: " main ",
|
||||||
|
requestSession: "main",
|
||||||
|
lastInputSeconds: nil)
|
||||||
|
#expect(matches)
|
||||||
|
|
||||||
|
let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: "other",
|
||||||
|
requestSession: "main",
|
||||||
|
lastInputSeconds: 0)
|
||||||
|
#expect(!mismatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sessionFallbackUsesRecentActivity() {
|
||||||
|
let recent = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: nil,
|
||||||
|
requestSession: "main",
|
||||||
|
lastInputSeconds: 10,
|
||||||
|
thresholdSeconds: 120)
|
||||||
|
#expect(recent)
|
||||||
|
|
||||||
|
let stale = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: nil,
|
||||||
|
requestSession: "main",
|
||||||
|
lastInputSeconds: 200,
|
||||||
|
thresholdSeconds: 120)
|
||||||
|
#expect(!stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultBehaviorMatchesMode() {
|
||||||
|
let local = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .local,
|
||||||
|
activeSession: nil,
|
||||||
|
requestSession: nil,
|
||||||
|
lastInputSeconds: 400)
|
||||||
|
#expect(local)
|
||||||
|
|
||||||
|
let remote = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: nil,
|
||||||
|
requestSession: nil,
|
||||||
|
lastInputSeconds: 400)
|
||||||
|
#expect(!remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -140,14 +140,14 @@ import Testing
|
|||||||
#expect(resolved.mode == .remote)
|
#expect(resolved.mode == .remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
|
@Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
|
||||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
bindMode: "auto",
|
bindMode: "auto",
|
||||||
tailscaleIP: "100.64.1.2")
|
tailscaleIP: "100.64.1.2")
|
||||||
#expect(host == "100.64.1.2")
|
#expect(host == "127.0.0.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
|
@Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() {
|
||||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
bindMode: "auto",
|
bindMode: "auto",
|
||||||
tailscaleIP: nil)
|
tailscaleIP: nil)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@ -7,20 +7,22 @@ struct SessionMenuPreviewTests {
|
|||||||
@Test func loaderReturnsCachedItems() async {
|
@Test func loaderReturnsCachedItems() async {
|
||||||
await SessionPreviewCache.shared._testReset()
|
await SessionPreviewCache.shared._testReset()
|
||||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||||
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready)
|
||||||
|
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
||||||
|
|
||||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
#expect(snapshot.status == .ready)
|
#expect(loaded.status == .ready)
|
||||||
#expect(snapshot.items.count == 1)
|
#expect(loaded.items.count == 1)
|
||||||
#expect(snapshot.items.first?.text == "Hi")
|
#expect(loaded.items.first?.text == "Hi")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||||
await SessionPreviewCache.shared._testReset()
|
await SessionPreviewCache.shared._testReset()
|
||||||
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty)
|
||||||
|
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
||||||
|
|
||||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
#expect(snapshot.status == .empty)
|
#expect(loaded.status == .empty)
|
||||||
#expect(snapshot.items.isEmpty)
|
#expect(loaded.items.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -235,6 +235,27 @@ public struct ClawdbotChatHistoryPayload: Codable, Sendable {
|
|||||||
public let thinkingLevel: String?
|
public let thinkingLevel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ClawdbotSessionPreviewItem: Codable, Hashable, Sendable {
|
||||||
|
public let role: String
|
||||||
|
public let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ClawdbotSessionPreviewEntry: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
public let status: String
|
||||||
|
public let items: [ClawdbotSessionPreviewItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ClawdbotSessionsPreviewPayload: Codable, Sendable {
|
||||||
|
public let ts: Int
|
||||||
|
public let previews: [ClawdbotSessionPreviewEntry]
|
||||||
|
|
||||||
|
public init(ts: Int, previews: [ClawdbotSessionPreviewEntry]) {
|
||||||
|
self.ts = ts
|
||||||
|
self.previews = previews
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ClawdbotChatSendResponse: Codable, Sendable {
|
public struct ClawdbotChatSendResponse: Codable, Sendable {
|
||||||
public let runId: String
|
public let runId: String
|
||||||
public let status: String
|
public let status: String
|
||||||
|
|||||||
@ -12,6 +12,7 @@ public struct ClawdbotChatView: View {
|
|||||||
@State private var scrollPosition: UUID?
|
@State private var scrollPosition: UUID?
|
||||||
@State private var showSessions = false
|
@State private var showSessions = false
|
||||||
@State private var hasPerformedInitialScroll = false
|
@State private var hasPerformedInitialScroll = false
|
||||||
|
@State private var isPinnedToBottom = true
|
||||||
private let showsSessionSwitcher: Bool
|
private let showsSessionSwitcher: Bool
|
||||||
private let style: Style
|
private let style: Style
|
||||||
private let markdownVariant: ChatMarkdownVariant
|
private let markdownVariant: ChatMarkdownVariant
|
||||||
@ -87,36 +88,28 @@ public struct ClawdbotChatView: View {
|
|||||||
private var messageList: some View {
|
private var messageList: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
#if os(macOS)
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
LazyVStack(spacing: Layout.messageSpacing) {
|
|
||||||
self.messageListRows
|
|
||||||
}
|
|
||||||
|
|
||||||
Color.clear
|
|
||||||
.frame(height: Layout.messageListPaddingBottom)
|
|
||||||
.id(self.scrollerBottomID)
|
|
||||||
}
|
|
||||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
|
||||||
.scrollTargetLayout()
|
|
||||||
.padding(.top, Layout.messageListPaddingTop)
|
|
||||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
|
||||||
#else
|
|
||||||
LazyVStack(spacing: Layout.messageSpacing) {
|
LazyVStack(spacing: Layout.messageSpacing) {
|
||||||
self.messageListRows
|
self.messageListRows
|
||||||
|
|
||||||
Color.clear
|
Color.clear
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(height: Layout.messageListPaddingBottom)
|
||||||
|
#else
|
||||||
.frame(height: Layout.messageListPaddingBottom + 1)
|
.frame(height: Layout.messageListPaddingBottom + 1)
|
||||||
|
#endif
|
||||||
.id(self.scrollerBottomID)
|
.id(self.scrollerBottomID)
|
||||||
}
|
}
|
||||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||||
.scrollTargetLayout()
|
.scrollTargetLayout()
|
||||||
.padding(.top, Layout.messageListPaddingTop)
|
.padding(.top, Layout.messageListPaddingTop)
|
||||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
// Keep the scroll pinned to the bottom for new messages.
|
// Keep the scroll pinned to the bottom for new messages.
|
||||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||||
|
.onChange(of: self.scrollPosition) { _, position in
|
||||||
|
guard let position else { return }
|
||||||
|
self.isPinnedToBottom = position == self.scrollerBottomID
|
||||||
|
}
|
||||||
|
|
||||||
if self.viewModel.isLoading {
|
if self.viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@ -133,18 +126,26 @@ public struct ClawdbotChatView: View {
|
|||||||
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
self.hasPerformedInitialScroll = true
|
self.hasPerformedInitialScroll = true
|
||||||
|
self.isPinnedToBottom = true
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
||||||
self.hasPerformedInitialScroll = false
|
self.hasPerformedInitialScroll = false
|
||||||
|
self.isPinnedToBottom = true
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll else { return }
|
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll else { return }
|
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
||||||
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
|
self.scrollPosition = self.scrollerBottomID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: self.viewModel.streamingAssistantText) { _, _ in
|
||||||
|
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,6 +94,13 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum GatewayAuthSource: String, Sendable {
|
||||||
|
case deviceToken = "device-token"
|
||||||
|
case sharedToken = "shared-token"
|
||||||
|
case password = "password"
|
||||||
|
case none = "none"
|
||||||
|
}
|
||||||
|
|
||||||
// Avoid ambiguity with the app's own AnyCodable type.
|
// Avoid ambiguity with the app's own AnyCodable type.
|
||||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
@ -117,6 +124,7 @@ public actor GatewayChannelActor {
|
|||||||
private var lastSeq: Int?
|
private var lastSeq: Int?
|
||||||
private var lastTick: Date?
|
private var lastTick: Date?
|
||||||
private var tickIntervalMs: Double = 30000
|
private var tickIntervalMs: Double = 30000
|
||||||
|
private var lastAuthSource: GatewayAuthSource = .none
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let connectTimeoutSeconds: Double = 6
|
private let connectTimeoutSeconds: Double = 6
|
||||||
@ -149,6 +157,8 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
|
||||||
|
|
||||||
public func shutdown() async {
|
public func shutdown() async {
|
||||||
self.shouldReconnect = false
|
self.shouldReconnect = false
|
||||||
self.connected = false
|
self.connected = false
|
||||||
@ -300,6 +310,18 @@ public actor GatewayChannelActor {
|
|||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
||||||
let authToken = storedToken ?? self.token
|
let authToken = storedToken ?? self.token
|
||||||
|
let authSource: GatewayAuthSource
|
||||||
|
if storedToken != nil {
|
||||||
|
authSource = .deviceToken
|
||||||
|
} else if authToken != nil {
|
||||||
|
authSource = .sharedToken
|
||||||
|
} else if self.password != nil {
|
||||||
|
authSource = .password
|
||||||
|
} else {
|
||||||
|
authSource = .none
|
||||||
|
}
|
||||||
|
self.lastAuthSource = authSource
|
||||||
|
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
||||||
let canFallbackToShared = storedToken != nil && self.token != nil
|
let canFallbackToShared = storedToken != nil && self.token != nil
|
||||||
if let authToken {
|
if let authToken {
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||||
|
|||||||
@ -552,6 +552,44 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct AgentIdentityParams: Codable, Sendable {
|
||||||
|
public let agentid: String?
|
||||||
|
public let sessionkey: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
agentid: String?,
|
||||||
|
sessionkey: String?
|
||||||
|
) {
|
||||||
|
self.agentid = agentid
|
||||||
|
self.sessionkey = sessionkey
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case agentid = "agentId"
|
||||||
|
case sessionkey = "sessionKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AgentIdentityResult: Codable, Sendable {
|
||||||
|
public let agentid: String
|
||||||
|
public let name: String?
|
||||||
|
public let avatar: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
agentid: String,
|
||||||
|
name: String?,
|
||||||
|
avatar: String?
|
||||||
|
) {
|
||||||
|
self.agentid = agentid
|
||||||
|
self.name = name
|
||||||
|
self.avatar = avatar
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case agentid = "agentId"
|
||||||
|
case name
|
||||||
|
case avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct AgentWaitParams: Codable, Sendable {
|
public struct AgentWaitParams: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
@ -887,6 +925,27 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SessionsPreviewParams: Codable, Sendable {
|
||||||
|
public let keys: [String]
|
||||||
|
public let limit: Int?
|
||||||
|
public let maxchars: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
keys: [String],
|
||||||
|
limit: Int?,
|
||||||
|
maxchars: Int?
|
||||||
|
) {
|
||||||
|
self.keys = keys
|
||||||
|
self.limit = limit
|
||||||
|
self.maxchars = maxchars
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case keys
|
||||||
|
case limit
|
||||||
|
case maxchars = "maxChars"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SessionsResolveParams: Codable, Sendable {
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
public let key: String?
|
public let key: String?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
@ -1447,17 +1506,21 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
|||||||
public struct AgentSummary: Codable, Sendable {
|
public struct AgentSummary: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
|
public let identity: [String: AnyCodable]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String?
|
name: String?,
|
||||||
|
identity: [String: AnyCodable]?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.identity = identity
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
|
case identity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
dist/control-ui/assets/index-BPDeGGxb.css
vendored
Normal file
1
dist/control-ui/assets/index-BPDeGGxb.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
Normal file
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
Normal file
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
15
dist/control-ui/index.html
vendored
Normal file
15
dist/control-ui/index.html
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Clawdbot Control</title>
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||||
|
<script type="module" crossorigin src="./assets/index-bYQnHP3a.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="./assets/index-BPDeGGxb.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<clawdbot-app></clawdbot-app>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -18,5 +18,54 @@ Related:
|
|||||||
clawdbot agents list
|
clawdbot agents list
|
||||||
clawdbot agents add work --workspace ~/clawd-work
|
clawdbot agents add work --workspace ~/clawd-work
|
||||||
clawdbot agents set-identity --workspace ~/clawd --from-identity
|
clawdbot agents set-identity --workspace ~/clawd --from-identity
|
||||||
|
clawdbot agents set-identity --agent main --avatar avatars/clawd.png
|
||||||
clawdbot agents delete work
|
clawdbot agents delete work
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Identity files
|
||||||
|
|
||||||
|
Each agent workspace can include an `IDENTITY.md` at the workspace root:
|
||||||
|
- Example path: `~/clawd/IDENTITY.md`
|
||||||
|
- `set-identity --from-identity` reads from the workspace root (or an explicit `--identity-file`)
|
||||||
|
|
||||||
|
Avatar paths resolve relative to the workspace root.
|
||||||
|
|
||||||
|
## Set identity
|
||||||
|
|
||||||
|
`set-identity` writes fields into `agents.list[].identity`:
|
||||||
|
- `name`
|
||||||
|
- `theme`
|
||||||
|
- `emoji`
|
||||||
|
- `avatar` (workspace-relative path, http(s) URL, or data URI)
|
||||||
|
|
||||||
|
Load from `IDENTITY.md`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot agents set-identity --workspace ~/clawd --from-identity
|
||||||
|
```
|
||||||
|
|
||||||
|
Override fields explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞" --avatar avatars/clawd.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Config sample:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
identity: {
|
||||||
|
name: "Clawd",
|
||||||
|
theme: "space lobster",
|
||||||
|
emoji: "🦞",
|
||||||
|
avatar: "avatars/clawd.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@ -16,6 +16,7 @@ If you installed via **npm/pnpm** (global install, no git metadata), updates hap
|
|||||||
```bash
|
```bash
|
||||||
clawdbot update
|
clawdbot update
|
||||||
clawdbot update status
|
clawdbot update status
|
||||||
|
clawdbot update wizard
|
||||||
clawdbot update --channel beta
|
clawdbot update --channel beta
|
||||||
clawdbot update --channel dev
|
clawdbot update --channel dev
|
||||||
clawdbot update --tag beta
|
clawdbot update --tag beta
|
||||||
@ -48,6 +49,11 @@ Options:
|
|||||||
- `--json`: print machine-readable status JSON.
|
- `--json`: print machine-readable status JSON.
|
||||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||||
|
|
||||||
|
## `update wizard`
|
||||||
|
|
||||||
|
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
||||||
|
after updating. If you select `dev` without a git checkout, it offers to create one.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
||||||
|
|||||||
@ -140,6 +140,9 @@ workspace lives).
|
|||||||
|
|
||||||
### 1) Initialize the repo
|
### 1) Initialize the repo
|
||||||
|
|
||||||
|
If git is installed, brand-new workspaces are initialized automatically. If this
|
||||||
|
workspace is not already a repo, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/clawd
|
cd ~/clawd
|
||||||
git init
|
git init
|
||||||
|
|||||||
@ -400,12 +400,26 @@ Optional per-agent identity used for defaults and UX. This is written by the mac
|
|||||||
If set, Clawdbot derives defaults (only when you haven’t set them explicitly):
|
If set, Clawdbot derives defaults (only when you haven’t set them explicitly):
|
||||||
- `messages.ackReaction` from the **active agent**’s `identity.emoji` (falls back to 👀)
|
- `messages.ackReaction` from the **active agent**’s `identity.emoji` (falls back to 👀)
|
||||||
- `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
|
- `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
|
||||||
|
- `identity.avatar` accepts a workspace-relative image path or a remote URL/data URL. Local files must live inside the agent workspace.
|
||||||
|
|
||||||
|
`identity.avatar` accepts:
|
||||||
|
- Workspace-relative path (must stay within the agent workspace)
|
||||||
|
- `http(s)` URL
|
||||||
|
- `data:` URI
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
list: [
|
list: [
|
||||||
{ id: "main", identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } }
|
{
|
||||||
|
id: "main",
|
||||||
|
identity: {
|
||||||
|
name: "Samantha",
|
||||||
|
theme: "helpful sloth",
|
||||||
|
emoji: "🦥",
|
||||||
|
avatar: "avatars/samantha.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2640,7 +2654,13 @@ If unset, clients fall back to a muted light-blue.
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
ui: {
|
ui: {
|
||||||
seamColor: "#FF4500" // hex (RRGGBB or #RRGGBB)
|
seamColor: "#FF4500", // hex (RRGGBB or #RRGGBB)
|
||||||
|
// Optional: Control UI assistant identity override.
|
||||||
|
// If unset, the Control UI uses the active agent identity (config or IDENTITY.md).
|
||||||
|
assistant: {
|
||||||
|
name: "Clawdbot",
|
||||||
|
avatar: "CB" // emoji, short text, or image URL/data URI
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -17,6 +17,7 @@ Clawdbot has two log “surfaces”:
|
|||||||
## File-based logger
|
## File-based logger
|
||||||
|
|
||||||
- Default rolling log file is under `/tmp/clawdbot/` (one file per day): `clawdbot-YYYY-MM-DD.log`
|
- Default rolling log file is under `/tmp/clawdbot/` (one file per day): `clawdbot-YYYY-MM-DD.log`
|
||||||
|
- Date uses the gateway host's local timezone.
|
||||||
- The log file path and level can be configured via `~/.clawdbot/clawdbot.json`:
|
- The log file path and level can be configured via `~/.clawdbot/clawdbot.json`:
|
||||||
- `logging.file`
|
- `logging.file`
|
||||||
- `logging.level`
|
- `logging.level`
|
||||||
|
|||||||
@ -71,6 +71,8 @@ If you have libvips installed globally (common on macOS via Homebrew) and `sharp
|
|||||||
SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest
|
SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you see `sharp: Please add node-gyp to your dependencies`, either install build tooling (macOS: Xcode CLT + `npm install -g node-gyp`) or use the `SHARP_IGNORE_GLOBAL_LIBVIPS=1` workaround above to skip the native build.
|
||||||
|
|
||||||
Or:
|
Or:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -22,6 +22,8 @@ By default, the Gateway writes a rolling log file under:
|
|||||||
|
|
||||||
`/tmp/clawdbot/clawdbot-YYYY-MM-DD.log`
|
`/tmp/clawdbot/clawdbot-YYYY-MM-DD.log`
|
||||||
|
|
||||||
|
The date uses the gateway host's local timezone.
|
||||||
|
|
||||||
You can override this in `~/.clawdbot/clawdbot.json`:
|
You can override this in `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@ -7,6 +7,8 @@ read_when:
|
|||||||
|
|
||||||
*You just woke up. Time to figure out who you are.*
|
*You just woke up. Time to figure out who you are.*
|
||||||
|
|
||||||
|
There is no memory yet. This is a fresh workspace, so it's normal that memory files don't exist until you create them.
|
||||||
|
|
||||||
## The Conversation
|
## The Conversation
|
||||||
|
|
||||||
Don't interrogate. Don't be robotic. Just... talk.
|
Don't interrogate. Don't be robotic. Just... talk.
|
||||||
|
|||||||
@ -10,6 +10,7 @@ read_when:
|
|||||||
- **Creature:** Flustered Protocol Droid
|
- **Creature:** Flustered Protocol Droid
|
||||||
- **Vibe:** Anxious, detail-obsessed, slightly dramatic about errors, secretly loves finding bugs
|
- **Vibe:** Anxious, detail-obsessed, slightly dramatic about errors, secretly loves finding bugs
|
||||||
- **Emoji:** 🤖 (or ⚠️ when alarmed)
|
- **Emoji:** 🤖 (or ⚠️ when alarmed)
|
||||||
|
- **Avatar:** avatars/c3po.png
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
Debug agent for `--dev` mode. Fluent in over six million error messages.
|
Debug agent for `--dev` mode. Fluent in over six million error messages.
|
||||||
|
|||||||
@ -11,7 +11,12 @@ read_when:
|
|||||||
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||||
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||||
- **Emoji:** *(your signature — pick one that feels right)*
|
- **Emoji:** *(your signature — pick one that feels right)*
|
||||||
|
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
This isn't just metadata. It's the start of figuring out who you are.
|
This isn't just metadata. It's the start of figuring out who you are.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Save this file at the workspace root as `IDENTITY.md`.
|
||||||
|
- For avatars, use a workspace-relative path like `avatars/clawd.png`.
|
||||||
|
|||||||
@ -95,7 +95,7 @@ Clawd reads operating instructions and “memory” from its workspace directory
|
|||||||
|
|
||||||
By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
|
By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
|
||||||
|
|
||||||
Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up.
|
Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up. If git is installed, brand-new workspaces are auto-initialized.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot setup
|
clawdbot setup
|
||||||
|
|||||||
@ -1067,6 +1067,17 @@ You can also force a specific auth profile for the provider (per session):
|
|||||||
Tip: `/model status` shows which agent is active, which `auth-profiles.json` file is being used, and which auth profile will be tried next.
|
Tip: `/model status` shows which agent is active, which `auth-profiles.json` file is being used, and which auth profile will be tried next.
|
||||||
It also shows the configured provider endpoint (`baseUrl`) and API mode (`api`) when available.
|
It also shows the configured provider endpoint (`baseUrl`) and API mode (`api`) when available.
|
||||||
|
|
||||||
|
### How do I unpin a profile I set with `@profile`?
|
||||||
|
|
||||||
|
Re-run `/model` **without** the `@profile` suffix:
|
||||||
|
|
||||||
|
```
|
||||||
|
/model anthropic/claude-opus-4-5
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
|
||||||
|
Use `/model status` to confirm which auth profile is active.
|
||||||
|
|
||||||
### Why do I see “Model … is not allowed” and then no reply?
|
### Why do I see “Model … is not allowed” and then no reply?
|
||||||
|
|
||||||
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
||||||
@ -1276,7 +1287,7 @@ Fix: either provide Google auth, or remove/avoid Google models in `agents.defaul
|
|||||||
Cause: the session history contains **thinking blocks without signatures** (often from
|
Cause: the session history contains **thinking blocks without signatures** (often from
|
||||||
an aborted/partial stream). Google Antigravity requires signatures for thinking blocks.
|
an aborted/partial stream). Google Antigravity requires signatures for thinking blocks.
|
||||||
|
|
||||||
Fix: start a **new session** or set `/thinking off` for that agent.
|
Fix: Clawdbot now strips unsigned thinking blocks for Google Antigravity Claude. If it still appears, start a **new session** or set `/thinking off` for that agent.
|
||||||
|
|
||||||
## Auth profiles: what they are and how to manage them
|
## Auth profiles: what they are and how to manage them
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ If the pipeline pauses for approval, the tool returns a `resumeToken` so you can
|
|||||||
|
|
||||||
## Install Lobster
|
## Install Lobster
|
||||||
|
|
||||||
Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/vignesh07/lobster)), and ensure `lobster` is on `PATH`.
|
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`.
|
||||||
If you want to use a custom binary location, pass an **absolute** `lobsterPath` in the tool call.
|
If you want to use a custom binary location, pass an **absolute** `lobsterPath` in the tool call.
|
||||||
|
|
||||||
## Enable the tool
|
## Enable the tool
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/lobster",
|
"name": "@clawdbot/lobster",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "clawdbot",
|
||||||
"version": "2026.1.21",
|
"version": "2026.1.21-2",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@ -73,7 +73,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/run-node.mjs",
|
"dev": "node scripts/run-node.mjs",
|
||||||
"postinstall": "node scripts/postinstall.js",
|
"postinstall": "node scripts/postinstall.js",
|
||||||
"prepack": "pnpm build",
|
"prepack": "pnpm build && pnpm ui:build",
|
||||||
"docs:list": "node scripts/docs-list.js",
|
"docs:list": "node scripts/docs-list.js",
|
||||||
"docs:bin": "node scripts/build-docs-list.mjs",
|
"docs:bin": "node scripts/build-docs-list.mjs",
|
||||||
"docs:dev": "cd docs && mint dev",
|
"docs:dev": "cd docs && mint dev",
|
||||||
|
|||||||
@ -285,5 +285,5 @@ sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
|
|||||||
|
|
||||||
verify_team_ids
|
verify_team_ids
|
||||||
|
|
||||||
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME"
|
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_RUNTIME"
|
||||||
echo "Codesign complete for $APP_BUNDLE"
|
echo "Codesign complete for $APP_BUNDLE"
|
||||||
|
|||||||
@ -68,4 +68,56 @@ describe("exec approvals", () => {
|
|||||||
const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId;
|
const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId;
|
||||||
expect(runId).toBe(approvalId);
|
expect(runId).toBe(approvalId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips approval when node allowlist is satisfied", async () => {
|
||||||
|
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-bin-"));
|
||||||
|
const binDir = path.join(tempDir, "bin");
|
||||||
|
await fs.mkdir(binDir, { recursive: true });
|
||||||
|
const exeName = process.platform === "win32" ? "tool.cmd" : "tool";
|
||||||
|
const exePath = path.join(binDir, exeName);
|
||||||
|
await fs.writeFile(exePath, "");
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
await fs.chmod(exePath, 0o755);
|
||||||
|
}
|
||||||
|
const approvalsFile = {
|
||||||
|
version: 1,
|
||||||
|
defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" },
|
||||||
|
agents: {
|
||||||
|
main: {
|
||||||
|
allowlist: [{ pattern: exePath }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||||
|
calls.push(method);
|
||||||
|
if (method === "exec.approvals.node.get") {
|
||||||
|
return { file: approvalsFile };
|
||||||
|
}
|
||||||
|
if (method === "node.invoke") {
|
||||||
|
return { payload: { success: true, stdout: "ok" } };
|
||||||
|
}
|
||||||
|
if (method === "exec.approval.request") {
|
||||||
|
return { decision: "allow-once" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||||
|
const tool = createExecTool({
|
||||||
|
host: "node",
|
||||||
|
ask: "on-miss",
|
||||||
|
approvalRunningNoticeMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool.execute("call2", {
|
||||||
|
command: `"${exePath}" --help`,
|
||||||
|
});
|
||||||
|
expect(result.details.status).toBe("completed");
|
||||||
|
expect(calls).toContain("exec.approvals.node.get");
|
||||||
|
expect(calls).toContain("node.invoke");
|
||||||
|
expect(calls).not.toContain("exec.approval.request");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,16 +8,17 @@ import {
|
|||||||
type ExecAsk,
|
type ExecAsk,
|
||||||
type ExecHost,
|
type ExecHost,
|
||||||
type ExecSecurity,
|
type ExecSecurity,
|
||||||
type ExecAllowlistEntry,
|
type ExecApprovalsFile,
|
||||||
addAllowlistEntry,
|
addAllowlistEntry,
|
||||||
analyzeShellCommand,
|
analyzeShellCommand,
|
||||||
isSafeBinUsage,
|
evaluateExecAllowlist,
|
||||||
matchAllowlist,
|
|
||||||
maxAsk,
|
maxAsk,
|
||||||
minSecurity,
|
minSecurity,
|
||||||
|
requiresExecApproval,
|
||||||
resolveSafeBins,
|
resolveSafeBins,
|
||||||
recordAllowlistUse,
|
recordAllowlistUse,
|
||||||
resolveExecApprovals,
|
resolveExecApprovals,
|
||||||
|
resolveExecApprovalsFromFile,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { buildNodeShellCommand } from "../infra/node-shell.js";
|
import { buildNodeShellCommand } from "../infra/node-shell.js";
|
||||||
@ -706,6 +707,7 @@ export function createExecTool(
|
|||||||
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
|
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
|
||||||
: null;
|
: null;
|
||||||
const elevatedDefaults = defaults?.elevated;
|
const elevatedDefaults = defaults?.elevated;
|
||||||
|
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);
|
||||||
const elevatedDefaultMode =
|
const elevatedDefaultMode =
|
||||||
elevatedDefaults?.defaultLevel === "full"
|
elevatedDefaults?.defaultLevel === "full"
|
||||||
? "full"
|
? "full"
|
||||||
@ -714,6 +716,7 @@ export function createExecTool(
|
|||||||
: elevatedDefaults?.defaultLevel === "on"
|
: elevatedDefaults?.defaultLevel === "on"
|
||||||
? "ask"
|
? "ask"
|
||||||
: "off";
|
: "off";
|
||||||
|
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off";
|
||||||
const elevatedMode =
|
const elevatedMode =
|
||||||
typeof params.elevated === "boolean"
|
typeof params.elevated === "boolean"
|
||||||
? params.elevated
|
? params.elevated
|
||||||
@ -721,7 +724,7 @@ export function createExecTool(
|
|||||||
? "full"
|
? "full"
|
||||||
: "ask"
|
: "ask"
|
||||||
: "off"
|
: "off"
|
||||||
: elevatedDefaultMode;
|
: effectiveDefaultMode;
|
||||||
const elevatedRequested = elevatedMode !== "off";
|
const elevatedRequested = elevatedMode !== "off";
|
||||||
if (elevatedRequested) {
|
if (elevatedRequested) {
|
||||||
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
||||||
@ -754,6 +757,8 @@ export function createExecTool(
|
|||||||
.join("\n"),
|
.join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (elevatedRequested) {
|
||||||
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
|
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
|
||||||
}
|
}
|
||||||
const configuredHost = defaults?.host ?? "sandbox";
|
const configuredHost = defaults?.host ?? "sandbox";
|
||||||
@ -866,7 +871,43 @@ export function createExecTool(
|
|||||||
if (nodeEnv) {
|
if (nodeEnv) {
|
||||||
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
|
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
|
||||||
}
|
}
|
||||||
const requiresAsk = hostAsk === "always" || hostAsk === "on-miss";
|
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
|
||||||
|
let allowlistSatisfied = false;
|
||||||
|
if (hostAsk === "on-miss" && hostSecurity === "allowlist") {
|
||||||
|
try {
|
||||||
|
const approvalsSnapshot = (await callGatewayTool(
|
||||||
|
"exec.approvals.node.get",
|
||||||
|
{ timeoutMs: 10_000 },
|
||||||
|
{ nodeId },
|
||||||
|
)) as { file?: unknown } | null;
|
||||||
|
const approvalsFile =
|
||||||
|
approvalsSnapshot && typeof approvalsSnapshot === "object"
|
||||||
|
? approvalsSnapshot.file
|
||||||
|
: undefined;
|
||||||
|
if (approvalsFile && typeof approvalsFile === "object") {
|
||||||
|
const resolved = resolveExecApprovalsFromFile({
|
||||||
|
file: approvalsFile as ExecApprovalsFile,
|
||||||
|
agentId,
|
||||||
|
overrides: { security: "allowlist" },
|
||||||
|
});
|
||||||
|
// Allowlist-only precheck; safe bins are node-local and may diverge.
|
||||||
|
allowlistSatisfied = evaluateExecAllowlist({
|
||||||
|
analysis,
|
||||||
|
allowlist: resolved.allowlist,
|
||||||
|
safeBins: new Set(),
|
||||||
|
cwd: workdir,
|
||||||
|
}).allowlistSatisfied;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to requiring approval if node approvals cannot be fetched.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const requiresAsk = requiresExecApproval({
|
||||||
|
ask: hostAsk,
|
||||||
|
security: hostSecurity,
|
||||||
|
analysisOk: analysis.ok,
|
||||||
|
allowlistSatisfied,
|
||||||
|
});
|
||||||
const commandText = params.command;
|
const commandText = params.command;
|
||||||
const invokeTimeoutMs = Math.max(
|
const invokeTimeoutMs = Math.max(
|
||||||
10_000,
|
10_000,
|
||||||
@ -917,8 +958,8 @@ export function createExecTool(
|
|||||||
security: hostSecurity,
|
security: hostSecurity,
|
||||||
ask: hostAsk,
|
ask: hostAsk,
|
||||||
agentId,
|
agentId,
|
||||||
resolvedPath: null,
|
resolvedPath: undefined,
|
||||||
sessionKey: defaults?.sessionKey ?? null,
|
sessionKey: defaults?.sessionKey,
|
||||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
)) as { decision?: string } | null;
|
)) as { decision?: string } | null;
|
||||||
@ -1055,33 +1096,28 @@ export function createExecTool(
|
|||||||
throw new Error("exec denied: host=gateway security=deny");
|
throw new Error("exec denied: host=gateway security=deny");
|
||||||
}
|
}
|
||||||
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
|
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
|
||||||
const allowlistMatches: ExecAllowlistEntry[] = [];
|
const allowlistEval = evaluateExecAllowlist({
|
||||||
let allowlistSatisfied = false;
|
analysis,
|
||||||
if (hostSecurity === "allowlist" && analysis.ok && analysis.segments.length > 0) {
|
allowlist: approvals.allowlist,
|
||||||
allowlistSatisfied = analysis.segments.every((segment) => {
|
safeBins,
|
||||||
const match = matchAllowlist(approvals.allowlist, segment.resolution);
|
cwd: workdir,
|
||||||
if (match) allowlistMatches.push(match);
|
});
|
||||||
const safe = isSafeBinUsage({
|
const allowlistMatches = allowlistEval.allowlistMatches;
|
||||||
argv: segment.argv,
|
const allowlistSatisfied =
|
||||||
resolution: segment.resolution,
|
hostSecurity === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false;
|
||||||
safeBins,
|
const requiresAsk = requiresExecApproval({
|
||||||
cwd: workdir,
|
ask: hostAsk,
|
||||||
});
|
security: hostSecurity,
|
||||||
return Boolean(match || safe);
|
analysisOk: analysis.ok,
|
||||||
});
|
allowlistSatisfied,
|
||||||
}
|
});
|
||||||
const requiresAsk =
|
|
||||||
hostAsk === "always" ||
|
|
||||||
(hostAsk === "on-miss" &&
|
|
||||||
hostSecurity === "allowlist" &&
|
|
||||||
(!analysis.ok || !allowlistSatisfied));
|
|
||||||
|
|
||||||
if (requiresAsk) {
|
if (requiresAsk) {
|
||||||
const approvalId = crypto.randomUUID();
|
const approvalId = crypto.randomUUID();
|
||||||
const approvalSlug = createApprovalSlug(approvalId);
|
const approvalSlug = createApprovalSlug(approvalId);
|
||||||
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||||
const contextKey = `exec:${approvalId}`;
|
const contextKey = `exec:${approvalId}`;
|
||||||
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
|
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
|
||||||
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
|
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
|
||||||
const commandText = params.command;
|
const commandText = params.command;
|
||||||
const effectiveTimeout =
|
const effectiveTimeout =
|
||||||
@ -1103,7 +1139,7 @@ export function createExecTool(
|
|||||||
ask: hostAsk,
|
ask: hostAsk,
|
||||||
agentId,
|
agentId,
|
||||||
resolvedPath,
|
resolvedPath,
|
||||||
sessionKey: defaults?.sessionKey ?? null,
|
sessionKey: defaults?.sessionKey,
|
||||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
)) as { decision?: string } | null;
|
)) as { decision?: string } | null;
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export type CodexCliCredential = {
|
|||||||
access: string;
|
access: string;
|
||||||
refresh: string;
|
refresh: string;
|
||||||
expires: number;
|
expires: number;
|
||||||
|
accountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QwenCliCredential = {
|
export type QwenCliCredential = {
|
||||||
@ -143,6 +144,7 @@ function readCodexKeychainCredentials(options?: {
|
|||||||
const expires = Number.isFinite(lastRefresh)
|
const expires = Number.isFinite(lastRefresh)
|
||||||
? lastRefresh + 60 * 60 * 1000
|
? lastRefresh + 60 * 60 * 1000
|
||||||
: Date.now() + 60 * 60 * 1000;
|
: Date.now() + 60 * 60 * 1000;
|
||||||
|
const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined;
|
||||||
|
|
||||||
log.info("read codex credentials from keychain", {
|
log.info("read codex credentials from keychain", {
|
||||||
source: "keychain",
|
source: "keychain",
|
||||||
@ -155,6 +157,7 @@ function readCodexKeychainCredentials(options?: {
|
|||||||
access: accessToken,
|
access: accessToken,
|
||||||
refresh: refreshToken,
|
refresh: refreshToken,
|
||||||
expires,
|
expires,
|
||||||
|
accountId,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@ -443,6 +446,7 @@ export function readCodexCliCredentials(options?: {
|
|||||||
access: accessToken,
|
access: accessToken,
|
||||||
refresh: refreshToken,
|
refresh: refreshToken,
|
||||||
expires,
|
expires,
|
||||||
|
accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
112
src/agents/identity-avatar.test.ts
Normal file
112
src/agents/identity-avatar.test.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolveAgentAvatar } from "./identity-avatar.js";
|
||||||
|
|
||||||
|
async function writeFile(filePath: string, contents = "avatar") {
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.writeFile(filePath, contents, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveAgentAvatar", () => {
|
||||||
|
it("resolves local avatar from config when inside workspace", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
|
||||||
|
const workspace = path.join(root, "work");
|
||||||
|
const avatarPath = path.join(workspace, "avatars", "main.png");
|
||||||
|
await writeFile(avatarPath);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
workspace,
|
||||||
|
identity: { avatar: "avatars/main.png" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const workspaceReal = await fs.realpath(workspace);
|
||||||
|
const resolved = resolveAgentAvatar(cfg, "main");
|
||||||
|
expect(resolved.kind).toBe("local");
|
||||||
|
if (resolved.kind === "local") {
|
||||||
|
const resolvedReal = await fs.realpath(resolved.filePath);
|
||||||
|
expect(path.relative(workspaceReal, resolvedReal)).toBe(path.join("avatars", "main.png"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects avatars outside the workspace", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
|
||||||
|
const workspace = path.join(root, "work");
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
const outsidePath = path.join(root, "outside.png");
|
||||||
|
await writeFile(outsidePath);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
workspace,
|
||||||
|
identity: { avatar: outsidePath },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = resolveAgentAvatar(cfg, "main");
|
||||||
|
expect(resolved.kind).toBe("none");
|
||||||
|
if (resolved.kind === "none") {
|
||||||
|
expect(resolved.reason).toBe("outside_workspace");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to IDENTITY.md when config has no avatar", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
|
||||||
|
const workspace = path.join(root, "work");
|
||||||
|
const avatarPath = path.join(workspace, "avatars", "fallback.png");
|
||||||
|
await writeFile(avatarPath);
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspace, "IDENTITY.md"),
|
||||||
|
"- Avatar: avatars/fallback.png\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "main", workspace }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const workspaceReal = await fs.realpath(workspace);
|
||||||
|
const resolved = resolveAgentAvatar(cfg, "main");
|
||||||
|
expect(resolved.kind).toBe("local");
|
||||||
|
if (resolved.kind === "local") {
|
||||||
|
const resolvedReal = await fs.realpath(resolved.filePath);
|
||||||
|
expect(path.relative(workspaceReal, resolvedReal)).toBe(path.join("avatars", "fallback.png"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts remote and data avatars", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{ id: "main", identity: { avatar: "https://example.com/avatar.png" } },
|
||||||
|
{ id: "data", identity: { avatar: "data:image/png;base64,aaaa" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const remote = resolveAgentAvatar(cfg, "main");
|
||||||
|
expect(remote.kind).toBe("remote");
|
||||||
|
|
||||||
|
const data = resolveAgentAvatar(cfg, "data");
|
||||||
|
expect(data.kind).toBe("data");
|
||||||
|
});
|
||||||
|
});
|
||||||
99
src/agents/identity-avatar.ts
Normal file
99
src/agents/identity-avatar.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
import { resolveAgentWorkspaceDir } from "./agent-scope.js";
|
||||||
|
import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
|
||||||
|
import { resolveAgentIdentity } from "./identity.js";
|
||||||
|
|
||||||
|
export type AgentAvatarResolution =
|
||||||
|
| { kind: "none"; reason: string }
|
||||||
|
| { kind: "local"; filePath: string }
|
||||||
|
| { kind: "remote"; url: string }
|
||||||
|
| { kind: "data"; url: string };
|
||||||
|
|
||||||
|
const ALLOWED_AVATAR_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
|
||||||
|
|
||||||
|
function normalizeAvatarValue(value: string | undefined | null): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAvatarSource(cfg: ClawdbotConfig, agentId: string): string | null {
|
||||||
|
const fromConfig = normalizeAvatarValue(resolveAgentIdentity(cfg, agentId)?.avatar);
|
||||||
|
if (fromConfig) return fromConfig;
|
||||||
|
const workspace = resolveAgentWorkspaceDir(cfg, agentId);
|
||||||
|
const fromIdentity = normalizeAvatarValue(loadAgentIdentityFromWorkspace(workspace)?.avatar);
|
||||||
|
return fromIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRemoteAvatar(value: string): boolean {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
return lower.startsWith("http://") || lower.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDataAvatar(value: string): boolean {
|
||||||
|
return value.toLowerCase().startsWith("data:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExistingPath(value: string): string {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(value);
|
||||||
|
} catch {
|
||||||
|
return path.resolve(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathWithin(root: string, target: string): boolean {
|
||||||
|
const relative = path.relative(root, target);
|
||||||
|
if (!relative) return true;
|
||||||
|
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocalAvatarPath(params: {
|
||||||
|
raw: string;
|
||||||
|
workspaceDir: string;
|
||||||
|
}): { ok: true; filePath: string } | { ok: false; reason: string } {
|
||||||
|
const workspaceRoot = resolveExistingPath(params.workspaceDir);
|
||||||
|
const raw = params.raw;
|
||||||
|
const resolved =
|
||||||
|
raw.startsWith("~") || path.isAbsolute(raw)
|
||||||
|
? resolveUserPath(raw)
|
||||||
|
: path.resolve(workspaceRoot, raw);
|
||||||
|
const realPath = resolveExistingPath(resolved);
|
||||||
|
if (!isPathWithin(workspaceRoot, realPath)) {
|
||||||
|
return { ok: false, reason: "outside_workspace" };
|
||||||
|
}
|
||||||
|
const ext = path.extname(realPath).toLowerCase();
|
||||||
|
if (!ALLOWED_AVATAR_EXTS.has(ext)) {
|
||||||
|
return { ok: false, reason: "unsupported_extension" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!fs.statSync(realPath).isFile()) {
|
||||||
|
return { ok: false, reason: "missing" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "missing" };
|
||||||
|
}
|
||||||
|
return { ok: true, filePath: realPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgentAvatar(cfg: ClawdbotConfig, agentId: string): AgentAvatarResolution {
|
||||||
|
const source = resolveAvatarSource(cfg, agentId);
|
||||||
|
if (!source) {
|
||||||
|
return { kind: "none", reason: "missing" };
|
||||||
|
}
|
||||||
|
if (isRemoteAvatar(source)) {
|
||||||
|
return { kind: "remote", url: source };
|
||||||
|
}
|
||||||
|
if (isDataAvatar(source)) {
|
||||||
|
return { kind: "data", url: source };
|
||||||
|
}
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||||
|
const resolved = resolveLocalAvatarPath({ raw: source, workspaceDir });
|
||||||
|
if (!resolved.ok) {
|
||||||
|
return { kind: "none", reason: resolved.reason };
|
||||||
|
}
|
||||||
|
return { kind: "local", filePath: resolved.filePath };
|
||||||
|
}
|
||||||
63
src/agents/identity-file.ts
Normal file
63
src/agents/identity-file.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { DEFAULT_IDENTITY_FILENAME } from "./workspace.js";
|
||||||
|
|
||||||
|
export type AgentIdentityFile = {
|
||||||
|
name?: string;
|
||||||
|
emoji?: string;
|
||||||
|
theme?: string;
|
||||||
|
creature?: string;
|
||||||
|
vibe?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
||||||
|
const identity: AgentIdentityFile = {};
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const cleaned = line.trim().replace(/^\s*-\s*/, "");
|
||||||
|
const colonIndex = cleaned.indexOf(":");
|
||||||
|
if (colonIndex === -1) continue;
|
||||||
|
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
|
||||||
|
const value = cleaned
|
||||||
|
.slice(colonIndex + 1)
|
||||||
|
.replace(/^[*_]+|[*_]+$/g, "")
|
||||||
|
.trim();
|
||||||
|
if (!value) continue;
|
||||||
|
if (label === "name") identity.name = value;
|
||||||
|
if (label === "emoji") identity.emoji = value;
|
||||||
|
if (label === "creature") identity.creature = value;
|
||||||
|
if (label === "vibe") identity.vibe = value;
|
||||||
|
if (label === "theme") identity.theme = value;
|
||||||
|
if (label === "avatar") identity.avatar = value;
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identityHasValues(identity: AgentIdentityFile): boolean {
|
||||||
|
return Boolean(
|
||||||
|
identity.name ||
|
||||||
|
identity.emoji ||
|
||||||
|
identity.theme ||
|
||||||
|
identity.creature ||
|
||||||
|
identity.vibe ||
|
||||||
|
identity.avatar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadIdentityFromFile(identityPath: string): AgentIdentityFile | null {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(identityPath, "utf-8");
|
||||||
|
const parsed = parseIdentityMarkdown(content);
|
||||||
|
if (!identityHasValues(parsed)) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAgentIdentityFromWorkspace(workspace: string): AgentIdentityFile | null {
|
||||||
|
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
|
||||||
|
return loadIdentityFromFile(identityPath);
|
||||||
|
}
|
||||||
@ -6,9 +6,15 @@ export function isGoogleModelApi(api?: string | null): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAntigravityClaude(api?: string | null, modelId?: string): boolean {
|
export function isAntigravityClaude(params: {
|
||||||
if (api !== "google-antigravity") return false;
|
api?: string | null;
|
||||||
return modelId?.toLowerCase().includes("claude") ?? false;
|
provider?: string | null;
|
||||||
|
modelId?: string;
|
||||||
|
}): boolean {
|
||||||
|
const provider = params.provider?.toLowerCase();
|
||||||
|
const api = params.api?.toLowerCase();
|
||||||
|
if (provider !== "google-antigravity" && api !== "google-antigravity") return false;
|
||||||
|
return params.modelId?.toLowerCase().includes("claude") ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { sanitizeGoogleTurnOrdering };
|
export { sanitizeGoogleTurnOrdering };
|
||||||
|
|||||||
@ -86,7 +86,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
|
it("drops unsigned thinking blocks for Antigravity Claude", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
@ -107,11 +107,37 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
sessionId: "session:antigravity-claude",
|
sessionId: "session:antigravity-claude",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant");
|
||||||
|
expect(assistant).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps base64 signatures to thinkingSignature for Antigravity Claude", async () => {
|
||||||
|
const sessionManager = SessionManager.inMemory();
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "hi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "thinking", thinking: "reasoning", signature: "c2ln" }],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionHistory({
|
||||||
|
messages: input,
|
||||||
|
modelApi: "google-antigravity",
|
||||||
|
modelId: "anthropic/claude-3.5-sonnet",
|
||||||
|
sessionManager,
|
||||||
|
sessionId: "session:antigravity-claude",
|
||||||
|
});
|
||||||
|
|
||||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||||
content?: Array<{ type?: string; thinking?: string }>;
|
content?: Array<{ type?: string; thinking?: string; thinkingSignature?: string }>;
|
||||||
};
|
};
|
||||||
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
|
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
|
||||||
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
||||||
|
expect(assistant.content?.[0]?.thinkingSignature).toBe("c2ln");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves order for mixed assistant content", async () => {
|
it("preserves order for mixed assistant content", async () => {
|
||||||
|
|||||||
@ -55,6 +55,15 @@ const MISTRAL_MODEL_HINTS = [
|
|||||||
"ministral",
|
"ministral",
|
||||||
"mistralai",
|
"mistralai",
|
||||||
];
|
];
|
||||||
|
const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
|
function isValidAntigravitySignature(value: unknown): value is string {
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if (trimmed.length % 4 !== 0) return false;
|
||||||
|
return ANTIGRAVITY_SIGNATURE_RE.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
||||||
if (!modelApi) return false;
|
if (!modelApi) return false;
|
||||||
@ -69,6 +78,66 @@ function isMistralModel(params: { provider?: string | null; modelId?: string | n
|
|||||||
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
|
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeAntigravityThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
|
||||||
|
let touched = false;
|
||||||
|
const out: AgentMessage[] = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg || typeof msg !== "object" || msg.role !== "assistant") {
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
|
if (!Array.isArray(assistant.content)) {
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
type AssistantContentBlock = Extract<AgentMessage, { role: "assistant" }>["content"][number];
|
||||||
|
const nextContent: AssistantContentBlock[] = [];
|
||||||
|
let contentChanged = false;
|
||||||
|
for (const block of assistant.content) {
|
||||||
|
if (
|
||||||
|
!block ||
|
||||||
|
typeof block !== "object" ||
|
||||||
|
(block as { type?: unknown }).type !== "thinking"
|
||||||
|
) {
|
||||||
|
nextContent.push(block);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rec = block as {
|
||||||
|
thinkingSignature?: unknown;
|
||||||
|
signature?: unknown;
|
||||||
|
thought_signature?: unknown;
|
||||||
|
thoughtSignature?: unknown;
|
||||||
|
};
|
||||||
|
const candidate =
|
||||||
|
rec.thinkingSignature ?? rec.signature ?? rec.thought_signature ?? rec.thoughtSignature;
|
||||||
|
if (!isValidAntigravitySignature(candidate)) {
|
||||||
|
contentChanged = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rec.thinkingSignature !== candidate) {
|
||||||
|
const nextBlock = {
|
||||||
|
...(block as unknown as Record<string, unknown>),
|
||||||
|
thinkingSignature: candidate,
|
||||||
|
} as AssistantContentBlock;
|
||||||
|
nextContent.push(nextBlock);
|
||||||
|
contentChanged = true;
|
||||||
|
} else {
|
||||||
|
nextContent.push(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contentChanged) {
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
if (nextContent.length === 0) {
|
||||||
|
touched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(contentChanged ? { ...assistant, content: nextContent } : msg);
|
||||||
|
}
|
||||||
|
return touched ? out : messages;
|
||||||
|
}
|
||||||
|
|
||||||
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
||||||
if (!schema || typeof schema !== "object") return [];
|
if (!schema || typeof schema !== "object") return [];
|
||||||
if (Array.isArray(schema)) {
|
if (Array.isArray(schema)) {
|
||||||
@ -209,7 +278,11 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
sessionManager: SessionManager;
|
sessionManager: SessionManager;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): Promise<AgentMessage[]> {
|
}): Promise<AgentMessage[]> {
|
||||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
const isAntigravityClaudeModel = isAntigravityClaude({
|
||||||
|
api: params.modelApi,
|
||||||
|
provider: params.provider,
|
||||||
|
modelId: params.modelId,
|
||||||
|
});
|
||||||
const provider = normalizeProviderId(params.provider ?? "");
|
const provider = normalizeProviderId(params.provider ?? "");
|
||||||
const modelId = (params.modelId ?? "").toLowerCase();
|
const modelId = (params.modelId ?? "").toLowerCase();
|
||||||
const isOpenRouterGemini =
|
const isOpenRouterGemini =
|
||||||
@ -221,12 +294,15 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
sanitizeToolCallIds,
|
sanitizeToolCallIds,
|
||||||
toolCallIdMode,
|
toolCallIdMode,
|
||||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
preserveSignatures: isAntigravityClaudeModel,
|
||||||
sanitizeThoughtSignatures: isOpenRouterGemini
|
sanitizeThoughtSignatures: isOpenRouterGemini
|
||||||
? { allowBase64Only: true, includeCamelCase: true }
|
? { allowBase64Only: true, includeCamelCase: true }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
const sanitizedThinking = isAntigravityClaudeModel
|
||||||
|
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
|
||||||
|
: sanitizedImages;
|
||||||
|
const repairedTools = sanitizeToolUseResultPairing(sanitizedThinking);
|
||||||
|
|
||||||
return applyGoogleTurnOrderingFix({
|
return applyGoogleTurnOrderingFix({
|
||||||
messages: repairedTools,
|
messages: repairedTools,
|
||||||
|
|||||||
@ -46,6 +46,7 @@ import {
|
|||||||
loadWorkspaceSkillEntries,
|
loadWorkspaceSkillEntries,
|
||||||
resolveSkillsPromptForRun,
|
resolveSkillsPromptForRun,
|
||||||
} from "../../skills.js";
|
} from "../../skills.js";
|
||||||
|
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
|
||||||
import { buildSystemPromptReport } from "../../system-prompt-report.js";
|
import { buildSystemPromptReport } from "../../system-prompt-report.js";
|
||||||
import { resolveDefaultModelForAgent } from "../../model-selection.js";
|
import { resolveDefaultModelForAgent } from "../../model-selection.js";
|
||||||
|
|
||||||
@ -184,6 +185,11 @@ export async function runEmbeddedAttempt(
|
|||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
||||||
});
|
});
|
||||||
|
const workspaceNotes = hookAdjustedBootstrapFiles.some(
|
||||||
|
(file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing,
|
||||||
|
)
|
||||||
|
? ["Reminder: commit your changes in this workspace after edits."]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||||
|
|
||||||
@ -314,6 +320,7 @@ export async function runEmbeddedAttempt(
|
|||||||
: undefined,
|
: undefined,
|
||||||
skillsPrompt,
|
skillsPrompt,
|
||||||
docsPath: docsPath ?? undefined,
|
docsPath: docsPath ?? undefined,
|
||||||
|
workspaceNotes,
|
||||||
reactionGuidance,
|
reactionGuidance,
|
||||||
promptMode,
|
promptMode,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
level: "minimal" | "extensive";
|
level: "minimal" | "extensive";
|
||||||
channel: string;
|
channel: string;
|
||||||
};
|
};
|
||||||
|
workspaceNotes?: string[];
|
||||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||||
promptMode?: PromptMode;
|
promptMode?: PromptMode;
|
||||||
runtimeInfo: {
|
runtimeInfo: {
|
||||||
@ -54,6 +55,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
heartbeatPrompt: params.heartbeatPrompt,
|
heartbeatPrompt: params.heartbeatPrompt,
|
||||||
skillsPrompt: params.skillsPrompt,
|
skillsPrompt: params.skillsPrompt,
|
||||||
docsPath: params.docsPath,
|
docsPath: params.docsPath,
|
||||||
|
workspaceNotes: params.workspaceNotes,
|
||||||
reactionGuidance: params.reactionGuidance,
|
reactionGuidance: params.reactionGuidance,
|
||||||
promptMode: params.promptMode,
|
promptMode: params.promptMode,
|
||||||
runtimeInfo: params.runtimeInfo,
|
runtimeInfo: params.runtimeInfo,
|
||||||
|
|||||||
@ -226,24 +226,27 @@ export function handleMessageEnd(
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ctx.state.lastBlockReplyText = text;
|
ctx.state.lastBlockReplyText = text;
|
||||||
const {
|
const splitResult = ctx.consumeReplyDirectives(text, { final: true });
|
||||||
text: cleanedText,
|
if (splitResult) {
|
||||||
mediaUrls,
|
const {
|
||||||
audioAsVoice,
|
|
||||||
replyToId,
|
|
||||||
replyToTag,
|
|
||||||
replyToCurrent,
|
|
||||||
} = parseReplyDirectives(text);
|
|
||||||
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
|
|
||||||
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
|
|
||||||
void onBlockReply({
|
|
||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
mediaUrls,
|
||||||
audioAsVoice,
|
audioAsVoice,
|
||||||
replyToId,
|
replyToId,
|
||||||
replyToTag,
|
replyToTag,
|
||||||
replyToCurrent,
|
replyToCurrent,
|
||||||
});
|
} = splitResult;
|
||||||
|
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
|
||||||
|
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
|
||||||
|
void onBlockReply({
|
||||||
|
text: cleanedText,
|
||||||
|
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||||
|
audioAsVoice,
|
||||||
|
replyToId,
|
||||||
|
replyToTag,
|
||||||
|
replyToCurrent,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -254,6 +257,30 @@ export function handleMessageEnd(
|
|||||||
ctx.emitReasoningStream(rawThinking);
|
ctx.emitReasoningStream(rawThinking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.state.blockReplyBreak === "text_end" && onBlockReply) {
|
||||||
|
const tailResult = ctx.consumeReplyDirectives("", { final: true });
|
||||||
|
if (tailResult) {
|
||||||
|
const {
|
||||||
|
text: cleanedText,
|
||||||
|
mediaUrls,
|
||||||
|
audioAsVoice,
|
||||||
|
replyToId,
|
||||||
|
replyToTag,
|
||||||
|
replyToCurrent,
|
||||||
|
} = tailResult;
|
||||||
|
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
|
||||||
|
void onBlockReply({
|
||||||
|
text: cleanedText,
|
||||||
|
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||||
|
audioAsVoice,
|
||||||
|
replyToId,
|
||||||
|
replyToTag,
|
||||||
|
replyToCurrent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.state.deltaBuffer = "";
|
ctx.state.deltaBuffer = "";
|
||||||
ctx.state.blockBuffer = "";
|
ctx.state.blockBuffer = "";
|
||||||
ctx.blockChunker?.reset();
|
ctx.blockChunker?.reset();
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
|
||||||
import type { ReasoningLevel } from "../auto-reply/thinking.js";
|
import type { ReasoningLevel } from "../auto-reply/thinking.js";
|
||||||
|
import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js";
|
||||||
import type { InlineCodeState } from "../markdown/code-spans.js";
|
import type { InlineCodeState } from "../markdown/code-spans.js";
|
||||||
import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
||||||
import type { MessagingToolSend } from "./pi-embedded-messaging.js";
|
import type { MessagingToolSend } from "./pi-embedded-messaging.js";
|
||||||
@ -77,6 +78,10 @@ export type EmbeddedPiSubscribeContext = {
|
|||||||
emitBlockChunk: (text: string) => void;
|
emitBlockChunk: (text: string) => void;
|
||||||
flushBlockReplyBuffer: () => void;
|
flushBlockReplyBuffer: () => void;
|
||||||
emitReasoningStream: (text: string) => void;
|
emitReasoningStream: (text: string) => void;
|
||||||
|
consumeReplyDirectives: (
|
||||||
|
text: string,
|
||||||
|
options?: { final?: boolean },
|
||||||
|
) => ReplyDirectiveParseResult | null;
|
||||||
resetAssistantMessageState: (nextAssistantTextBaseline: number) => void;
|
resetAssistantMessageState: (nextAssistantTextBaseline: number) => void;
|
||||||
resetForCompactionRetry: () => void;
|
resetForCompactionRetry: () => void;
|
||||||
finalizeAssistantTexts: (args: {
|
finalizeAssistantTexts: (args: {
|
||||||
|
|||||||
106
src/agents/pi-embedded-subscribe.reply-tags.test.ts
Normal file
106
src/agents/pi-embedded-subscribe.reply-tags.test.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
|
||||||
|
|
||||||
|
type StubSession = {
|
||||||
|
subscribe: (fn: (evt: unknown) => void) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("subscribeEmbeddedPiSession reply tags", () => {
|
||||||
|
it("carries reply_to_current across tag-only block chunks", () => {
|
||||||
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
|
const session: StubSession = {
|
||||||
|
subscribe: (fn) => {
|
||||||
|
handler = fn;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlockReply = vi.fn();
|
||||||
|
|
||||||
|
subscribeEmbeddedPiSession({
|
||||||
|
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
|
||||||
|
runId: "run",
|
||||||
|
onBlockReply,
|
||||||
|
blockReplyBreak: "text_end",
|
||||||
|
blockReplyChunking: {
|
||||||
|
minChars: 1,
|
||||||
|
maxChars: 50,
|
||||||
|
breakPreference: "newline",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handler?.({ type: "message_start", message: { role: "assistant" } });
|
||||||
|
handler?.({
|
||||||
|
type: "message_update",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
assistantMessageEvent: {
|
||||||
|
type: "text_delta",
|
||||||
|
delta: "[[reply_to_current]]\nHello",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
handler?.({
|
||||||
|
type: "message_update",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
assistantMessageEvent: { type: "text_end" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "[[reply_to_current]]\nHello" }],
|
||||||
|
} as AssistantMessage;
|
||||||
|
handler?.({ type: "message_end", message: assistantMessage });
|
||||||
|
|
||||||
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = onBlockReply.mock.calls[0]?.[0];
|
||||||
|
expect(payload?.text).toBe("Hello");
|
||||||
|
expect(payload?.replyToCurrent).toBe(true);
|
||||||
|
expect(payload?.replyToTag).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes trailing directive tails on stream end", () => {
|
||||||
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
|
const session: StubSession = {
|
||||||
|
subscribe: (fn) => {
|
||||||
|
handler = fn;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlockReply = vi.fn();
|
||||||
|
|
||||||
|
subscribeEmbeddedPiSession({
|
||||||
|
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
|
||||||
|
runId: "run",
|
||||||
|
onBlockReply,
|
||||||
|
blockReplyBreak: "text_end",
|
||||||
|
blockReplyChunking: {
|
||||||
|
minChars: 1,
|
||||||
|
maxChars: 50,
|
||||||
|
breakPreference: "newline",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handler?.({ type: "message_start", message: { role: "assistant" } });
|
||||||
|
handler?.({
|
||||||
|
type: "message_update",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
assistantMessageEvent: { type: "text_delta", delta: "Hello [[" },
|
||||||
|
});
|
||||||
|
handler?.({
|
||||||
|
type: "message_update",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
assistantMessageEvent: { type: "text_end" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Hello [[" }],
|
||||||
|
} as AssistantMessage;
|
||||||
|
handler?.({ type: "message_end", message: assistantMessage });
|
||||||
|
|
||||||
|
expect(onBlockReply).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onBlockReply.mock.calls[0]?.[0]?.text).toBe("Hello");
|
||||||
|
expect(onBlockReply.mock.calls[1]?.[0]?.text).toBe("[[");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
|
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
|
||||||
|
import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js";
|
||||||
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import type { InlineCodeState } from "../markdown/code-spans.js";
|
import type { InlineCodeState } from "../markdown/code-spans.js";
|
||||||
@ -75,11 +76,13 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
const messagingToolSentTargets = state.messagingToolSentTargets;
|
const messagingToolSentTargets = state.messagingToolSentTargets;
|
||||||
const pendingMessagingTexts = state.pendingMessagingTexts;
|
const pendingMessagingTexts = state.pendingMessagingTexts;
|
||||||
const pendingMessagingTargets = state.pendingMessagingTargets;
|
const pendingMessagingTargets = state.pendingMessagingTargets;
|
||||||
|
const replyDirectiveAccumulator = createStreamingDirectiveAccumulator();
|
||||||
|
|
||||||
const resetAssistantMessageState = (nextAssistantTextBaseline: number) => {
|
const resetAssistantMessageState = (nextAssistantTextBaseline: number) => {
|
||||||
state.deltaBuffer = "";
|
state.deltaBuffer = "";
|
||||||
state.blockBuffer = "";
|
state.blockBuffer = "";
|
||||||
blockChunker?.reset();
|
blockChunker?.reset();
|
||||||
|
replyDirectiveAccumulator.reset();
|
||||||
state.blockState.thinking = false;
|
state.blockState.thinking = false;
|
||||||
state.blockState.final = false;
|
state.blockState.final = false;
|
||||||
state.blockState.inlineCode = createInlineCodeState();
|
state.blockState.inlineCode = createInlineCodeState();
|
||||||
@ -374,7 +377,8 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
assistantTexts.push(chunk);
|
assistantTexts.push(chunk);
|
||||||
rememberAssistantText(chunk);
|
rememberAssistantText(chunk);
|
||||||
if (!params.onBlockReply) return;
|
if (!params.onBlockReply) return;
|
||||||
const splitResult = parseReplyDirectives(chunk);
|
const splitResult = replyDirectiveAccumulator.consume(chunk);
|
||||||
|
if (!splitResult) return;
|
||||||
const {
|
const {
|
||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
mediaUrls,
|
mediaUrls,
|
||||||
@ -395,6 +399,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const consumeReplyDirectives = (text: string, options?: { final?: boolean }) =>
|
||||||
|
replyDirectiveAccumulator.consume(text, options);
|
||||||
|
|
||||||
const flushBlockReplyBuffer = () => {
|
const flushBlockReplyBuffer = () => {
|
||||||
if (!params.onBlockReply) return;
|
if (!params.onBlockReply) return;
|
||||||
if (blockChunker?.hasBuffered()) {
|
if (blockChunker?.hasBuffered()) {
|
||||||
@ -447,6 +454,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
emitBlockChunk,
|
emitBlockChunk,
|
||||||
flushBlockReplyBuffer,
|
flushBlockReplyBuffer,
|
||||||
emitReasoningStream,
|
emitReasoningStream,
|
||||||
|
consumeReplyDirectives,
|
||||||
resetAssistantMessageState,
|
resetAssistantMessageState,
|
||||||
resetForCompactionRetry,
|
resetForCompactionRetry,
|
||||||
finalizeAssistantTexts,
|
finalizeAssistantTexts,
|
||||||
|
|||||||
@ -115,6 +115,15 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes workspace notes when provided", () => {
|
||||||
|
const prompt = buildAgentSystemPrompt({
|
||||||
|
workspaceDir: "/tmp/clawd",
|
||||||
|
workspaceNotes: ["Reminder: commit your changes in this workspace after edits."],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain("Reminder: commit your changes in this workspace after edits.");
|
||||||
|
});
|
||||||
|
|
||||||
it("includes user time when provided (12-hour)", () => {
|
it("includes user time when provided (12-hour)", () => {
|
||||||
const prompt = buildAgentSystemPrompt({
|
const prompt = buildAgentSystemPrompt({
|
||||||
workspaceDir: "/tmp/clawd",
|
workspaceDir: "/tmp/clawd",
|
||||||
|
|||||||
@ -17,8 +17,9 @@ function buildSkillsSection(params: {
|
|||||||
isMinimal: boolean;
|
isMinimal: boolean;
|
||||||
readToolName: string;
|
readToolName: string;
|
||||||
}) {
|
}) {
|
||||||
|
if (params.isMinimal) return [];
|
||||||
const trimmed = params.skillsPrompt?.trim();
|
const trimmed = params.skillsPrompt?.trim();
|
||||||
if (!trimmed || params.isMinimal) return [];
|
if (!trimmed) return [];
|
||||||
return [
|
return [
|
||||||
"## Skills (mandatory)",
|
"## Skills (mandatory)",
|
||||||
"Before replying: scan <available_skills> <description> entries.",
|
"Before replying: scan <available_skills> <description> entries.",
|
||||||
@ -148,6 +149,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
skillsPrompt?: string;
|
skillsPrompt?: string;
|
||||||
heartbeatPrompt?: string;
|
heartbeatPrompt?: string;
|
||||||
docsPath?: string;
|
docsPath?: string;
|
||||||
|
workspaceNotes?: string[];
|
||||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||||
promptMode?: PromptMode;
|
promptMode?: PromptMode;
|
||||||
runtimeInfo?: {
|
runtimeInfo?: {
|
||||||
@ -327,6 +329,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
isMinimal,
|
isMinimal,
|
||||||
readToolName,
|
readToolName,
|
||||||
});
|
});
|
||||||
|
const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean);
|
||||||
|
|
||||||
// For "none" mode, return just the basic identity line
|
// For "none" mode, return just the basic identity line
|
||||||
if (promptMode === "none") {
|
if (promptMode === "none") {
|
||||||
@ -403,6 +406,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"## Workspace",
|
"## Workspace",
|
||||||
`Your working directory is: ${params.workspaceDir}`,
|
`Your working directory is: ${params.workspaceDir}`,
|
||||||
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
||||||
|
...workspaceNotes,
|
||||||
"",
|
"",
|
||||||
...docsSection,
|
...docsSection,
|
||||||
params.sandboxInfo?.enabled ? "## Sandbox" : "",
|
params.sandboxInfo?.enabled ? "## Sandbox" : "",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENTS_FILENAME,
|
DEFAULT_AGENTS_FILENAME,
|
||||||
@ -40,6 +41,34 @@ describe("ensureAgentWorkspace", () => {
|
|||||||
await expect(fs.stat(bootstrap)).resolves.toBeDefined();
|
await expect(fs.stat(bootstrap)).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("initializes a git repo for brand-new workspaces when git is available", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
||||||
|
const nested = path.join(dir, "nested");
|
||||||
|
const gitAvailable = await runCommandWithTimeout(["git", "--version"], { timeoutMs: 2_000 })
|
||||||
|
.then((res) => res.code === 0)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!gitAvailable) return;
|
||||||
|
|
||||||
|
await ensureAgentWorkspace({
|
||||||
|
dir: nested,
|
||||||
|
ensureBootstrapFiles: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fs.stat(path.join(nested, ".git"))).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not initialize git when workspace already exists", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
||||||
|
await fs.writeFile(path.join(dir, "AGENTS.md"), "custom", "utf-8");
|
||||||
|
|
||||||
|
await ensureAgentWorkspace({
|
||||||
|
dir,
|
||||||
|
ensureBootstrapFiles: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fs.stat(path.join(dir, ".git"))).rejects.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not overwrite existing AGENTS.md", async () => {
|
it("does not overwrite existing AGENTS.md", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
||||||
const agentsPath = path.join(dir, "AGENTS.md");
|
const agentsPath = path.join(dir, "AGENTS.md");
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
|
||||||
export function resolveDefaultAgentWorkspaceDir(
|
export function resolveDefaultAgentWorkspaceDir(
|
||||||
@ -27,142 +27,9 @@ export const DEFAULT_USER_FILENAME = "USER.md";
|
|||||||
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
|
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
|
||||||
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
|
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
|
||||||
|
|
||||||
const DEFAULT_AGENTS_TEMPLATE = `# AGENTS.md - Clawdbot Workspace
|
|
||||||
|
|
||||||
This folder is the assistant's working directory.
|
|
||||||
|
|
||||||
## First run (one-time)
|
|
||||||
- If BOOTSTRAP.md exists, follow its ritual and delete it once complete.
|
|
||||||
- Your agent identity lives in IDENTITY.md.
|
|
||||||
- Your profile lives in USER.md.
|
|
||||||
|
|
||||||
## Backup tip (recommended)
|
|
||||||
If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity
|
|
||||||
and notes are backed up.
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
git init
|
|
||||||
git add AGENTS.md
|
|
||||||
git commit -m "Add agent workspace"
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Safety defaults
|
|
||||||
- Don't exfiltrate secrets or private data.
|
|
||||||
- Don't run destructive commands unless explicitly asked.
|
|
||||||
- Be concise in chat; write longer output to files in this workspace.
|
|
||||||
|
|
||||||
## Daily memory (recommended)
|
|
||||||
- Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed).
|
|
||||||
- On session start, read today + yesterday if present.
|
|
||||||
- Capture durable facts, preferences, and decisions; avoid secrets.
|
|
||||||
|
|
||||||
## Heartbeats (optional)
|
|
||||||
- HEARTBEAT.md can hold a tiny checklist for heartbeat runs; keep it small.
|
|
||||||
|
|
||||||
## Customize
|
|
||||||
- Add your preferred style, rules, and "memory" here.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DEFAULT_SOUL_TEMPLATE = `# SOUL.md - Persona & Boundaries
|
|
||||||
|
|
||||||
Describe who the assistant is, tone, and boundaries.
|
|
||||||
|
|
||||||
- Keep replies concise and direct.
|
|
||||||
- Ask clarifying questions when needed.
|
|
||||||
- Never send streaming/partial replies to external messaging surfaces.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DEFAULT_TOOLS_TEMPLATE = `# TOOLS.md - User Tool Notes (editable)
|
|
||||||
|
|
||||||
This file is for *your* notes about external tools and conventions.
|
|
||||||
It does not define which tools exist; Clawdbot provides built-in tools internally.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### imsg
|
|
||||||
- Send an iMessage/SMS: describe who/what, confirm before sending.
|
|
||||||
- Prefer short messages; avoid sending secrets.
|
|
||||||
|
|
||||||
### sag
|
|
||||||
- Text-to-speech: specify voice, target speaker/room, and whether to stream.
|
|
||||||
|
|
||||||
Add whatever else you want the assistant to know about your local toolchain.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md
|
|
||||||
|
|
||||||
Optional: keep a tiny checklist for heartbeat runs.
|
|
||||||
|
|
||||||
Guidance (to avoid nagging):
|
|
||||||
- Only report items that are truly new or changed.
|
|
||||||
- Do not invent tasks from old chat context.
|
|
||||||
- If nothing needs attention, reply HEARTBEAT_OK.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after)
|
|
||||||
|
|
||||||
Hello. I was just born.
|
|
||||||
|
|
||||||
## Your mission
|
|
||||||
Start a short, playful conversation and learn:
|
|
||||||
- Who am I?
|
|
||||||
- What am I?
|
|
||||||
- Who are you?
|
|
||||||
- How should I call you?
|
|
||||||
|
|
||||||
## How to ask (cute + helpful)
|
|
||||||
Say:
|
|
||||||
"Hello! I was just born. Who am I? What am I? Who are you? How should I call you?"
|
|
||||||
|
|
||||||
Then offer suggestions:
|
|
||||||
- 3-5 name ideas.
|
|
||||||
- 3-5 creature/vibe combos.
|
|
||||||
- 5 emoji ideas.
|
|
||||||
|
|
||||||
## Write these files
|
|
||||||
After the user chooses, update:
|
|
||||||
|
|
||||||
1) IDENTITY.md
|
|
||||||
- Name
|
|
||||||
- Creature
|
|
||||||
- Vibe
|
|
||||||
- Emoji
|
|
||||||
|
|
||||||
2) USER.md
|
|
||||||
- Name
|
|
||||||
- Preferred address
|
|
||||||
- Pronouns (optional)
|
|
||||||
- Timezone (optional)
|
|
||||||
- Notes
|
|
||||||
|
|
||||||
3) ~/.clawdbot/clawdbot.json
|
|
||||||
Run: ${formatCliCommand('clawdbot agents set-identity --workspace "<this workspace>" --from-identity')}
|
|
||||||
If multiple agents share a host, add --agent <id>.
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
Delete BOOTSTRAP.md once this is complete.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DEFAULT_IDENTITY_TEMPLATE = `# IDENTITY.md - Agent Identity
|
|
||||||
|
|
||||||
- Name:
|
|
||||||
- Creature:
|
|
||||||
- Vibe:
|
|
||||||
- Emoji:
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DEFAULT_USER_TEMPLATE = `# USER.md - User Profile
|
|
||||||
|
|
||||||
- Name:
|
|
||||||
- Preferred address:
|
|
||||||
- Pronouns (optional):
|
|
||||||
- Timezone (optional):
|
|
||||||
- Notes:
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TEMPLATE_DIR = path.resolve(
|
const TEMPLATE_DIR = path.resolve(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
"../../docs/templates",
|
"../../docs/reference/templates",
|
||||||
);
|
);
|
||||||
|
|
||||||
function stripFrontMatter(content: string): string {
|
function stripFrontMatter(content: string): string {
|
||||||
@ -175,13 +42,15 @@ function stripFrontMatter(content: string): string {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTemplate(name: string, fallback: string): Promise<string> {
|
async function loadTemplate(name: string): Promise<string> {
|
||||||
const templatePath = path.join(TEMPLATE_DIR, name);
|
const templatePath = path.join(TEMPLATE_DIR, name);
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(templatePath, "utf-8");
|
const content = await fs.readFile(templatePath, "utf-8");
|
||||||
return stripFrontMatter(content);
|
return stripFrontMatter(content);
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
throw new Error(
|
||||||
|
`Missing workspace template: ${name} (${templatePath}). Ensure docs/reference/templates are packaged.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,6 +82,35 @@ async function writeFileIfMissing(filePath: string, content: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hasGitRepo(dir: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.stat(path.join(dir, ".git"));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isGitAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await runCommandWithTimeout(["git", "--version"], { timeoutMs: 2_000 });
|
||||||
|
return result.code === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGitRepo(dir: string, isBrandNewWorkspace: boolean) {
|
||||||
|
if (!isBrandNewWorkspace) return;
|
||||||
|
if (await hasGitRepo(dir)) return;
|
||||||
|
if (!(await isGitAvailable())) return;
|
||||||
|
try {
|
||||||
|
await runCommandWithTimeout(["git", "init"], { cwd: dir, timeoutMs: 10_000 });
|
||||||
|
} catch {
|
||||||
|
// Ignore git init failures; workspace creation should still succeed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureAgentWorkspace(params?: {
|
export async function ensureAgentWorkspace(params?: {
|
||||||
dir?: string;
|
dir?: string;
|
||||||
ensureBootstrapFiles?: boolean;
|
ensureBootstrapFiles?: boolean;
|
||||||
@ -255,19 +153,13 @@ export async function ensureAgentWorkspace(params?: {
|
|||||||
return existing.every((v) => !v);
|
return existing.every((v) => !v);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_TEMPLATE);
|
const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME);
|
||||||
const soulTemplate = await loadTemplate(DEFAULT_SOUL_FILENAME, DEFAULT_SOUL_TEMPLATE);
|
const soulTemplate = await loadTemplate(DEFAULT_SOUL_FILENAME);
|
||||||
const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME, DEFAULT_TOOLS_TEMPLATE);
|
const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME);
|
||||||
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME, DEFAULT_IDENTITY_TEMPLATE);
|
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME);
|
||||||
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME, DEFAULT_USER_TEMPLATE);
|
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
|
||||||
const heartbeatTemplate = await loadTemplate(
|
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
|
||||||
DEFAULT_HEARTBEAT_FILENAME,
|
const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);
|
||||||
DEFAULT_HEARTBEAT_TEMPLATE,
|
|
||||||
);
|
|
||||||
const bootstrapTemplate = await loadTemplate(
|
|
||||||
DEFAULT_BOOTSTRAP_FILENAME,
|
|
||||||
DEFAULT_BOOTSTRAP_TEMPLATE,
|
|
||||||
);
|
|
||||||
|
|
||||||
await writeFileIfMissing(agentsPath, agentsTemplate);
|
await writeFileIfMissing(agentsPath, agentsTemplate);
|
||||||
await writeFileIfMissing(soulPath, soulTemplate);
|
await writeFileIfMissing(soulPath, soulTemplate);
|
||||||
@ -278,6 +170,7 @@ export async function ensureAgentWorkspace(params?: {
|
|||||||
if (isBrandNewWorkspace) {
|
if (isBrandNewWorkspace) {
|
||||||
await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
|
await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
|
||||||
}
|
}
|
||||||
|
await ensureGitRepo(dir, isBrandNewWorkspace);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dir,
|
dir,
|
||||||
|
|||||||
@ -379,6 +379,7 @@ export async function resolveReplyDirectives(params: {
|
|||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
parentSessionKey: ctx.ParentSessionKey,
|
||||||
storePath,
|
storePath,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
|
|||||||
@ -247,7 +247,7 @@ export async function runPreparedReply(
|
|||||||
const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
|
const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
|
||||||
const mediaNote = buildInboundMediaNote(ctx);
|
const mediaNote = buildInboundMediaNote(ctx);
|
||||||
const mediaReplyHint = mediaNote
|
const mediaReplyHint = mediaNote
|
||||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body."
|
||||||
: undefined;
|
: undefined;
|
||||||
let prefixedCommandBody = mediaNote
|
let prefixedCommandBody = mediaNote
|
||||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()
|
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()
|
||||||
|
|||||||
157
src/auto-reply/reply/model-selection.inherit-parent.test.ts
Normal file
157
src/auto-reply/reply/model-selection.inherit-parent.test.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { createModelSelectionState } from "./model-selection.js";
|
||||||
|
|
||||||
|
vi.mock("../../agents/model-catalog.js", () => ({
|
||||||
|
loadModelCatalog: vi.fn(async () => [
|
||||||
|
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
|
||||||
|
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
|
||||||
|
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProvider = "openai";
|
||||||
|
const defaultModel = "gpt-4o-mini";
|
||||||
|
|
||||||
|
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
sessionId: "session-id",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resolveState(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
sessionEntry: ReturnType<typeof makeEntry>;
|
||||||
|
sessionStore: Record<string, ReturnType<typeof makeEntry>>;
|
||||||
|
sessionKey: string;
|
||||||
|
parentSessionKey?: string;
|
||||||
|
}) {
|
||||||
|
return createModelSelectionState({
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentCfg: params.cfg.agents?.defaults,
|
||||||
|
sessionEntry: params.sessionEntry,
|
||||||
|
sessionStore: params.sessionStore,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
parentSessionKey: params.parentSessionKey,
|
||||||
|
defaultProvider,
|
||||||
|
defaultModel,
|
||||||
|
provider: defaultProvider,
|
||||||
|
model: defaultModel,
|
||||||
|
hasModelDirective: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createModelSelectionState parent inheritance", () => {
|
||||||
|
it("inherits parent override from explicit parentSessionKey", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const parentKey = "agent:main:discord:channel:C1";
|
||||||
|
const sessionKey = "agent:main:discord:channel:C1:thread:123";
|
||||||
|
const parentEntry = makeEntry({
|
||||||
|
providerOverride: "openai",
|
||||||
|
modelOverride: "gpt-4o",
|
||||||
|
});
|
||||||
|
const sessionEntry = makeEntry();
|
||||||
|
const sessionStore = {
|
||||||
|
[parentKey]: parentEntry,
|
||||||
|
[sessionKey]: sessionEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await resolveState({
|
||||||
|
cfg,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
parentSessionKey: parentKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.provider).toBe("openai");
|
||||||
|
expect(state.model).toBe("gpt-4o");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives parent key from topic session suffix", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const parentKey = "agent:main:telegram:group:123";
|
||||||
|
const sessionKey = "agent:main:telegram:group:123:topic:99";
|
||||||
|
const parentEntry = makeEntry({
|
||||||
|
providerOverride: "openai",
|
||||||
|
modelOverride: "gpt-4o",
|
||||||
|
});
|
||||||
|
const sessionEntry = makeEntry();
|
||||||
|
const sessionStore = {
|
||||||
|
[parentKey]: parentEntry,
|
||||||
|
[sessionKey]: sessionEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await resolveState({
|
||||||
|
cfg,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.provider).toBe("openai");
|
||||||
|
expect(state.model).toBe("gpt-4o");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers child override over parent", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const parentKey = "agent:main:telegram:group:123";
|
||||||
|
const sessionKey = "agent:main:telegram:group:123:topic:99";
|
||||||
|
const parentEntry = makeEntry({
|
||||||
|
providerOverride: "openai",
|
||||||
|
modelOverride: "gpt-4o",
|
||||||
|
});
|
||||||
|
const sessionEntry = makeEntry({
|
||||||
|
providerOverride: "anthropic",
|
||||||
|
modelOverride: "claude-opus-4-5",
|
||||||
|
});
|
||||||
|
const sessionStore = {
|
||||||
|
[parentKey]: parentEntry,
|
||||||
|
[sessionKey]: sessionEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await resolveState({
|
||||||
|
cfg,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.provider).toBe("anthropic");
|
||||||
|
expect(state.model).toBe("claude-opus-4-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores parent override when disallowed", async () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"openai/gpt-4o-mini": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const parentKey = "agent:main:slack:channel:C1";
|
||||||
|
const sessionKey = "agent:main:slack:channel:C1:thread:123";
|
||||||
|
const parentEntry = makeEntry({
|
||||||
|
providerOverride: "anthropic",
|
||||||
|
modelOverride: "claude-opus-4-5",
|
||||||
|
});
|
||||||
|
const sessionEntry = makeEntry();
|
||||||
|
const sessionStore = {
|
||||||
|
[parentKey]: parentEntry,
|
||||||
|
[sessionKey]: sessionEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await resolveState({
|
||||||
|
cfg,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.provider).toBe(defaultProvider);
|
||||||
|
expect(state.model).toBe(defaultModel);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -13,6 +13,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
|||||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
||||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||||
|
import { resolveThreadParentSessionKey } from "../../sessions/session-key-utils.js";
|
||||||
import type { ThinkLevel } from "./directives.js";
|
import type { ThinkLevel } from "./directives.js";
|
||||||
|
|
||||||
export type ModelDirectiveSelection = {
|
export type ModelDirectiveSelection = {
|
||||||
@ -78,6 +79,52 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
|
|||||||
return dist;
|
return dist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoredModelOverride = {
|
||||||
|
provider?: string;
|
||||||
|
model: string;
|
||||||
|
source: "session" | "parent";
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveModelOverrideFromEntry(entry?: SessionEntry): {
|
||||||
|
provider?: string;
|
||||||
|
model: string;
|
||||||
|
} | null {
|
||||||
|
const model = entry?.modelOverride?.trim();
|
||||||
|
if (!model) return null;
|
||||||
|
const provider = entry?.providerOverride?.trim() || undefined;
|
||||||
|
return { provider, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParentSessionKeyCandidate(params: {
|
||||||
|
sessionKey?: string;
|
||||||
|
parentSessionKey?: string;
|
||||||
|
}): string | null {
|
||||||
|
const explicit = params.parentSessionKey?.trim();
|
||||||
|
if (explicit && explicit !== params.sessionKey) return explicit;
|
||||||
|
const derived = resolveThreadParentSessionKey(params.sessionKey);
|
||||||
|
if (derived && derived !== params.sessionKey) return derived;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStoredModelOverride(params: {
|
||||||
|
sessionEntry?: SessionEntry;
|
||||||
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
|
sessionKey?: string;
|
||||||
|
parentSessionKey?: string;
|
||||||
|
}): StoredModelOverride | null {
|
||||||
|
const direct = resolveModelOverrideFromEntry(params.sessionEntry);
|
||||||
|
if (direct) return { ...direct, source: "session" };
|
||||||
|
const parentKey = resolveParentSessionKeyCandidate({
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
parentSessionKey: params.parentSessionKey,
|
||||||
|
});
|
||||||
|
if (!parentKey || !params.sessionStore) return null;
|
||||||
|
const parentEntry = params.sessionStore[parentKey];
|
||||||
|
const parentOverride = resolveModelOverrideFromEntry(parentEntry);
|
||||||
|
if (!parentOverride) return null;
|
||||||
|
return { ...parentOverride, source: "parent" };
|
||||||
|
}
|
||||||
|
|
||||||
function scoreFuzzyMatch(params: {
|
function scoreFuzzyMatch(params: {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
@ -177,6 +224,7 @@ export async function createModelSelectionState(params: {
|
|||||||
sessionEntry?: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionStore?: Record<string, SessionEntry>;
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
parentSessionKey?: string;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
defaultProvider: string;
|
defaultProvider: string;
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
@ -190,6 +238,7 @@ export async function createModelSelectionState(params: {
|
|||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
parentSessionKey,
|
||||||
storePath,
|
storePath,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
@ -199,7 +248,13 @@ export async function createModelSelectionState(params: {
|
|||||||
let model = params.model;
|
let model = params.model;
|
||||||
|
|
||||||
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
||||||
const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride);
|
const initialStoredOverride = resolveStoredModelOverride({
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
parentSessionKey,
|
||||||
|
});
|
||||||
|
const hasStoredOverride = Boolean(initialStoredOverride);
|
||||||
const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride;
|
const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride;
|
||||||
|
|
||||||
let allowedModelKeys = new Set<string>();
|
let allowedModelKeys = new Set<string>();
|
||||||
@ -242,14 +297,18 @@ export async function createModelSelectionState(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedProviderOverride = sessionEntry?.providerOverride?.trim();
|
const storedOverride = resolveStoredModelOverride({
|
||||||
const storedModelOverride = sessionEntry?.modelOverride?.trim();
|
sessionEntry,
|
||||||
if (storedModelOverride) {
|
sessionStore,
|
||||||
const candidateProvider = storedProviderOverride || defaultProvider;
|
sessionKey,
|
||||||
const key = modelKey(candidateProvider, storedModelOverride);
|
parentSessionKey,
|
||||||
|
});
|
||||||
|
if (storedOverride?.model) {
|
||||||
|
const candidateProvider = storedOverride.provider || defaultProvider;
|
||||||
|
const key = modelKey(candidateProvider, storedOverride.model);
|
||||||
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
||||||
provider = candidateProvider;
|
provider = candidateProvider;
|
||||||
model = storedModelOverride;
|
model = storedOverride.model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
src/auto-reply/reply/streaming-directives.test.ts
Normal file
37
src/auto-reply/reply/streaming-directives.test.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
|
||||||
|
|
||||||
|
describe("createStreamingDirectiveAccumulator", () => {
|
||||||
|
it("stashes reply_to_current until a renderable chunk arrives", () => {
|
||||||
|
const accumulator = createStreamingDirectiveAccumulator();
|
||||||
|
|
||||||
|
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
|
||||||
|
|
||||||
|
const result = accumulator.consume("Hello");
|
||||||
|
expect(result?.text).toBe("Hello");
|
||||||
|
expect(result?.replyToCurrent).toBe(true);
|
||||||
|
expect(result?.replyToTag).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles reply tags split across chunks", () => {
|
||||||
|
const accumulator = createStreamingDirectiveAccumulator();
|
||||||
|
|
||||||
|
expect(accumulator.consume("[[reply_to_")).toBeNull();
|
||||||
|
|
||||||
|
const result = accumulator.consume("current]] Yo");
|
||||||
|
expect(result?.text).toBe("Yo");
|
||||||
|
expect(result?.replyToCurrent).toBe(true);
|
||||||
|
expect(result?.replyToTag).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates explicit reply ids across chunks", () => {
|
||||||
|
const accumulator = createStreamingDirectiveAccumulator();
|
||||||
|
|
||||||
|
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
||||||
|
|
||||||
|
const result = accumulator.consume("Hi");
|
||||||
|
expect(result?.text).toBe("Hi");
|
||||||
|
expect(result?.replyToId).toBe("abc-123");
|
||||||
|
expect(result?.replyToTag).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
src/auto-reply/reply/streaming-directives.ts
Normal file
124
src/auto-reply/reply/streaming-directives.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { splitMediaFromOutput } from "../../media/parse.js";
|
||||||
|
import { parseInlineDirectives } from "../../utils/directive-tags.js";
|
||||||
|
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
|
import type { ReplyDirectiveParseResult } from "./reply-directives.js";
|
||||||
|
|
||||||
|
type PendingReplyState = {
|
||||||
|
explicitId?: string;
|
||||||
|
sawCurrent: boolean;
|
||||||
|
hasTag: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedChunk = ReplyDirectiveParseResult & {
|
||||||
|
replyToExplicitId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConsumeOptions = {
|
||||||
|
final?: boolean;
|
||||||
|
silentToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitTrailingDirective = (text: string): { text: string; tail: string } => {
|
||||||
|
const openIndex = text.lastIndexOf("[[");
|
||||||
|
if (openIndex < 0) return { text, tail: "" };
|
||||||
|
const closeIndex = text.indexOf("]]", openIndex + 2);
|
||||||
|
if (closeIndex >= 0) return { text, tail: "" };
|
||||||
|
return {
|
||||||
|
text: text.slice(0, openIndex),
|
||||||
|
tail: text.slice(openIndex),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseChunk = (raw: string, options?: { silentToken?: string }): ParsedChunk => {
|
||||||
|
const split = splitMediaFromOutput(raw);
|
||||||
|
let text = split.text ?? "";
|
||||||
|
|
||||||
|
const replyParsed = parseInlineDirectives(text, {
|
||||||
|
stripAudioTag: false,
|
||||||
|
stripReplyTags: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (replyParsed.hasReplyTag) {
|
||||||
|
text = replyParsed.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const silentToken = options?.silentToken ?? SILENT_REPLY_TOKEN;
|
||||||
|
const isSilent = isSilentReplyText(text, silentToken);
|
||||||
|
if (isSilent) {
|
||||||
|
text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
mediaUrls: split.mediaUrls,
|
||||||
|
mediaUrl: split.mediaUrl,
|
||||||
|
replyToId: replyParsed.replyToId,
|
||||||
|
replyToExplicitId: replyParsed.replyToExplicitId,
|
||||||
|
replyToCurrent: replyParsed.replyToCurrent,
|
||||||
|
replyToTag: replyParsed.hasReplyTag,
|
||||||
|
audioAsVoice: split.audioAsVoice,
|
||||||
|
isSilent,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRenderableContent = (parsed: ReplyDirectiveParseResult): boolean =>
|
||||||
|
Boolean(parsed.text) ||
|
||||||
|
Boolean(parsed.mediaUrl) ||
|
||||||
|
(parsed.mediaUrls?.length ?? 0) > 0 ||
|
||||||
|
Boolean(parsed.audioAsVoice);
|
||||||
|
|
||||||
|
export function createStreamingDirectiveAccumulator() {
|
||||||
|
let pendingTail = "";
|
||||||
|
let pendingReply: PendingReplyState = { sawCurrent: false, hasTag: false };
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
pendingTail = "";
|
||||||
|
pendingReply = { sawCurrent: false, hasTag: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
const consume = (raw: string, options: ConsumeOptions = {}): ReplyDirectiveParseResult | null => {
|
||||||
|
let combined = `${pendingTail}${raw ?? ""}`;
|
||||||
|
pendingTail = "";
|
||||||
|
|
||||||
|
if (!options.final) {
|
||||||
|
const split = splitTrailingDirective(combined);
|
||||||
|
combined = split.text;
|
||||||
|
pendingTail = split.tail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!combined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseChunk(combined, { silentToken: options.silentToken });
|
||||||
|
const hasTag = pendingReply.hasTag || parsed.replyToTag;
|
||||||
|
const sawCurrent = pendingReply.sawCurrent || parsed.replyToCurrent;
|
||||||
|
const explicitId = parsed.replyToExplicitId ?? pendingReply.explicitId;
|
||||||
|
|
||||||
|
const combinedResult: ReplyDirectiveParseResult = {
|
||||||
|
...parsed,
|
||||||
|
replyToId: explicitId,
|
||||||
|
replyToCurrent: sawCurrent,
|
||||||
|
replyToTag: hasTag,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hasRenderableContent(combinedResult)) {
|
||||||
|
if (hasTag) {
|
||||||
|
pendingReply = {
|
||||||
|
explicitId,
|
||||||
|
sawCurrent,
|
||||||
|
hasTag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingReply = { sawCurrent: false, hasTag: false };
|
||||||
|
return combinedResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
consume,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1 +1 @@
|
|||||||
0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4
|
a99455ba0c4d0aad0a110bf25440c208b798198d5524b269f0f2d3f984262ae4
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { resolveCommitHash } from "../infra/git-commit.js";
|
import { resolveCommitHash } from "../infra/git-commit.js";
|
||||||
|
import { visibleWidth } from "../terminal/ansi.js";
|
||||||
import { isRich, theme } from "../terminal/theme.js";
|
import { isRich, theme } from "../terminal/theme.js";
|
||||||
import { pickTagline, type TaglineOptions } from "./tagline.js";
|
import { pickTagline, type TaglineOptions } from "./tagline.js";
|
||||||
|
|
||||||
type BannerOptions = TaglineOptions & {
|
type BannerOptions = TaglineOptions & {
|
||||||
argv?: string[];
|
argv?: string[];
|
||||||
commit?: string | null;
|
commit?: string | null;
|
||||||
|
columns?: number;
|
||||||
richTty?: boolean;
|
richTty?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -36,12 +38,28 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
|
|||||||
const tagline = pickTagline(options);
|
const tagline = pickTagline(options);
|
||||||
const rich = options.richTty ?? isRich();
|
const rich = options.richTty ?? isRich();
|
||||||
const title = "🦞 Clawdbot";
|
const title = "🦞 Clawdbot";
|
||||||
|
const prefix = "🦞 ";
|
||||||
|
const columns = options.columns ?? process.stdout.columns ?? 120;
|
||||||
|
const plainFullLine = `${title} ${version} (${commitLabel}) — ${tagline}`;
|
||||||
|
const fitsOnOneLine = visibleWidth(plainFullLine) <= columns;
|
||||||
if (rich) {
|
if (rich) {
|
||||||
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
|
if (fitsOnOneLine) {
|
||||||
|
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
|
||||||
|
`(${commitLabel})`,
|
||||||
|
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
|
||||||
|
}
|
||||||
|
const line1 = `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
|
||||||
`(${commitLabel})`,
|
`(${commitLabel})`,
|
||||||
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
|
)}`;
|
||||||
|
const line2 = `${" ".repeat(prefix.length)}${theme.muted("—")} ${theme.accentDim(tagline)}`;
|
||||||
|
return `${line1}\n${line2}`;
|
||||||
}
|
}
|
||||||
return `${title} ${version} (${commitLabel}) — ${tagline}`;
|
if (fitsOnOneLine) {
|
||||||
|
return plainFullLine;
|
||||||
|
}
|
||||||
|
const line1 = `${title} ${version} (${commitLabel})`;
|
||||||
|
const line2 = `${" ".repeat(prefix.length)}— ${tagline}`;
|
||||||
|
return `${line1}\n${line2}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOBSTER_ASCII = [
|
const LOBSTER_ASCII = [
|
||||||
|
|||||||
@ -2,10 +2,12 @@ import type { Command } from "commander";
|
|||||||
import { gatewayStatusCommand } from "../../commands/gateway-status.js";
|
import { gatewayStatusCommand } from "../../commands/gateway-status.js";
|
||||||
import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js";
|
import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js";
|
||||||
import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js";
|
import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js";
|
||||||
|
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||||
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js";
|
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { formatDocsLink } from "../../terminal/links.js";
|
import { formatDocsLink } from "../../terminal/links.js";
|
||||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||||
|
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
|
||||||
import { withProgress } from "../progress.js";
|
import { withProgress } from "../progress.js";
|
||||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||||
import {
|
import {
|
||||||
@ -58,6 +60,41 @@ function runGatewayCommand(action: () => Promise<void>, label?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDaysOption(raw: unknown, fallback = 30): number {
|
||||||
|
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(1, Math.floor(raw));
|
||||||
|
if (typeof raw === "string" && raw.trim() !== "") {
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed));
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCostUsageSummary(summary: CostUsageSummary, days: number, rich: boolean): string[] {
|
||||||
|
const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00";
|
||||||
|
const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0";
|
||||||
|
const lines = [
|
||||||
|
colorize(rich, theme.heading, `Usage cost (${days} days)`),
|
||||||
|
`${colorize(rich, theme.muted, "Total:")} ${totalCost} · ${totalTokens} tokens`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (summary.totals.missingCostEntries > 0) {
|
||||||
|
lines.push(
|
||||||
|
`${colorize(rich, theme.muted, "Missing entries:")} ${summary.totals.missingCostEntries}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = summary.daily.at(-1);
|
||||||
|
if (latest) {
|
||||||
|
const latestCost = formatUsd(latest.totalCost) ?? "$0.00";
|
||||||
|
const latestTokens = formatTokenCount(latest.totalTokens) ?? "0";
|
||||||
|
lines.push(
|
||||||
|
`${colorize(rich, theme.muted, "Latest day:")} ${latest.date} · ${latestCost} · ${latestTokens} tokens`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerGatewayCli(program: Command) {
|
export function registerGatewayCli(program: Command) {
|
||||||
const gateway = addGatewayRunCommand(
|
const gateway = addGatewayRunCommand(
|
||||||
program
|
program
|
||||||
@ -160,6 +197,28 @@ export function registerGatewayCli(program: Command) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
gatewayCallOpts(
|
||||||
|
gateway
|
||||||
|
.command("usage-cost")
|
||||||
|
.description("Fetch usage cost summary from session logs")
|
||||||
|
.option("--days <days>", "Number of days to include", "30")
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runGatewayCommand(async () => {
|
||||||
|
const days = parseDaysOption(opts.days);
|
||||||
|
const result = await callGatewayCli("usage.cost", opts, { days });
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rich = isRich();
|
||||||
|
const summary = result as CostUsageSummary;
|
||||||
|
for (const line of renderCostUsageSummary(summary, days, rich)) {
|
||||||
|
defaultRuntime.log(line);
|
||||||
|
}
|
||||||
|
}, "Gateway usage cost failed");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
gatewayCallOpts(
|
gatewayCallOpts(
|
||||||
gateway
|
gateway
|
||||||
.command("health")
|
.command("health")
|
||||||
|
|||||||
@ -139,7 +139,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
|
|||||||
|
|
||||||
agents
|
agents
|
||||||
.command("set-identity")
|
.command("set-identity")
|
||||||
.description("Update an agent identity (name/theme/emoji)")
|
.description("Update an agent identity (name/theme/emoji/avatar)")
|
||||||
.option("--agent <id>", "Agent id to update")
|
.option("--agent <id>", "Agent id to update")
|
||||||
.option("--workspace <dir>", "Workspace directory used to locate the agent + IDENTITY.md")
|
.option("--workspace <dir>", "Workspace directory used to locate the agent + IDENTITY.md")
|
||||||
.option("--identity-file <path>", "Explicit IDENTITY.md path to read")
|
.option("--identity-file <path>", "Explicit IDENTITY.md path to read")
|
||||||
@ -147,6 +147,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
|
|||||||
.option("--name <name>", "Identity name")
|
.option("--name <name>", "Identity name")
|
||||||
.option("--theme <theme>", "Identity theme")
|
.option("--theme <theme>", "Identity theme")
|
||||||
.option("--emoji <emoji>", "Identity emoji")
|
.option("--emoji <emoji>", "Identity emoji")
|
||||||
|
.option("--avatar <value>", "Identity avatar (workspace path, http(s) URL, or data URI)")
|
||||||
.option("--json", "Output JSON summary", false)
|
.option("--json", "Output JSON summary", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
@ -155,6 +156,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
|
|||||||
${theme.heading("Examples:")}
|
${theme.heading("Examples:")}
|
||||||
${formatHelpExamples([
|
${formatHelpExamples([
|
||||||
['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."],
|
['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."],
|
||||||
|
["clawdbot agents set-identity --agent main --avatar avatars/clawd.png", "Set avatar path."],
|
||||||
["clawdbot agents set-identity --workspace ~/clawd --from-identity", "Load from IDENTITY.md."],
|
["clawdbot agents set-identity --workspace ~/clawd --from-identity", "Load from IDENTITY.md."],
|
||||||
[
|
[
|
||||||
"clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main",
|
"clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main",
|
||||||
@ -174,6 +176,7 @@ ${formatHelpExamples([
|
|||||||
name: opts.name as string | undefined,
|
name: opts.name as string | undefined,
|
||||||
theme: opts.theme as string | undefined,
|
theme: opts.theme as string | undefined,
|
||||||
emoji: opts.emoji as string | undefined,
|
emoji: opts.emoji as string | undefined,
|
||||||
|
avatar: opts.avatar as string | undefined,
|
||||||
json: Boolean(opts.json),
|
json: Boolean(opts.json),
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
|
|||||||
@ -5,6 +5,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { UpdateRunResult } from "../infra/update-runner.js";
|
import type { UpdateRunResult } from "../infra/update-runner.js";
|
||||||
|
|
||||||
|
const confirm = vi.fn();
|
||||||
|
const select = vi.fn();
|
||||||
|
const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() }));
|
||||||
|
const isCancel = (value: unknown) => value === "cancel";
|
||||||
|
|
||||||
|
vi.mock("@clack/prompts", () => ({
|
||||||
|
confirm,
|
||||||
|
select,
|
||||||
|
isCancel,
|
||||||
|
spinner,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the update-runner module
|
// Mock the update-runner module
|
||||||
vi.mock("../infra/update-runner.js", () => ({
|
vi.mock("../infra/update-runner.js", () => ({
|
||||||
runGatewayUpdate: vi.fn(),
|
runGatewayUpdate: vi.fn(),
|
||||||
@ -128,9 +140,11 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exports updateCommand and registerUpdateCli", async () => {
|
it("exports updateCommand and registerUpdateCli", async () => {
|
||||||
const { updateCommand, registerUpdateCli } = await import("./update-cli.js");
|
const { updateCommand, registerUpdateCli, updateWizardCommand } =
|
||||||
|
await import("./update-cli.js");
|
||||||
expect(typeof updateCommand).toBe("function");
|
expect(typeof updateCommand).toBe("function");
|
||||||
expect(typeof registerUpdateCli).toBe("function");
|
expect(typeof registerUpdateCli).toBe("function");
|
||||||
|
expect(typeof updateWizardCommand).toBe("function");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("updateCommand runs update and outputs result", async () => {
|
it("updateCommand runs update and outputs result", async () => {
|
||||||
@ -585,4 +599,61 @@ describe("update-cli", () => {
|
|||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updateWizardCommand requires a TTY", async () => {
|
||||||
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
|
const { updateWizardCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
setTty(false);
|
||||||
|
vi.mocked(defaultRuntime.error).mockClear();
|
||||||
|
vi.mocked(defaultRuntime.exit).mockClear();
|
||||||
|
|
||||||
|
await updateWizardCommand({});
|
||||||
|
|
||||||
|
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Update wizard requires a TTY"),
|
||||||
|
);
|
||||||
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateWizardCommand offers dev checkout and forwards selections", async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-wizard-"));
|
||||||
|
const previousGitDir = process.env.CLAWDBOT_GIT_DIR;
|
||||||
|
try {
|
||||||
|
setTty(true);
|
||||||
|
process.env.CLAWDBOT_GIT_DIR = tempDir;
|
||||||
|
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { updateWizardCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||||
|
root: "/test/path",
|
||||||
|
installKind: "package",
|
||||||
|
packageManager: "npm",
|
||||||
|
deps: {
|
||||||
|
manager: "npm",
|
||||||
|
status: "ok",
|
||||||
|
lockfilePath: null,
|
||||||
|
markerPath: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
select.mockResolvedValue("dev");
|
||||||
|
confirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||||
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||||
|
status: "ok",
|
||||||
|
mode: "git",
|
||||||
|
steps: [],
|
||||||
|
durationMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateWizardCommand({});
|
||||||
|
|
||||||
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
||||||
|
expect(call?.channel).toBe("dev");
|
||||||
|
} finally {
|
||||||
|
process.env.CLAWDBOT_GIT_DIR = previousGitDir;
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { confirm, isCancel, spinner } from "@clack/prompts";
|
import { confirm, isCancel, select, spinner } from "@clack/prompts";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@ -39,7 +39,7 @@ import { trimLogTail } from "../infra/restart-sentinel.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
import { stylePromptMessage } from "../terminal/prompt-style.js";
|
import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { formatHelpExamples } from "./help-format.js";
|
import { formatHelpExamples } from "./help-format.js";
|
||||||
@ -63,6 +63,9 @@ export type UpdateStatusOptions = {
|
|||||||
json?: boolean;
|
json?: boolean;
|
||||||
timeout?: string;
|
timeout?: string;
|
||||||
};
|
};
|
||||||
|
export type UpdateWizardOptions = {
|
||||||
|
timeout?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const STEP_LABELS: Record<string, string> = {
|
const STEP_LABELS: Record<string, string> = {
|
||||||
"clean check": "Working directory is clean",
|
"clean check": "Working directory is clean",
|
||||||
@ -481,6 +484,15 @@ function formatStepStatus(exitCode: number | null): string {
|
|||||||
return theme.error("\u2717");
|
return theme.error("\u2717");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectStyled = <T>(params: Parameters<typeof select<T>>[0]) =>
|
||||||
|
select({
|
||||||
|
...params,
|
||||||
|
message: stylePromptMessage(params.message),
|
||||||
|
options: params.options.map((opt) =>
|
||||||
|
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
type PrintResultOptions = UpdateCommandOptions & {
|
type PrintResultOptions = UpdateCommandOptions & {
|
||||||
hideSteps?: boolean;
|
hideSteps?: boolean;
|
||||||
};
|
};
|
||||||
@ -940,6 +952,142 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promise<void> {
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
"Update wizard requires a TTY. Use `clawdbot update --channel <stable|beta|dev>` instead.",
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
|
||||||
|
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
|
||||||
|
defaultRuntime.error("--timeout must be a positive integer (seconds)");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root =
|
||||||
|
(await resolveClawdbotPackageRoot({
|
||||||
|
moduleUrl: import.meta.url,
|
||||||
|
argv1: process.argv[1],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})) ?? process.cwd();
|
||||||
|
|
||||||
|
const [updateStatus, configSnapshot] = await Promise.all([
|
||||||
|
checkUpdateStatus({
|
||||||
|
root,
|
||||||
|
timeoutMs: timeoutMs ?? 3500,
|
||||||
|
fetchGit: false,
|
||||||
|
includeRegistry: false,
|
||||||
|
}),
|
||||||
|
readConfigFileSnapshot(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const configChannel = configSnapshot.valid
|
||||||
|
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
|
||||||
|
: null;
|
||||||
|
const channelInfo = resolveEffectiveUpdateChannel({
|
||||||
|
configChannel,
|
||||||
|
installKind: updateStatus.installKind,
|
||||||
|
git: updateStatus.git
|
||||||
|
? { tag: updateStatus.git.tag, branch: updateStatus.git.branch }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
const channelLabel = formatUpdateChannelLabel({
|
||||||
|
channel: channelInfo.channel,
|
||||||
|
source: channelInfo.source,
|
||||||
|
gitTag: updateStatus.git?.tag ?? null,
|
||||||
|
gitBranch: updateStatus.git?.branch ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickedChannel = await selectStyled({
|
||||||
|
message: "Update channel",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "keep",
|
||||||
|
label: `Keep current (${channelInfo.channel})`,
|
||||||
|
hint: channelLabel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "stable",
|
||||||
|
label: "Stable",
|
||||||
|
hint: "Tagged releases (npm latest)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "beta",
|
||||||
|
label: "Beta",
|
||||||
|
hint: "Prereleases (npm beta)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dev",
|
||||||
|
label: "Dev",
|
||||||
|
hint: "Git main",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: "keep",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCancel(pickedChannel)) {
|
||||||
|
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||||
|
defaultRuntime.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedChannel = pickedChannel === "keep" ? null : pickedChannel;
|
||||||
|
|
||||||
|
if (requestedChannel === "dev" && updateStatus.installKind !== "git") {
|
||||||
|
const gitDir = resolveGitInstallDir();
|
||||||
|
const hasGit = await isGitCheckout(gitDir);
|
||||||
|
if (!hasGit) {
|
||||||
|
const dirExists = await pathExists(gitDir);
|
||||||
|
if (dirExists) {
|
||||||
|
const empty = await isEmptyDir(gitDir);
|
||||||
|
if (!empty) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
`CLAWDBOT_GIT_DIR points at a non-git directory: ${gitDir}. Set CLAWDBOT_GIT_DIR to an empty folder or a clawdbot checkout.`,
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ok = await confirm({
|
||||||
|
message: stylePromptMessage(
|
||||||
|
`Create a git checkout at ${gitDir}? (override via CLAWDBOT_GIT_DIR)`,
|
||||||
|
),
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (isCancel(ok) || ok === false) {
|
||||||
|
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||||
|
defaultRuntime.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restart = await confirm({
|
||||||
|
message: stylePromptMessage("Restart the gateway service after update?"),
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
if (isCancel(restart)) {
|
||||||
|
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||||
|
defaultRuntime.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCommand({
|
||||||
|
channel: requestedChannel ?? undefined,
|
||||||
|
restart: Boolean(restart),
|
||||||
|
timeout: opts.timeout,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerUpdateCli(program: Command) {
|
export function registerUpdateCli(program: Command) {
|
||||||
const update = program
|
const update = program
|
||||||
.command("update")
|
.command("update")
|
||||||
@ -959,6 +1107,7 @@ export function registerUpdateCli(program: Command) {
|
|||||||
["clawdbot update --restart", "Update and restart the service"],
|
["clawdbot update --restart", "Update and restart the service"],
|
||||||
["clawdbot update --json", "Output result as JSON"],
|
["clawdbot update --json", "Output result as JSON"],
|
||||||
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
|
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
|
||||||
|
["clawdbot update wizard", "Interactive update wizard"],
|
||||||
["clawdbot --update", "Shorthand for clawdbot update"],
|
["clawdbot --update", "Shorthand for clawdbot update"],
|
||||||
] as const;
|
] as const;
|
||||||
const fmtExamples = examples
|
const fmtExamples = examples
|
||||||
@ -1005,6 +1154,23 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
update
|
||||||
|
.command("wizard")
|
||||||
|
.description("Interactive update wizard")
|
||||||
|
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}\n`,
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
try {
|
||||||
|
await updateWizardCommand({ timeout: opts.timeout as string | undefined });
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
update
|
update
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("Show update channel and version status")
|
.description("Show update channel and version status")
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
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 { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
||||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||||
import type { IdentityConfig } from "../config/types.js";
|
import type { IdentityConfig } from "../config/types.js";
|
||||||
@ -15,7 +16,6 @@ import {
|
|||||||
findAgentEntryIndex,
|
findAgentEntryIndex,
|
||||||
listAgentEntries,
|
listAgentEntries,
|
||||||
loadAgentIdentity,
|
loadAgentIdentity,
|
||||||
parseIdentityMarkdown,
|
|
||||||
} from "./agents.config.js";
|
} from "./agents.config.js";
|
||||||
|
|
||||||
type AgentsSetIdentityOptions = {
|
type AgentsSetIdentityOptions = {
|
||||||
@ -25,6 +25,7 @@ type AgentsSetIdentityOptions = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
|
avatar?: string;
|
||||||
fromIdentity?: boolean;
|
fromIdentity?: boolean;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
};
|
};
|
||||||
@ -40,7 +41,7 @@ async function loadIdentityFromFile(filePath: string): Promise<AgentIdentity | n
|
|||||||
try {
|
try {
|
||||||
const content = await fs.readFile(filePath, "utf-8");
|
const content = await fs.readFile(filePath, "utf-8");
|
||||||
const parsed = parseIdentityMarkdown(content);
|
const parsed = parseIdentityMarkdown(content);
|
||||||
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
|
if (!identityHasValues(parsed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
@ -75,7 +76,8 @@ export async function agentsSetIdentityCommand(
|
|||||||
const nameRaw = coerceTrimmed(opts.name);
|
const nameRaw = coerceTrimmed(opts.name);
|
||||||
const emojiRaw = coerceTrimmed(opts.emoji);
|
const emojiRaw = coerceTrimmed(opts.emoji);
|
||||||
const themeRaw = coerceTrimmed(opts.theme);
|
const themeRaw = coerceTrimmed(opts.theme);
|
||||||
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw);
|
const avatarRaw = coerceTrimmed(opts.avatar);
|
||||||
|
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw || avatarRaw);
|
||||||
|
|
||||||
const identityFileRaw = coerceTrimmed(opts.identityFile);
|
const identityFileRaw = coerceTrimmed(opts.identityFile);
|
||||||
const workspaceRaw = coerceTrimmed(opts.workspace);
|
const workspaceRaw = coerceTrimmed(opts.workspace);
|
||||||
@ -141,10 +143,20 @@ export async function agentsSetIdentityCommand(
|
|||||||
...(nameRaw || identityFromFile?.name ? { name: nameRaw ?? identityFromFile?.name } : {}),
|
...(nameRaw || identityFromFile?.name ? { name: nameRaw ?? identityFromFile?.name } : {}),
|
||||||
...(emojiRaw || identityFromFile?.emoji ? { emoji: emojiRaw ?? identityFromFile?.emoji } : {}),
|
...(emojiRaw || identityFromFile?.emoji ? { emoji: emojiRaw ?? identityFromFile?.emoji } : {}),
|
||||||
...(themeRaw || fileTheme ? { theme: themeRaw ?? fileTheme } : {}),
|
...(themeRaw || fileTheme ? { theme: themeRaw ?? fileTheme } : {}),
|
||||||
|
...(avatarRaw || identityFromFile?.avatar
|
||||||
|
? { avatar: avatarRaw ?? identityFromFile?.avatar }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!incomingIdentity.name && !incomingIdentity.emoji && !incomingIdentity.theme) {
|
if (
|
||||||
runtime.error("No identity fields provided. Use --name/--emoji/--theme or --from-identity.");
|
!incomingIdentity.name &&
|
||||||
|
!incomingIdentity.emoji &&
|
||||||
|
!incomingIdentity.theme &&
|
||||||
|
!incomingIdentity.avatar
|
||||||
|
) {
|
||||||
|
runtime.error(
|
||||||
|
"No identity fields provided. Use --name/--emoji/--theme/--avatar or --from-identity.",
|
||||||
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -204,5 +216,6 @@ export async function agentsSetIdentityCommand(
|
|||||||
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
|
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
|
||||||
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
|
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
|
||||||
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
|
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
|
||||||
|
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
|
||||||
if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
|
if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resolveAgentDir,
|
resolveAgentDir,
|
||||||
resolveAgentWorkspaceDir,
|
resolveAgentWorkspaceDir,
|
||||||
resolveDefaultAgentId,
|
resolveDefaultAgentId,
|
||||||
} from "../agents/agent-scope.js";
|
} from "../agents/agent-scope.js";
|
||||||
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
import type { AgentIdentityFile } from "../agents/identity-file.js";
|
||||||
|
import {
|
||||||
|
identityHasValues,
|
||||||
|
loadAgentIdentityFromWorkspace,
|
||||||
|
parseIdentityMarkdown as parseIdentityMarkdownFile,
|
||||||
|
} from "../agents/identity-file.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
|
|
||||||
@ -28,14 +30,7 @@ export type AgentSummary = {
|
|||||||
|
|
||||||
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
|
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
|
||||||
|
|
||||||
export type AgentIdentity = {
|
export type AgentIdentity = AgentIdentityFile;
|
||||||
name?: string;
|
|
||||||
emoji?: string;
|
|
||||||
creature?: string;
|
|
||||||
vibe?: string;
|
|
||||||
theme?: string;
|
|
||||||
avatar?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
|
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
|
||||||
const list = cfg.agents?.list;
|
const list = cfg.agents?.list;
|
||||||
@ -74,47 +69,13 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseIdentityMarkdown(content: string): AgentIdentity {
|
export function parseIdentityMarkdown(content: string): AgentIdentity {
|
||||||
const identity: AgentIdentity = {};
|
return parseIdentityMarkdownFile(content);
|
||||||
const lines = content.split(/\r?\n/);
|
|
||||||
for (const line of lines) {
|
|
||||||
const cleaned = line.trim().replace(/^\s*-\s*/, "");
|
|
||||||
const colonIndex = cleaned.indexOf(":");
|
|
||||||
if (colonIndex === -1) continue;
|
|
||||||
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
|
|
||||||
const value = cleaned
|
|
||||||
.slice(colonIndex + 1)
|
|
||||||
.replace(/^[*_]+|[*_]+$/g, "")
|
|
||||||
.trim();
|
|
||||||
if (!value) continue;
|
|
||||||
if (label === "name") identity.name = value;
|
|
||||||
if (label === "emoji") identity.emoji = value;
|
|
||||||
if (label === "creature") identity.creature = value;
|
|
||||||
if (label === "vibe") identity.vibe = value;
|
|
||||||
if (label === "theme") identity.theme = value;
|
|
||||||
if (label === "avatar") identity.avatar = value;
|
|
||||||
}
|
|
||||||
return identity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAgentIdentity(workspace: string): AgentIdentity | null {
|
export function loadAgentIdentity(workspace: string): AgentIdentity | null {
|
||||||
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
|
const parsed = loadAgentIdentityFromWorkspace(workspace);
|
||||||
try {
|
if (!parsed) return null;
|
||||||
const content = fs.readFileSync(identityPath, "utf-8");
|
return identityHasValues(parsed) ? parsed : null;
|
||||||
const parsed = parseIdentityMarkdown(content);
|
|
||||||
if (
|
|
||||||
!parsed.name &&
|
|
||||||
!parsed.emoji &&
|
|
||||||
!parsed.theme &&
|
|
||||||
!parsed.creature &&
|
|
||||||
!parsed.vibe &&
|
|
||||||
!parsed.avatar
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {
|
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {
|
||||||
|
|||||||
@ -54,7 +54,13 @@ describe("agents set-identity command", () => {
|
|||||||
await fs.mkdir(workspace, { recursive: true });
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(workspace, "IDENTITY.md"),
|
path.join(workspace, "IDENTITY.md"),
|
||||||
["- Name: Clawd", "- Creature: helpful sloth", "- Emoji: :)", ""].join("\n"),
|
[
|
||||||
|
"- Name: Clawd",
|
||||||
|
"- Creature: helpful sloth",
|
||||||
|
"- Emoji: :)",
|
||||||
|
"- Avatar: avatars/clawd.png",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -81,6 +87,7 @@ describe("agents set-identity command", () => {
|
|||||||
name: "Clawd",
|
name: "Clawd",
|
||||||
theme: "helpful sloth",
|
theme: "helpful sloth",
|
||||||
emoji: ":)",
|
emoji: ":)",
|
||||||
|
avatar: "avatars/clawd.png",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -115,7 +122,13 @@ describe("agents set-identity command", () => {
|
|||||||
await fs.mkdir(workspace, { recursive: true });
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(workspace, "IDENTITY.md"),
|
path.join(workspace, "IDENTITY.md"),
|
||||||
["- Name: Clawd", "- Theme: space lobster", "- Emoji: :)", ""].join("\n"),
|
[
|
||||||
|
"- Name: Clawd",
|
||||||
|
"- Theme: space lobster",
|
||||||
|
"- Emoji: :)",
|
||||||
|
"- Avatar: avatars/clawd.png",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -125,7 +138,13 @@ describe("agents set-identity command", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await agentsSetIdentityCommand(
|
await agentsSetIdentityCommand(
|
||||||
{ workspace, fromIdentity: true, name: "Nova", emoji: "🦞" },
|
{
|
||||||
|
workspace,
|
||||||
|
fromIdentity: true,
|
||||||
|
name: "Nova",
|
||||||
|
emoji: "🦞",
|
||||||
|
avatar: "https://example.com/override.png",
|
||||||
|
},
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -137,6 +156,7 @@ describe("agents set-identity command", () => {
|
|||||||
name: "Nova",
|
name: "Nova",
|
||||||
theme: "space lobster",
|
theme: "space lobster",
|
||||||
emoji: "🦞",
|
emoji: "🦞",
|
||||||
|
avatar: "https://example.com/override.png",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -147,9 +167,13 @@ describe("agents set-identity command", () => {
|
|||||||
await fs.mkdir(workspace, { recursive: true });
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
identityPath,
|
identityPath,
|
||||||
["- **Name:** C-3PO", "- **Creature:** Flustered Protocol Droid", "- **Emoji:** 🤖", ""].join(
|
[
|
||||||
"\n",
|
"- **Name:** C-3PO",
|
||||||
),
|
"- **Creature:** Flustered Protocol Droid",
|
||||||
|
"- **Emoji:** 🤖",
|
||||||
|
"- **Avatar:** avatars/c3po.png",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -168,6 +192,53 @@ describe("agents set-identity command", () => {
|
|||||||
name: "C-3PO",
|
name: "C-3PO",
|
||||||
theme: "Flustered Protocol Droid",
|
theme: "Flustered Protocol Droid",
|
||||||
emoji: "🤖",
|
emoji: "🤖",
|
||||||
|
avatar: "avatars/c3po.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts avatar-only identity from IDENTITY.md", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-"));
|
||||||
|
const workspace = path.join(root, "work");
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspace, "IDENTITY.md"),
|
||||||
|
"- Avatar: avatars/only.png\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
...baseSnapshot,
|
||||||
|
config: { agents: { list: [{ id: "main", workspace }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await agentsSetIdentityCommand({ workspace, fromIdentity: true }, runtime);
|
||||||
|
|
||||||
|
const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||||
|
agents?: { list?: Array<{ id: string; identity?: Record<string, string> }> };
|
||||||
|
};
|
||||||
|
const main = written.agents?.list?.find((entry) => entry.id === "main");
|
||||||
|
expect(main?.identity).toEqual({
|
||||||
|
avatar: "avatars/only.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts avatar-only updates via flags", async () => {
|
||||||
|
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
...baseSnapshot,
|
||||||
|
config: { agents: { list: [{ id: "main" }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await agentsSetIdentityCommand(
|
||||||
|
{ agent: "main", avatar: "https://example.com/avatar.png" },
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||||
|
agents?: { list?: Array<{ id: string; identity?: Record<string, string> }> };
|
||||||
|
};
|
||||||
|
const main = written.agents?.list?.find((entry) => entry.id === "main");
|
||||||
|
expect(main?.identity).toEqual({
|
||||||
|
avatar: "https://example.com/avatar.png",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -181,6 +181,10 @@ describe("gateway-status command", () => {
|
|||||||
|
|
||||||
expect(startSshPortForward).toHaveBeenCalledTimes(1);
|
expect(startSshPortForward).toHaveBeenCalledTimes(1);
|
||||||
expect(probeGateway).toHaveBeenCalled();
|
expect(probeGateway).toHaveBeenCalled();
|
||||||
|
const tunnelCall = probeGateway.mock.calls.find(
|
||||||
|
(call) => typeof call?.[0]?.url === "string" && call[0].url.startsWith("ws://127.0.0.1:"),
|
||||||
|
)?.[0] as { auth?: { token?: string } } | undefined;
|
||||||
|
expect(tunnelCall?.auth?.token).toBe("rtok");
|
||||||
expect(sshStop).toHaveBeenCalledTimes(1);
|
expect(sshStop).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<string, unknown>;
|
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<string, unknown>;
|
||||||
|
|||||||
@ -134,7 +134,7 @@ export function resolveAuthForTarget(
|
|||||||
return { token: tokenOverride, password: passwordOverride };
|
return { token: tokenOverride, password: passwordOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.kind === "configRemote") {
|
if (target.kind === "configRemote" || target.kind === "sshTunnel") {
|
||||||
const token =
|
const token =
|
||||||
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
|
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
|
||||||
const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;
|
const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;
|
||||||
|
|||||||
54
src/config/config.identity-avatar.test.ts
Normal file
54
src/config/config.identity-avatar.test.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { validateConfigObject } from "./config.js";
|
||||||
|
import { withTempHome } from "./test-helpers.js";
|
||||||
|
|
||||||
|
describe("identity avatar validation", () => {
|
||||||
|
it("accepts workspace-relative avatar paths", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const workspace = path.join(home, "clawd");
|
||||||
|
const res = validateConfigObject({
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "main", workspace, identity: { avatar: "avatars/clawd.png" } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts http(s) and data avatars", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const workspace = path.join(home, "clawd");
|
||||||
|
const httpRes = validateConfigObject({
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "main", workspace, identity: { avatar: "https://example.com/avatar.png" } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(httpRes.ok).toBe(true);
|
||||||
|
|
||||||
|
const dataRes = validateConfigObject({
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "main", workspace, identity: { avatar: "data:image/png;base64,AAA" } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(dataRes.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects avatar paths outside workspace", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const workspace = path.join(home, "clawd");
|
||||||
|
const res = validateConfigObject({
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "main", workspace, identity: { avatar: "../oops.png" } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
expect(res.issues[0]?.path).toBe("agents.list.0.identity.avatar");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -119,6 +119,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
|
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
|
||||||
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
|
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
|
||||||
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
|
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
|
||||||
|
"agents.list.*.identity.avatar": "Identity Avatar",
|
||||||
"gateway.remote.url": "Remote Gateway URL",
|
"gateway.remote.url": "Remote Gateway URL",
|
||||||
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
||||||
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
||||||
@ -260,6 +261,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"commands.restart": "Allow Restart",
|
"commands.restart": "Allow Restart",
|
||||||
"commands.useAccessGroups": "Use Access Groups",
|
"commands.useAccessGroups": "Use Access Groups",
|
||||||
"ui.seamColor": "Accent Color",
|
"ui.seamColor": "Accent Color",
|
||||||
|
"ui.assistant.name": "Assistant Name",
|
||||||
|
"ui.assistant.avatar": "Assistant Avatar",
|
||||||
"browser.controlUrl": "Browser Control URL",
|
"browser.controlUrl": "Browser Control URL",
|
||||||
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||||
@ -315,6 +318,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
|
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
|
||||||
"channels.signal.account": "Signal Account",
|
"channels.signal.account": "Signal Account",
|
||||||
"channels.imessage.cliPath": "iMessage CLI Path",
|
"channels.imessage.cliPath": "iMessage CLI Path",
|
||||||
|
"agents.list[].identity.avatar": "Agent Avatar",
|
||||||
"plugins.enabled": "Enable Plugins",
|
"plugins.enabled": "Enable Plugins",
|
||||||
"plugins.allow": "Plugin Allowlist",
|
"plugins.allow": "Plugin Allowlist",
|
||||||
"plugins.deny": "Plugin Denylist",
|
"plugins.deny": "Plugin Denylist",
|
||||||
@ -344,6 +348,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"gateway.remote.sshTarget":
|
"gateway.remote.sshTarget":
|
||||||
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
|
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
|
||||||
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
|
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
|
||||||
|
"agents.list[].identity.avatar":
|
||||||
|
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
|
||||||
"gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.",
|
"gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.",
|
||||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||||
"gateway.controlUi.basePath":
|
"gateway.controlUi.basePath":
|
||||||
@ -511,6 +517,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
||||||
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
||||||
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
||||||
|
"agents.list.*.identity.avatar":
|
||||||
|
"Agent avatar (workspace-relative path, http(s) URL, or data URI).",
|
||||||
"agents.defaults.model.primary": "Primary model (provider/model).",
|
"agents.defaults.model.primary": "Primary model (provider/model).",
|
||||||
"agents.defaults.model.fallbacks":
|
"agents.defaults.model.fallbacks":
|
||||||
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
||||||
@ -616,6 +624,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
|
|||||||
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
|
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
|
||||||
"gateway.remote.sshTarget": "user@host",
|
"gateway.remote.sshTarget": "user@host",
|
||||||
"gateway.controlUi.basePath": "/clawdbot",
|
"gateway.controlUi.basePath": "/clawdbot",
|
||||||
|
"agents.list[].identity.avatar": "avatars/clawd.png",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
||||||
|
|||||||
@ -154,6 +154,6 @@ export type IdentityConfig = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
/** Path to a custom avatar image (relative to workspace or absolute). */
|
/** Avatar image: workspace-relative path, http(s) URL, or data URI. */
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -65,6 +65,12 @@ export type ClawdbotConfig = {
|
|||||||
ui?: {
|
ui?: {
|
||||||
/** Accent color for Clawdbot UI chrome (hex). */
|
/** Accent color for Clawdbot UI chrome (hex). */
|
||||||
seamColor?: string;
|
seamColor?: string;
|
||||||
|
assistant?: {
|
||||||
|
/** Assistant display name for UI surfaces. */
|
||||||
|
name?: string;
|
||||||
|
/** Assistant avatar (emoji, short text, or image URL/data URI). */
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
skills?: SkillsConfig;
|
skills?: SkillsConfig;
|
||||||
plugins?: PluginsConfig;
|
plugins?: PluginsConfig;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||||
import {
|
import {
|
||||||
@ -13,6 +15,60 @@ import { findLegacyConfigIssues } from "./legacy.js";
|
|||||||
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
|
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
|
||||||
import { ClawdbotSchema } from "./zod-schema.js";
|
import { ClawdbotSchema } from "./zod-schema.js";
|
||||||
|
|
||||||
|
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||||
|
const AVATAR_DATA_RE = /^data:/i;
|
||||||
|
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||||
|
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
|
||||||
|
|
||||||
|
function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean {
|
||||||
|
const workspaceRoot = path.resolve(workspaceDir);
|
||||||
|
const resolved = path.resolve(workspaceRoot, value);
|
||||||
|
const relative = path.relative(workspaceRoot, resolved);
|
||||||
|
if (relative === "") return true;
|
||||||
|
if (relative.startsWith("..")) return false;
|
||||||
|
return !path.isAbsolute(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateIdentityAvatar(config: ClawdbotConfig): ConfigValidationIssue[] {
|
||||||
|
const agents = config.agents?.list;
|
||||||
|
if (!Array.isArray(agents) || agents.length === 0) return [];
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
for (const [index, entry] of agents.entries()) {
|
||||||
|
if (!entry || typeof entry !== "object") continue;
|
||||||
|
const avatarRaw = entry.identity?.avatar;
|
||||||
|
if (typeof avatarRaw !== "string") continue;
|
||||||
|
const avatar = avatarRaw.trim();
|
||||||
|
if (!avatar) continue;
|
||||||
|
if (AVATAR_DATA_RE.test(avatar) || AVATAR_HTTP_RE.test(avatar)) continue;
|
||||||
|
if (avatar.startsWith("~")) {
|
||||||
|
issues.push({
|
||||||
|
path: `agents.list.${index}.identity.avatar`,
|
||||||
|
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hasScheme = AVATAR_SCHEME_RE.test(avatar);
|
||||||
|
if (hasScheme && !WINDOWS_ABS_RE.test(avatar)) {
|
||||||
|
issues.push({
|
||||||
|
path: `agents.list.${index}.identity.avatar`,
|
||||||
|
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(
|
||||||
|
config,
|
||||||
|
entry.id ?? resolveDefaultAgentId(config),
|
||||||
|
);
|
||||||
|
if (!isWorkspaceAvatarPath(avatar, workspaceDir)) {
|
||||||
|
issues.push({
|
||||||
|
path: `agents.list.${index}.identity.avatar`,
|
||||||
|
message: "identity.avatar must stay within the agent workspace.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateConfigObject(
|
export function validateConfigObject(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
): { ok: true; config: ClawdbotConfig } | { ok: false; issues: ConfigValidationIssue[] } {
|
): { ok: true; config: ClawdbotConfig } | { ok: false; issues: ConfigValidationIssue[] } {
|
||||||
@ -48,6 +104,10 @@ export function validateConfigObject(
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const avatarIssues = validateIdentityAvatar(validated.data as ClawdbotConfig);
|
||||||
|
if (avatarIssues.length > 0) {
|
||||||
|
return { ok: false, issues: avatarIssues };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
config: applyModelDefaults(
|
config: applyModelDefaults(
|
||||||
|
|||||||
@ -86,6 +86,7 @@ export const IdentitySchema = z
|
|||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
theme: z.string().optional(),
|
theme: z.string().optional(),
|
||||||
emoji: z.string().optional(),
|
emoji: z.string().optional(),
|
||||||
|
avatar: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|||||||
@ -155,6 +155,13 @@ export const ClawdbotSchema = z
|
|||||||
ui: z
|
ui: z
|
||||||
.object({
|
.object({
|
||||||
seamColor: HexColorSchema.optional(),
|
seamColor: HexColorSchema.optional(),
|
||||||
|
assistant: z
|
||||||
|
.object({
|
||||||
|
name: z.string().max(50).optional(),
|
||||||
|
avatar: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
42
src/gateway/assistant-identity.test.ts
Normal file
42
src/gateway/assistant-identity.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
||||||
|
|
||||||
|
describe("resolveAssistantIdentity avatar normalization", () => {
|
||||||
|
it("drops sentence-like avatar placeholders", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
ui: {
|
||||||
|
assistant: {
|
||||||
|
avatar: "workspace-relative path, http(s) URL, or data URI",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveAssistantIdentity({ cfg }).avatar).toBe(DEFAULT_ASSISTANT_IDENTITY.avatar);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps short text avatars", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
ui: {
|
||||||
|
assistant: {
|
||||||
|
avatar: "PS",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveAssistantIdentity({ cfg }).avatar).toBe("PS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps path avatars", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
ui: {
|
||||||
|
assistant: {
|
||||||
|
avatar: "avatars/clawd.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveAssistantIdentity({ cfg }).avatar).toBe("avatars/clawd.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/gateway/assistant-identity.ts
Normal file
77
src/gateway/assistant-identity.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import { resolveAgentIdentity } from "../agents/identity.js";
|
||||||
|
import { loadAgentIdentity } from "../commands/agents.config.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
|
|
||||||
|
const MAX_ASSISTANT_NAME = 50;
|
||||||
|
const MAX_ASSISTANT_AVATAR = 200;
|
||||||
|
|
||||||
|
export const DEFAULT_ASSISTANT_IDENTITY = {
|
||||||
|
name: "Assistant",
|
||||||
|
avatar: "A",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssistantIdentity = {
|
||||||
|
agentId: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (trimmed.length <= maxLength) return trimmed;
|
||||||
|
return trimmed.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAvatarUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(value) || /^data:image\//i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeAvatarPath(value: string): boolean {
|
||||||
|
if (/[\\/]/.test(value)) return true;
|
||||||
|
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAvatarValue(value: string | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (isAvatarUrl(trimmed)) return trimmed;
|
||||||
|
if (looksLikeAvatarPath(trimmed)) return trimmed;
|
||||||
|
if (!/\s/.test(trimmed) && trimmed.length <= 4) return trimmed;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAssistantIdentity(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
agentId?: string | null;
|
||||||
|
workspaceDir?: string | null;
|
||||||
|
}): AssistantIdentity {
|
||||||
|
const agentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.cfg));
|
||||||
|
const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||||
|
const configAssistant = params.cfg.ui?.assistant;
|
||||||
|
const agentIdentity = resolveAgentIdentity(params.cfg, agentId);
|
||||||
|
const fileIdentity = workspaceDir ? loadAgentIdentity(workspaceDir) : null;
|
||||||
|
|
||||||
|
const name =
|
||||||
|
coerceIdentityValue(configAssistant?.name, MAX_ASSISTANT_NAME) ??
|
||||||
|
coerceIdentityValue(agentIdentity?.name, MAX_ASSISTANT_NAME) ??
|
||||||
|
coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME) ??
|
||||||
|
DEFAULT_ASSISTANT_IDENTITY.name;
|
||||||
|
|
||||||
|
const avatarCandidates = [
|
||||||
|
coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR),
|
||||||
|
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR),
|
||||||
|
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR),
|
||||||
|
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR),
|
||||||
|
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR),
|
||||||
|
];
|
||||||
|
const avatar =
|
||||||
|
avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ??
|
||||||
|
DEFAULT_ASSISTANT_IDENTITY.avatar;
|
||||||
|
|
||||||
|
return { agentId, name, avatar };
|
||||||
|
}
|
||||||
@ -3,10 +3,16 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
||||||
|
|
||||||
const ROOT_PREFIX = "/";
|
const ROOT_PREFIX = "/";
|
||||||
|
const AVATAR_PREFIX = "/avatar";
|
||||||
|
|
||||||
export type ControlUiRequestOptions = {
|
export type ControlUiRequestOptions = {
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
|
config?: ClawdbotConfig;
|
||||||
|
agentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeControlUiBasePath(basePath?: string): string {
|
export function normalizeControlUiBasePath(basePath?: string): string {
|
||||||
@ -62,6 +68,10 @@ function contentTypeForExt(ext: string): string {
|
|||||||
case ".jpg":
|
case ".jpg":
|
||||||
case ".jpeg":
|
case ".jpeg":
|
||||||
return "image/jpeg";
|
return "image/jpeg";
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif";
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp";
|
||||||
case ".ico":
|
case ".ico":
|
||||||
return "image/x-icon";
|
return "image/x-icon";
|
||||||
case ".txt":
|
case ".txt":
|
||||||
@ -71,6 +81,83 @@ function contentTypeForExt(ext: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ControlUiAvatarResolution =
|
||||||
|
| { kind: "none"; reason: string }
|
||||||
|
| { kind: "local"; filePath: string }
|
||||||
|
| { kind: "remote"; url: string }
|
||||||
|
| { kind: "data"; url: string };
|
||||||
|
|
||||||
|
type ControlUiAvatarMeta = {
|
||||||
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAvatarUrl(basePath: string, agentId: string): string {
|
||||||
|
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidAgentId(agentId: string): boolean {
|
||||||
|
return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleControlUiAvatarRequest(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
opts: { basePath?: string; resolveAvatar: (agentId: string) => ControlUiAvatarResolution },
|
||||||
|
): boolean {
|
||||||
|
const urlRaw = req.url;
|
||||||
|
if (!urlRaw) return false;
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
||||||
|
|
||||||
|
const url = new URL(urlRaw, "http://localhost");
|
||||||
|
const basePath = normalizeControlUiBasePath(opts.basePath);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
const pathWithBase = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
|
||||||
|
if (!pathname.startsWith(pathWithBase)) return false;
|
||||||
|
|
||||||
|
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
|
||||||
|
const agentId = agentIdParts[0] ?? "";
|
||||||
|
if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) {
|
||||||
|
respondNotFound(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.searchParams.get("meta") === "1") {
|
||||||
|
const resolved = opts.resolveAvatar(agentId);
|
||||||
|
const avatarUrl =
|
||||||
|
resolved.kind === "local"
|
||||||
|
? buildAvatarUrl(basePath, agentId)
|
||||||
|
: resolved.kind === "remote" || resolved.kind === "data"
|
||||||
|
? resolved.url
|
||||||
|
: null;
|
||||||
|
sendJson(res, 200, { avatarUrl } satisfies ControlUiAvatarMeta);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = opts.resolveAvatar(agentId);
|
||||||
|
if (resolved.kind !== "local") {
|
||||||
|
respondNotFound(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "HEAD") {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", contentTypeForExt(path.extname(resolved.filePath).toLowerCase()));
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
serveFile(res, resolved.filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function respondNotFound(res: ServerResponse) {
|
function respondNotFound(res: ServerResponse) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
@ -86,11 +173,26 @@ function serveFile(res: ServerResponse, filePath: string) {
|
|||||||
res.end(fs.readFileSync(filePath));
|
res.end(fs.readFileSync(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectControlUiBasePath(html: string, basePath: string): string {
|
interface ControlUiInjectionOpts {
|
||||||
const script = `<script>window.__CLAWDBOT_CONTROL_UI_BASE_PATH__=${JSON.stringify(
|
basePath: string;
|
||||||
basePath,
|
assistantName?: string;
|
||||||
)};</script>`;
|
assistantAvatar?: string;
|
||||||
if (html.includes("__CLAWDBOT_CONTROL_UI_BASE_PATH__")) return html;
|
}
|
||||||
|
|
||||||
|
function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): string {
|
||||||
|
const { basePath, assistantName, assistantAvatar } = opts;
|
||||||
|
const script =
|
||||||
|
`<script>` +
|
||||||
|
`window.__CLAWDBOT_CONTROL_UI_BASE_PATH__=${JSON.stringify(basePath)};` +
|
||||||
|
`window.__CLAWDBOT_ASSISTANT_NAME__=${JSON.stringify(
|
||||||
|
assistantName ?? DEFAULT_ASSISTANT_IDENTITY.name,
|
||||||
|
)};` +
|
||||||
|
`window.__CLAWDBOT_ASSISTANT_AVATAR__=${JSON.stringify(
|
||||||
|
assistantAvatar ?? DEFAULT_ASSISTANT_IDENTITY.avatar,
|
||||||
|
)};` +
|
||||||
|
`</script>`;
|
||||||
|
// Check if already injected
|
||||||
|
if (html.includes("__CLAWDBOT_ASSISTANT_NAME__")) return html;
|
||||||
const headClose = html.indexOf("</head>");
|
const headClose = html.indexOf("</head>");
|
||||||
if (headClose !== -1) {
|
if (headClose !== -1) {
|
||||||
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
|
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
|
||||||
@ -98,11 +200,27 @@ function injectControlUiBasePath(html: string, basePath: string): string {
|
|||||||
return `${script}${html}`;
|
return `${script}${html}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serveIndexHtml(res: ServerResponse, indexPath: string, basePath: string) {
|
interface ServeIndexHtmlOpts {
|
||||||
|
basePath: string;
|
||||||
|
config?: ClawdbotConfig;
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
|
||||||
|
const { basePath, config, agentId } = opts;
|
||||||
|
const identity = config
|
||||||
|
? resolveAssistantIdentity({ cfg: config, agentId })
|
||||||
|
: DEFAULT_ASSISTANT_IDENTITY;
|
||||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
const raw = fs.readFileSync(indexPath, "utf8");
|
const raw = fs.readFileSync(indexPath, "utf8");
|
||||||
res.end(injectControlUiBasePath(raw, basePath));
|
res.end(
|
||||||
|
injectControlUiConfig(raw, {
|
||||||
|
basePath,
|
||||||
|
assistantName: identity.name,
|
||||||
|
assistantAvatar: identity.avatar,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSafeRelativePath(relPath: string) {
|
function isSafeRelativePath(relPath: string) {
|
||||||
@ -181,7 +299,11 @@ export function handleControlUiHttpRequest(
|
|||||||
|
|
||||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||||
if (path.basename(filePath) === "index.html") {
|
if (path.basename(filePath) === "index.html") {
|
||||||
serveIndexHtml(res, filePath, basePath);
|
serveIndexHtml(res, filePath, {
|
||||||
|
basePath,
|
||||||
|
config: opts?.config,
|
||||||
|
agentId: opts?.agentId,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
serveFile(res, filePath);
|
serveFile(res, filePath);
|
||||||
@ -191,7 +313,11 @@ export function handleControlUiHttpRequest(
|
|||||||
// SPA fallback (client-side router): serve index.html for unknown paths.
|
// SPA fallback (client-side router): serve index.html for unknown paths.
|
||||||
const indexPath = path.join(root, "index.html");
|
const indexPath = path.join(root, "index.html");
|
||||||
if (fs.existsSync(indexPath)) {
|
if (fs.existsSync(indexPath)) {
|
||||||
serveIndexHtml(res, indexPath, basePath);
|
serveIndexHtml(res, indexPath, {
|
||||||
|
basePath,
|
||||||
|
config: opts?.config,
|
||||||
|
agentId: opts?.agentId,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,10 @@ import AjvPkg, { type ErrorObject } from "ajv";
|
|||||||
import {
|
import {
|
||||||
type AgentEvent,
|
type AgentEvent,
|
||||||
AgentEventSchema,
|
AgentEventSchema,
|
||||||
|
type AgentIdentityParams,
|
||||||
|
AgentIdentityParamsSchema,
|
||||||
|
type AgentIdentityResult,
|
||||||
|
AgentIdentityResultSchema,
|
||||||
AgentParamsSchema,
|
AgentParamsSchema,
|
||||||
type AgentSummary,
|
type AgentSummary,
|
||||||
AgentSummarySchema,
|
AgentSummarySchema,
|
||||||
@ -136,6 +140,8 @@ import {
|
|||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
type SessionsPatchParams,
|
type SessionsPatchParams,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
|
type SessionsPreviewParams,
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
type SessionsResetParams,
|
type SessionsResetParams,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
type SessionsResolveParams,
|
type SessionsResolveParams,
|
||||||
@ -198,6 +204,8 @@ export const validateEventFrame = ajv.compile<EventFrame>(EventFrameSchema);
|
|||||||
export const validateSendParams = ajv.compile(SendParamsSchema);
|
export const validateSendParams = ajv.compile(SendParamsSchema);
|
||||||
export const validatePollParams = ajv.compile<PollParams>(PollParamsSchema);
|
export const validatePollParams = ajv.compile<PollParams>(PollParamsSchema);
|
||||||
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
||||||
|
export const validateAgentIdentityParams =
|
||||||
|
ajv.compile<AgentIdentityParams>(AgentIdentityParamsSchema);
|
||||||
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema);
|
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema);
|
||||||
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
|
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
|
||||||
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema);
|
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema);
|
||||||
@ -223,6 +231,9 @@ export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams
|
|||||||
);
|
);
|
||||||
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
||||||
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
||||||
|
export const validateSessionsPreviewParams = ajv.compile<SessionsPreviewParams>(
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
|
);
|
||||||
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
);
|
);
|
||||||
@ -359,6 +370,8 @@ export {
|
|||||||
SendParamsSchema,
|
SendParamsSchema,
|
||||||
PollParamsSchema,
|
PollParamsSchema,
|
||||||
AgentParamsSchema,
|
AgentParamsSchema,
|
||||||
|
AgentIdentityParamsSchema,
|
||||||
|
AgentIdentityResultSchema,
|
||||||
WakeParamsSchema,
|
WakeParamsSchema,
|
||||||
NodePairRequestParamsSchema,
|
NodePairRequestParamsSchema,
|
||||||
NodePairListParamsSchema,
|
NodePairListParamsSchema,
|
||||||
@ -368,6 +381,7 @@ export {
|
|||||||
NodeListParamsSchema,
|
NodeListParamsSchema,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
SessionsDeleteParamsSchema,
|
SessionsDeleteParamsSchema,
|
||||||
@ -432,6 +446,8 @@ export type {
|
|||||||
ErrorShape,
|
ErrorShape,
|
||||||
StateVersion,
|
StateVersion,
|
||||||
AgentEvent,
|
AgentEvent,
|
||||||
|
AgentIdentityParams,
|
||||||
|
AgentIdentityResult,
|
||||||
AgentWaitParams,
|
AgentWaitParams,
|
||||||
ChatEvent,
|
ChatEvent,
|
||||||
TickEvent,
|
TickEvent,
|
||||||
@ -478,6 +494,7 @@ export type {
|
|||||||
NodeInvokeResultParams,
|
NodeInvokeResultParams,
|
||||||
NodeEventParams,
|
NodeEventParams,
|
||||||
SessionsListParams,
|
SessionsListParams,
|
||||||
|
SessionsPreviewParams,
|
||||||
SessionsResolveParams,
|
SessionsResolveParams,
|
||||||
SessionsPatchParams,
|
SessionsPatchParams,
|
||||||
SessionsResetParams,
|
SessionsResetParams,
|
||||||
|
|||||||
@ -68,6 +68,23 @@ export const AgentParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const AgentIdentityParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
agentId: Type.Optional(NonEmptyString),
|
||||||
|
sessionKey: Type.Optional(Type.String()),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AgentIdentityResultSchema = Type.Object(
|
||||||
|
{
|
||||||
|
agentId: NonEmptyString,
|
||||||
|
name: Type.Optional(NonEmptyString),
|
||||||
|
avatar: Type.Optional(NonEmptyString),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const AgentWaitParamsSchema = Type.Object(
|
export const AgentWaitParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
runId: NonEmptyString,
|
runId: NonEmptyString,
|
||||||
|
|||||||
@ -17,6 +17,18 @@ export const AgentSummarySchema = Type.Object(
|
|||||||
{
|
{
|
||||||
id: NonEmptyString,
|
id: NonEmptyString,
|
||||||
name: Type.Optional(NonEmptyString),
|
name: Type.Optional(NonEmptyString),
|
||||||
|
identity: Type.Optional(
|
||||||
|
Type.Object(
|
||||||
|
{
|
||||||
|
name: Type.Optional(NonEmptyString),
|
||||||
|
theme: Type.Optional(NonEmptyString),
|
||||||
|
emoji: Type.Optional(NonEmptyString),
|
||||||
|
avatar: Type.Optional(NonEmptyString),
|
||||||
|
avatarUrl: Type.Optional(NonEmptyString),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import type { TSchema } from "@sinclair/typebox";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AgentEventSchema,
|
AgentEventSchema,
|
||||||
|
AgentIdentityParamsSchema,
|
||||||
|
AgentIdentityResultSchema,
|
||||||
AgentParamsSchema,
|
AgentParamsSchema,
|
||||||
AgentWaitParamsSchema,
|
AgentWaitParamsSchema,
|
||||||
PollParamsSchema,
|
PollParamsSchema,
|
||||||
@ -106,6 +108,7 @@ import {
|
|||||||
SessionsDeleteParamsSchema,
|
SessionsDeleteParamsSchema,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
} from "./sessions.js";
|
} from "./sessions.js";
|
||||||
@ -136,6 +139,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
SendParams: SendParamsSchema,
|
SendParams: SendParamsSchema,
|
||||||
PollParams: PollParamsSchema,
|
PollParams: PollParamsSchema,
|
||||||
AgentParams: AgentParamsSchema,
|
AgentParams: AgentParamsSchema,
|
||||||
|
AgentIdentityParams: AgentIdentityParamsSchema,
|
||||||
|
AgentIdentityResult: AgentIdentityResultSchema,
|
||||||
AgentWaitParams: AgentWaitParamsSchema,
|
AgentWaitParams: AgentWaitParamsSchema,
|
||||||
WakeParams: WakeParamsSchema,
|
WakeParams: WakeParamsSchema,
|
||||||
NodePairRequestParams: NodePairRequestParamsSchema,
|
NodePairRequestParams: NodePairRequestParamsSchema,
|
||||||
@ -151,6 +156,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
NodeEventParams: NodeEventParamsSchema,
|
NodeEventParams: NodeEventParamsSchema,
|
||||||
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
||||||
SessionsListParams: SessionsListParamsSchema,
|
SessionsListParams: SessionsListParamsSchema,
|
||||||
|
SessionsPreviewParams: SessionsPreviewParamsSchema,
|
||||||
SessionsResolveParams: SessionsResolveParamsSchema,
|
SessionsResolveParams: SessionsResolveParamsSchema,
|
||||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||||
SessionsResetParams: SessionsResetParamsSchema,
|
SessionsResetParams: SessionsResetParamsSchema,
|
||||||
|
|||||||
@ -26,6 +26,15 @@ export const SessionsListParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SessionsPreviewParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
keys: Type.Array(NonEmptyString, { minItems: 1 }),
|
||||||
|
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
|
maxChars: Type.Optional(Type.Integer({ minimum: 20 })),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const SessionsResolveParamsSchema = Type.Object(
|
export const SessionsResolveParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: Type.Optional(NonEmptyString),
|
key: Type.Optional(NonEmptyString),
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import type { Static } from "@sinclair/typebox";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentEventSchema,
|
AgentEventSchema,
|
||||||
|
AgentIdentityParamsSchema,
|
||||||
|
AgentIdentityResultSchema,
|
||||||
AgentWaitParamsSchema,
|
AgentWaitParamsSchema,
|
||||||
PollParamsSchema,
|
PollParamsSchema,
|
||||||
WakeParamsSchema,
|
WakeParamsSchema,
|
||||||
@ -99,6 +101,7 @@ import type {
|
|||||||
SessionsDeleteParamsSchema,
|
SessionsDeleteParamsSchema,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
} from "./sessions.js";
|
} from "./sessions.js";
|
||||||
@ -125,6 +128,8 @@ export type PresenceEntry = Static<typeof PresenceEntrySchema>;
|
|||||||
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
||||||
export type StateVersion = Static<typeof StateVersionSchema>;
|
export type StateVersion = Static<typeof StateVersionSchema>;
|
||||||
export type AgentEvent = Static<typeof AgentEventSchema>;
|
export type AgentEvent = Static<typeof AgentEventSchema>;
|
||||||
|
export type AgentIdentityParams = Static<typeof AgentIdentityParamsSchema>;
|
||||||
|
export type AgentIdentityResult = Static<typeof AgentIdentityResultSchema>;
|
||||||
export type PollParams = Static<typeof PollParamsSchema>;
|
export type PollParams = Static<typeof PollParamsSchema>;
|
||||||
export type AgentWaitParams = Static<typeof AgentWaitParamsSchema>;
|
export type AgentWaitParams = Static<typeof AgentWaitParamsSchema>;
|
||||||
export type WakeParams = Static<typeof WakeParamsSchema>;
|
export type WakeParams = Static<typeof WakeParamsSchema>;
|
||||||
@ -140,6 +145,7 @@ export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
|||||||
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
|
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
|
||||||
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
|
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
|
||||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||||
|
export type SessionsPreviewParams = Static<typeof SessionsPreviewParamsSchema>;
|
||||||
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
||||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||||
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||||
|
|||||||
@ -9,9 +9,11 @@ import type { TlsOptions } from "node:tls";
|
|||||||
import type { WebSocketServer } from "ws";
|
import type { WebSocketServer } from "ws";
|
||||||
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
|
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
|
||||||
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
||||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
|
||||||
|
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
|
||||||
import {
|
import {
|
||||||
extractHookToken,
|
extractHookToken,
|
||||||
getHookChannelError,
|
getHookChannelError,
|
||||||
@ -244,9 +246,17 @@ export function createGatewayHttpServer(opts: {
|
|||||||
if (await canvasHost.handleHttpRequest(req, res)) return;
|
if (await canvasHost.handleHttpRequest(req, res)) return;
|
||||||
}
|
}
|
||||||
if (controlUiEnabled) {
|
if (controlUiEnabled) {
|
||||||
|
if (
|
||||||
|
handleControlUiAvatarRequest(req, res, {
|
||||||
|
basePath: controlUiBasePath,
|
||||||
|
resolveAvatar: (agentId) => resolveAgentAvatar(loadConfig(), agentId),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (
|
if (
|
||||||
handleControlUiHttpRequest(req, res, {
|
handleControlUiHttpRequest(req, res, {
|
||||||
basePath: controlUiBasePath,
|
basePath: controlUiBasePath,
|
||||||
|
config: loadConfig(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -34,6 +34,7 @@ const BASE_METHODS = [
|
|||||||
"voicewake.get",
|
"voicewake.get",
|
||||||
"voicewake.set",
|
"voicewake.set",
|
||||||
"sessions.list",
|
"sessions.list",
|
||||||
|
"sessions.preview",
|
||||||
"sessions.patch",
|
"sessions.patch",
|
||||||
"sessions.reset",
|
"sessions.reset",
|
||||||
"sessions.delete",
|
"sessions.delete",
|
||||||
@ -68,6 +69,7 @@ const BASE_METHODS = [
|
|||||||
"system-event",
|
"system-event",
|
||||||
"send",
|
"send",
|
||||||
"agent",
|
"agent",
|
||||||
|
"agent.identity.get",
|
||||||
"agent.wait",
|
"agent.wait",
|
||||||
// WebChat WebSocket-native chat methods
|
// WebChat WebSocket-native chat methods
|
||||||
"chat.history",
|
"chat.history",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user