Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke 2026-01-08 21:47:09 -05:00 committed by GitHub
commit e52a2888cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1265 additions and 268 deletions

View File

@ -24,7 +24,10 @@
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123 - Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
- Auto-reply: fix /status usage summary filtering for the active provider.
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
- Status: show active auth profile and key snippet in /status.
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
@ -56,7 +59,15 @@
- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj - Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
- Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy - Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne - TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
- Status: show Verbose/Elevated only when enabled.
- Status: filter usage summary to the active model provider.
- Status: map model providers to usage sources so unrelated usage doesnt appear.
- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
- Commands: keep multi-directive messages from clearing directive handling.
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
- Commands: return /status in directive-only multi-line messages.
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
## 2026.1.8 ## 2026.1.8

View File

@ -240,11 +240,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only): Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
- `/status`health + session info (group shows activation mode) - `/status`compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session - `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary) - `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high - `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off` - `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups) - `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only) - `/activation mention|always` — group activation toggle (groups only)
@ -459,11 +460,11 @@ Thanks to all clawtributors:
<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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/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/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/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/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/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/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/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/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/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/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/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/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/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/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/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/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/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/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/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/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/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/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/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/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/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/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/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/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/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/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/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/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=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/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/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/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/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/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/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/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=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/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/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/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/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/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/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/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/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/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></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/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/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/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/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/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/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/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></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=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></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/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/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/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></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>

View File

@ -664,19 +664,22 @@ public struct SessionsListParams: Codable, Sendable {
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
spawnedby: String? spawnedby: String?,
agentid: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
@ -684,6 +687,7 @@ public struct SessionsListParams: Codable, Sendable {
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId"
} }
} }
@ -692,6 +696,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let thinkinglevel: AnyCodable? public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable? public let verboselevel: AnyCodable?
public let reasoninglevel: AnyCodable? public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable?
public let elevatedlevel: AnyCodable? public let elevatedlevel: AnyCodable?
public let model: AnyCodable? public let model: AnyCodable?
public let spawnedby: AnyCodable? public let spawnedby: AnyCodable?
@ -703,6 +708,7 @@ public struct SessionsPatchParams: Codable, Sendable {
thinkinglevel: AnyCodable?, thinkinglevel: AnyCodable?,
verboselevel: AnyCodable?, verboselevel: AnyCodable?,
reasoninglevel: AnyCodable?, reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
elevatedlevel: AnyCodable?, elevatedlevel: AnyCodable?,
model: AnyCodable?, model: AnyCodable?,
spawnedby: AnyCodable?, spawnedby: AnyCodable?,
@ -713,6 +719,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.thinkinglevel = thinkinglevel self.thinkinglevel = thinkinglevel
self.verboselevel = verboselevel self.verboselevel = verboselevel
self.reasoninglevel = reasoninglevel self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
self.elevatedlevel = elevatedlevel self.elevatedlevel = elevatedlevel
self.model = model self.model = model
self.spawnedby = spawnedby self.spawnedby = spawnedby
@ -724,6 +731,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case thinkinglevel = "thinkingLevel" case thinkinglevel = "thinkingLevel"
case verboselevel = "verboseLevel" case verboselevel = "verboseLevel"
case reasoninglevel = "reasoningLevel" case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"
case elevatedlevel = "elevatedLevel" case elevatedlevel = "elevatedLevel"
case model case model
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
@ -1100,6 +1108,51 @@ public struct WebLoginWaitParams: Codable, Sendable {
} }
} }
public struct AgentSummary: Codable, Sendable {
public let id: String
public let name: String?
public init(
id: String,
name: String?
) {
self.id = id
self.name = name
}
private enum CodingKeys: String, CodingKey {
case id
case name
}
}
public struct AgentsListParams: Codable, Sendable {
}
public struct AgentsListResult: Codable, Sendable {
public let defaultid: String
public let mainkey: String
public let scope: AnyCodable
public let agents: [AgentSummary]
public init(
defaultid: String,
mainkey: String,
scope: AnyCodable,
agents: [AgentSummary]
) {
self.defaultid = defaultid
self.mainkey = mainkey
self.scope = scope
self.agents = agents
}
private enum CodingKeys: String, CodingKey {
case defaultid = "defaultId"
case mainkey = "mainKey"
case scope
case agents
}
}
public struct ModelChoice: Codable, Sendable { public struct ModelChoice: Codable, Sendable {
public let id: String public let id: String
public let name: String public let name: String

View File

@ -16,6 +16,8 @@ sessions.
resolve relative paths against the workspace, but absolute paths can still reach resolve relative paths against the workspace, but absolute paths can still reach
elsewhere on the host unless sandboxing is enabled. If you need isolation, use elsewhere on the host unless sandboxing is enabled. If you need isolation, use
[`agent.sandbox`](/gateway/sandboxing) (and/or peragent sandbox config). [`agent.sandbox`](/gateway/sandboxing) (and/or peragent sandbox config).
When sandboxing is enabled and `workspaceAccess` is not `"rw"`, tools operate
inside a sandbox workspace under `~/.clawdbot/sandboxes`, not your host workspace.
## Default location ## Default location

View File

@ -11,7 +11,8 @@ read_when:
- No estimated costs; only the provider-reported windows. - No estimated costs; only the provider-reported windows.
## Where it shows up ## Where it shows up
- `/status` in chats: adds a short “Usage” line (only if available). - `/status` in chats: compact oneliner with session tokens + estimated cost (API key only) and provider quota windows when available.
- `/cost on|off` in chats: toggles perresponse usage lines (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot providers list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - CLI: `clawdbot providers list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: “Usage” section under Context (only if available). - macOS menu bar: “Usage” section under Context (only if available).

View File

@ -556,6 +556,7 @@
"concepts/agent", "concepts/agent",
"concepts/agent-loop", "concepts/agent-loop",
"concepts/system-prompt", "concepts/system-prompt",
"token-use",
"concepts/oauth", "concepts/oauth",
"concepts/agent-workspace", "concepts/agent-workspace",
"concepts/multi-agent", "concepts/multi-agent",

View File

@ -1473,6 +1473,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
Fields: Fields:
- `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`. - `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`.
- Sandbox note: `agent.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed.
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. - `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.

View File

@ -31,6 +31,8 @@ Not sandboxed:
- `"off"`: no sandboxing. - `"off"`: no sandboxing.
- `"non-main"`: sandbox only **non-main** sessions (default if you want normal chats on host). - `"non-main"`: sandbox only **non-main** sessions (default if you want normal chats on host).
- `"all"`: every session runs in a sandbox. - `"all"`: every session runs in a sandbox.
Note: `"non-main"` is based on `session.mainKey` (default `"main"`), not agent id.
Group/channel sessions use their own keys, so they count as non-main and will be sandboxed.
## Scope ## Scope
`agent.sandbox.scope` controls **how many containers** are created: `agent.sandbox.scope` controls **how many containers** are created:

View File

@ -122,6 +122,19 @@ or state drift because only one workspace is active.
**Fix:** keep a single active workspace and archive/remove the rest. See **Fix:** keep a single active workspace and archive/remove the rest. See
[Agent workspace](/concepts/agent-workspace#legacy-workspace-folders). [Agent workspace](/concepts/agent-workspace#legacy-workspace-folders).
### Main chat running in a sandbox workspace
Symptoms: `pwd` or file tools show `~/.clawdbot/sandboxes/...` even though you
expected the host workspace.
**Why:** `agent.sandbox.mode: "non-main"` keys off `session.mainKey` (default `"main"`).
Group/channel sessions use their own keys, so they are treated as non-main and
get sandbox workspaces.
**Fix options:**
- If you want host workspaces for an agent: set `routing.agents.<id>.sandbox.mode: "off"`.
- If you want host workspace access inside sandbox: set `workspaceAccess: "rw"` for that agent.
### "Agent was aborted" ### "Agent was aborted"
The agent was interrupted mid-response. The agent was interrupted mid-response.

View File

@ -252,6 +252,15 @@ The global `agent.workspace` and `agent.sandbox` are still supported for backwar
--- ---
## Common Pitfall: "non-main"
`sandbox.mode: "non-main"` is based on `session.mainKey` (default `"main"`),
not the agent id. Group/channel sessions always get their own keys, so they
are treated as non-main and will be sandboxed. If you want an agent to never
sandbox, set `routing.agents.<id>.sandbox.mode: "off"`.
---
## Testing ## Testing
After configuring multi-agent sandbox and tools: After configuring multi-agent sandbox and tools:

View File

@ -19,6 +19,23 @@ Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It set
If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security). If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security).
Sandboxing note: `agent.sandbox.mode: "non-main"` uses `session.mainKey` (default `"main"`),
so group/channel sessions are sandboxed. If you want the main agent to always
run on host, set an explicit per-agent override:
```json
{
"routing": {
"agents": {
"main": {
"workspace": "~/clawd",
"sandbox": { "mode": "off" }
}
}
}
}
```
## 0) Prereqs ## 0) Prereqs
- Node `>=22` - Node `>=22`

72
docs/token-use.md Normal file
View File

@ -0,0 +1,72 @@
---
summary: "How Clawdbot builds prompt context and reports token usage + costs"
read_when:
- Explaining token usage, costs, or context windows
- Debugging context growth or compaction behavior
---
# Token use & costs
Clawdbot tracks **tokens**, not characters. Tokens are model-specific, but most
OpenAI-style models average ~4 characters per token for English text.
## How the system prompt is built
Clawdbot assembles its own system prompt on every run. It includes:
- Tool list + short descriptions
- Skills list (only metadata; instructions are loaded on demand with `read`)
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new)
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)
See the full breakdown in [System Prompt](/concepts/system-prompt).
## What counts in the context window
Everything the model receives counts toward the context limit:
- System prompt (all sections listed above)
- Conversation history (user + assistant messages)
- Tool calls and tool results
- Attachments/transcripts (images, audio, files)
- Compaction summaries and pruning artifacts
- Provider wrappers or safety headers (not visible, but still counted)
## How to see current token usage
Use these in chat:
- `/status`**compact oneliner** with the session model, context usage,
last response input/output tokens, and **estimated cost** (API key only).
- `/cost on|off` → appends a **per-response usage line** to every reply.
- Persists per session (stored as `responseUsage`).
- OAuth auth **hides cost** (tokens only).
Other surfaces:
- **TUI/Web TUI:** `/status` + `/cost` are supported.
- **CLI:** `clawdbot status --usage` and `clawdbot providers list` show
provider quota windows (not per-response costs).
## Cost estimation (when shown)
Costs are estimated from your model pricing config:
```
models.providers.<provider>.models[].cost
```
These are **USD per 1M tokens** for `input`, `output`, `cacheRead`, and
`cacheWrite`. If pricing is missing, Clawdbot shows tokens only. OAuth tokens
never show dollar cost.
## Tips for reducing token pressure
- Use `/compact` to summarize long sessions.
- Trim large tool outputs in your workflows.
- Keep skill descriptions short (skill list is injected into the prompt).
- Prefer smaller models for verbose, exploratory work.
See [Skills](/tools/skills) for the exact skill list overhead formula.

View File

@ -163,6 +163,23 @@ This is **scoped to the agent run**, not a global shell environment.
Clawdbot snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session. Clawdbot snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session.
## Token impact (skills list)
When skills are eligible, Clawdbot injects a compact XML list of available skills into the system prompt (via `formatSkillsForPrompt` in `pi-coding-agent`). The cost is deterministic:
- **Base overhead (only when ≥1 skill):** 195 characters.
- **Per skill:** 97 characters + the length of the XML-escaped `<name>`, `<description>`, and `<location>` values.
Formula (characters):
```
total = 195 + Σ (97 + len(name_escaped) + len(description_escaped) + len(location_escaped))
```
Notes:
- XML escaping expands `& < > " '` into entities (`&amp;`, `&lt;`, etc.), increasing length.
- Token counts vary by model tokenizer. A rough OpenAI-style estimate is ~4 chars/token, so **97 chars ≈ 24 tokens** per skill plus your actual field lengths.
## Managed skills lifecycle ## Managed skills lifecycle
Clawdbot ships a baseline set of skills as **bundled skills** as part of the Clawdbot ships a baseline set of skills as **bundled skills** as part of the

View File

@ -36,6 +36,7 @@ Text + native (when enabled):
- `/help` - `/help`
- `/commands` - `/commands`
- `/status` - `/status`
- `/cost on|off` (toggle per-response usage line)
- `/stop` - `/stop`
- `/restart` - `/restart`
- `/activation mention|always` (groups only) - `/activation mention|always` (groups only)
@ -53,6 +54,7 @@ Text-only:
Notes: Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost).
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.

View File

@ -77,6 +77,7 @@ Session controls:
- `/think <off|minimal|low|medium|high>` - `/think <off|minimal|low|medium|high>`
- `/verbose <on|off>` - `/verbose <on|off>`
- `/reasoning <on|off|stream>` - `/reasoning <on|off|stream>`
- `/cost <on|off>`
- `/elevated <on|off>` (alias: `/elev`) - `/elevated <on|off>` (alias: `/elev`)
- `/activation <mention|always>` - `/activation <mention|always>`
- `/deliver <on|off>` - `/deliver <on|off>`

View File

@ -53,6 +53,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/think <off|minimal|low|medium|high>` - `/think <off|minimal|low|medium|high>`
- `/verbose <on|off>` - `/verbose <on|off>`
- `/reasoning <on|off|stream>` (stream = Telegram draft only) - `/reasoning <on|off|stream>` (stream = Telegram draft only)
- `/cost <on|off>`
- `/elevated <on|off>` - `/elevated <on|off>`
- `/elev <on|off>` - `/elev <on|off>`
- `/activation <mention|always>` - `/activation <mention|always>`

View File

@ -1,6 +1,7 @@
{ {
"ensureLogins": [ "ensureLogins": [
"jdrhyne", "jdrhyne",
"latitudeki5223",
"manmal" "manmal"
], ],
"seedCommit": "d6863f87", "seedCommit": "d6863f87",

View File

@ -100,6 +100,7 @@ export async function resolveApiKeyForProvider(params: {
} }
export type EnvApiKeyResult = { apiKey: string; source: string }; export type EnvApiKeyResult = { apiKey: string; source: string };
export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown";
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const applied = new Set(getShellEnvAppliedKeys()); const applied = new Set(getShellEnvAppliedKeys());
@ -143,6 +144,37 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
return pick(envVar); return pick(envVar);
} }
export function resolveModelAuthMode(
provider?: string,
cfg?: ClawdbotConfig,
store?: AuthProfileStore,
): ModelAuthMode | undefined {
const resolved = provider?.trim();
if (!resolved) return undefined;
const authStore = store ?? ensureAuthProfileStore();
const profiles = listProfilesForProvider(authStore, resolved);
if (profiles.length > 0) {
const modes = new Set(
profiles
.map((id) => authStore.profiles[id]?.type)
.filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
);
if (modes.has("oauth") && modes.has("api_key")) return "mixed";
if (modes.has("oauth")) return "oauth";
if (modes.has("api_key")) return "api-key";
}
const envKey = resolveEnvApiKey(resolved);
if (envKey?.apiKey) {
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
}
if (getCustomProviderApiKey(cfg, resolved)) return "api-key";
return "unknown";
}
export async function getApiKeyForModel(params: { export async function getApiKeyForModel(params: {
model: Model<Api>; model: Model<Api>;
cfg?: ClawdbotConfig; cfg?: ClawdbotConfig;

View File

@ -224,6 +224,11 @@ export function buildAgentSystemPrompt(params: {
"- [[reply_to:<id>]] replies to a specific message id when you have it.", "- [[reply_to:<id>]] replies to a specific message id when you have it.",
"Tags are stripped before sending; support depends on the current provider config.", "Tags are stripped before sending; support depends on the current provider config.",
"", "",
"## Messaging",
"- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use bash/curl for provider messaging; Clawdbot handles all routing internally.",
"",
]; ];
if (extraSystemPrompt) { if (extraSystemPrompt) {

View File

@ -34,6 +34,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
description: "Show current status.", description: "Show current status.",
textAliases: ["/status"], textAliases: ["/status"],
}, },
{
key: "cost",
nativeName: "cost",
description: "Toggle per-response usage line.",
textAliases: ["/cost"],
acceptsArgs: true,
},
{ {
key: "stop", key: "stop",
nativeName: "stop", nativeName: "stop",

View File

@ -472,6 +472,40 @@ describe("directive behavior", () => {
}); });
}); });
it("warns when elevated is used in direct runtime", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/elevated off",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
sandbox: { mode: "off" },
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("Runtime is direct; sandboxing does not apply.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("rejects invalid elevated level", async () => { it("rejects invalid elevated level", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
@ -504,6 +538,72 @@ describe("directive behavior", () => {
}); });
}); });
it("handles multiple directives in a single message", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/elevated off\n/verbose on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("Verbose logging enabled.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("returns status alongside directive-only acks", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/elevated off\n/status",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("status agent:main:main");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("acks queue directive and persists override", async () => { it("acks queue directive and persists override", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();

View File

@ -14,6 +14,17 @@ vi.mock("../agents/pi-embedded.js", () => ({
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
})); }));
const usageMocks = vi.hoisted(() => ({
loadProviderUsageSummary: vi.fn().mockResolvedValue({
updatedAt: 0,
providers: [],
}),
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
}));
vi.mock("../infra/provider-usage.js", () => usageMocks);
import { import {
abortEmbeddedPiRun, abortEmbeddedPiRun,
compactEmbeddedPiSession, compactEmbeddedPiSession,
@ -66,6 +77,30 @@ afterEach(() => {
}); });
describe("trigger handling", () => { describe("trigger handling", () => {
it("filters usage summary to the current model provider", async () => {
await withTempHome(async (home) => {
usageMocks.loadProviderUsageSummary.mockClear();
const res = await getReplyFromConfig(
{
Body: "/status",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("📊 Usage: Claude 80% left");
expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ providers: ["anthropic"] }),
);
});
});
it("aborts even with timestamp prefix", async () => { it("aborts even with timestamp prefix", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
@ -178,7 +213,71 @@ describe("trigger handling", () => {
makeCfg(home), makeCfg(home),
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("ClawdBot"); expect(text).toContain("status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("reports active auth profile and key snippet in status", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const agentDir = join(home, ".clawdbot", "agents", "main", "agent");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-test-1234567890abcdef",
},
},
lastGood: { anthropic: "anthropic:work" },
},
null,
2,
),
);
const sessionKey = resolveSessionKey("per-sender", {
From: "+1002",
To: "+2000",
Provider: "whatsapp",
} as Parameters<typeof resolveSessionKey>[1]);
await fs.writeFile(
cfg.session.store,
JSON.stringify(
{
[sessionKey]: {
sessionId: "session-auth",
updatedAt: Date.now(),
authProfileOverride: "anthropic:work",
},
},
null,
2,
),
);
const res = await getReplyFromConfig(
{
Body: "/status",
From: "+1002",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1002",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("🔑 api-key");
expect(text).toContain("…");
expect(text).toContain("(anthropic:work)");
expect(text).not.toContain("mixed");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@ -383,6 +482,48 @@ describe("trigger handling", () => {
}); });
}); });
it("allows elevated off in groups without mention", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated off",
From: "group:123@g.us",
To: "whatsapp:+2000",
Provider: "whatsapp",
SenderE164: "+1000",
ChatType: "group",
WasMentioned: false,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
});
});
it("allows elevated directive in groups when mentioned", async () => { it("allows elevated directive in groups when mentioned", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = { const cfg = {

View File

@ -40,7 +40,11 @@ import { getAbortMemory } from "./reply/abort.js";
import { runReplyAgent } from "./reply/agent-runner.js"; import { runReplyAgent } from "./reply/agent-runner.js";
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js"; import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
import { applySessionHints } from "./reply/body.js"; import { applySessionHints } from "./reply/body.js";
import { buildCommandContext, handleCommands } from "./reply/commands.js"; import {
buildCommandContext,
buildStatusReply,
handleCommands,
} from "./reply/commands.js";
import { import {
handleDirectiveOnly, handleDirectiveOnly,
type InlineDirectives, type InlineDirectives,
@ -329,11 +333,23 @@ export async function getReplyFromConfig(
.map((entry) => entry.alias?.trim()) .map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias)) .filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase())); .filter((alias) => !reservedCommands.has(alias.toLowerCase()));
const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true;
let parsedDirectives = parseInlineDirectives(rawBody, { let parsedDirectives = parseInlineDirectives(rawBody, {
modelAliases: configuredAliases, modelAliases: configuredAliases,
disableElevated: disableElevatedInGroup,
}); });
if (
isGroup &&
ctx.WasMentioned !== true &&
parsedDirectives.hasElevatedDirective
) {
if (parsedDirectives.elevatedLevel !== "off") {
parsedDirectives = {
...parsedDirectives,
hasElevatedDirective: false,
elevatedLevel: undefined,
rawElevatedLevel: undefined,
};
}
}
const hasDirective = const hasDirective =
parsedDirectives.hasThinkDirective || parsedDirectives.hasThinkDirective ||
parsedDirectives.hasVerboseDirective || parsedDirectives.hasVerboseDirective ||
@ -348,7 +364,12 @@ export async function getReplyFromConfig(
? stripMentions(stripped, ctx, cfg, agentId) ? stripMentions(stripped, ctx, cfg, agentId)
: stripped; : stripped;
if (noMentions.trim().length > 0) { if (noMentions.trim().length > 0) {
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); const directiveOnlyCheck = parseInlineDirectives(noMentions, {
modelAliases: configuredAliases,
});
if (directiveOnlyCheck.cleaned.trim().length > 0) {
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
}
} }
} }
const directives = commandAuthorized const directives = commandAuthorized
@ -465,6 +486,21 @@ export async function getReplyFromConfig(
? undefined ? undefined
: directives.rawModelDirective; : directives.rawModelDirective;
const command = buildCommandContext({
ctx,
cfg,
agentId,
sessionKey,
isGroup,
triggerBodyNormalized,
commandAuthorized,
});
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: command.surface,
commandSource: ctx.CommandSource,
});
if ( if (
isDirectiveOnly({ isDirectiveOnly({
directives, directives,
@ -510,8 +546,36 @@ export async function getReplyFromConfig(
currentReasoningLevel, currentReasoningLevel,
currentElevatedLevel, currentElevatedLevel,
}); });
let statusReply: ReplyPayload | undefined;
if (directives.hasStatusDirective && allowTextCommands) {
statusReply = await buildStatusReply({
cfg,
command,
sessionEntry,
sessionKey,
sessionScope,
provider,
model,
contextTokens,
resolvedThinkLevel:
currentThinkLevel ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined),
resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel,
resolvedReasoningLevel: (currentReasoningLevel ??
"off") as ReasoningLevel,
resolvedElevatedLevel: currentElevatedLevel,
resolveDefaultThinkingLevel: async () =>
currentThinkLevel ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined),
isGroup,
defaultGroupActivation: () => defaultActivation,
});
}
typing.cleanup(); typing.cleanup();
return directiveReply; if (statusReply?.text && directiveReply?.text) {
return { text: `${directiveReply.text}\n${statusReply.text}` };
}
return statusReply ?? directiveReply;
} }
const persisted = await persistInlineDirectives({ const persisted = await persistInlineDirectives({
@ -551,20 +615,6 @@ export async function getReplyFromConfig(
} }
: undefined; : undefined;
const command = buildCommandContext({
ctx,
cfg,
agentId,
sessionKey,
isGroup,
triggerBodyNormalized,
commandAuthorized,
});
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: command.surface,
commandSource: ctx.CommandSource,
});
const isEmptyConfig = Object.keys(cfg).length === 0; const isEmptyConfig = Object.keys(cfg).length === 0;
if ( if (
command.isWhatsAppProvider && command.isWhatsAppProvider &&

View File

@ -2,12 +2,13 @@ import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { lookupContextTokens } from "../../agents/context.js"; import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runWithModelFallback } from "../../agents/model-fallback.js";
import { import {
queueEmbeddedPiMessage, queueEmbeddedPiMessage,
runEmbeddedPiAgent, runEmbeddedPiAgent,
} from "../../agents/pi-embedded.js"; } from "../../agents/pi-embedded.js";
import { hasNonzeroUsage } from "../../agents/usage.js"; import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
import { import {
loadSessionStore, loadSessionStore,
resolveSessionTranscriptPath, resolveSessionTranscriptPath,
@ -18,6 +19,12 @@ import type { TypingMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { registerAgentRunContext } from "../../infra/agent-events.js"; import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import {
estimateUsageCost,
formatTokenCount,
formatUsd,
resolveModelCostConfig,
} from "../../utils/usage-format.js";
import { stripHeartbeatToken } from "../heartbeat.js"; import { stripHeartbeatToken } from "../heartbeat.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js"; import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
@ -62,6 +69,65 @@ const formatBunFetchSocketError = (message: string) => {
].join("\n"); ].join("\n");
}; };
const formatResponseUsageLine = (params: {
usage?: NormalizedUsage;
showCost: boolean;
costConfig?: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
}): string | null => {
const usage = params.usage;
if (!usage) return null;
const input = usage.input;
const output = usage.output;
if (typeof input !== "number" && typeof output !== "number") return null;
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
const outputLabel =
typeof output === "number" ? formatTokenCount(output) : "?";
const cost =
params.showCost && typeof input === "number" && typeof output === "number"
? estimateUsageCost({
usage: {
input,
output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
},
cost: params.costConfig,
})
: undefined;
const costLabel = params.showCost ? formatUsd(cost) : undefined;
const suffix = costLabel ? ` · est ${costLabel}` : "";
return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`;
};
const appendUsageLine = (
payloads: ReplyPayload[],
line: string,
): ReplyPayload[] => {
let index = -1;
for (let i = payloads.length - 1; i >= 0; i -= 1) {
if (payloads[i]?.text) {
index = i;
break;
}
}
if (index === -1) return [...payloads, { text: line }];
const existing = payloads[index];
const existingText = existing.text ?? "";
const separator = existingText.endsWith("\n") ? "" : "\n";
const next = {
...existing,
text: `${existingText}${separator}${line}`,
};
const updated = payloads.slice();
updated[index] = next;
return updated;
};
const withTimeout = async <T>( const withTimeout = async <T>(
promise: Promise<T>, promise: Promise<T>,
timeoutMs: number, timeoutMs: number,
@ -191,6 +257,7 @@ export async function runReplyAgent(params: {
replyToChannel, replyToChannel,
); );
const applyReplyToMode = createReplyToModeFilter(replyToMode); const applyReplyToMode = createReplyToModeFilter(replyToMode);
const cfg = followupRun.run.config;
if (shouldSteer && isStreaming) { if (shouldSteer && isStreaming) {
const steered = queueEmbeddedPiMessage( const steered = queueEmbeddedPiMessage(
@ -242,6 +309,7 @@ export async function runReplyAgent(params: {
let didLogHeartbeatStrip = false; let didLogHeartbeatStrip = false;
let autoCompactionCompleted = false; let autoCompactionCompleted = false;
let responseUsageLine: string | undefined;
try { try {
const runId = crypto.randomUUID(); const runId = crypto.randomUUID();
if (sessionKey) { if (sessionKey) {
@ -641,20 +709,20 @@ export async function runReplyAgent(params: {
await typingSignals.signalRunStart(); await typingSignals.signalRunStart();
} }
if (sessionStore && sessionKey) { const usage = runResult.meta.agentMeta?.usage;
const usage = runResult.meta.agentMeta?.usage; const modelUsed =
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; const providerUsed =
const providerUsed = runResult.meta.agentMeta?.provider ??
runResult.meta.agentMeta?.provider ?? fallbackProvider ??
fallbackProvider ?? followupRun.run.provider;
followupRun.run.provider; const contextTokensUsed =
const contextTokensUsed = agentCfgContextTokens ??
agentCfgContextTokens ?? lookupContextTokens(modelUsed) ??
lookupContextTokens(modelUsed) ?? sessionEntry?.contextTokens ??
sessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS;
DEFAULT_CONTEXT_TOKENS;
if (sessionStore && sessionKey) {
if (hasNonzeroUsage(usage)) { if (hasNonzeroUsage(usage)) {
const entry = sessionEntry ?? sessionStore[sessionKey]; const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) { if (entry) {
@ -694,6 +762,29 @@ export async function runReplyAgent(params: {
} }
} }
const responseUsageEnabled =
(sessionEntry?.responseUsage ??
(sessionKey
? sessionStore?.[sessionKey]?.responseUsage
: undefined)) === "on";
if (responseUsageEnabled && hasNonzeroUsage(usage)) {
const authMode = resolveModelAuthMode(providerUsed, cfg);
const showCost = authMode === "api-key";
const costConfig = showCost
? resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
})
: undefined;
const formatted = formatResponseUsageLine({
usage,
showCost,
costConfig,
});
if (formatted) responseUsageLine = formatted;
}
// If verbose is enabled and this is a new session, prepend a session hint. // If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = replyPayloads; let finalPayloads = replyPayloads;
if (autoCompactionCompleted) { if (autoCompactionCompleted) {
@ -717,6 +808,9 @@ export async function runReplyAgent(params: {
...finalPayloads, ...finalPayloads,
]; ];
} }
if (responseUsageLine) {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
}
return finalizeWithFollowup( return finalizeWithFollowup(
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,

View File

@ -1,11 +1,13 @@
import { import {
ensureAuthProfileStore, ensureAuthProfileStore,
listProfilesForProvider, resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js"; } from "../../agents/auth-profiles.js";
import { import {
getCustomProviderApiKey, getCustomProviderApiKey,
resolveEnvApiKey, resolveEnvApiKey,
} from "../../agents/model-auth.js"; } from "../../agents/model-auth.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import { import {
abortEmbeddedPiRun, abortEmbeddedPiRun,
compactEmbeddedPiSession, compactEmbeddedPiSession,
@ -23,6 +25,7 @@ import { logVerbose } from "../../globals.js";
import { import {
formatUsageSummaryLine, formatUsageSummaryLine,
loadProviderUsageSummary, loadProviderUsageSummary,
resolveUsageProviderId,
} from "../../infra/provider-usage.js"; } from "../../infra/provider-usage.js";
import { import {
scheduleGatewaySigusr1Restart, scheduleGatewaySigusr1Restart,
@ -92,32 +95,166 @@ export type CommandContext = {
to?: string; to?: string;
}; };
export async function buildStatusReply(params: {
cfg: ClawdbotConfig;
command: CommandContext;
sessionEntry?: SessionEntry;
sessionKey?: string;
sessionScope?: SessionScope;
provider: string;
model: string;
contextTokens: number;
resolvedThinkLevel?: ThinkLevel;
resolvedVerboseLevel: VerboseLevel;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel?: ElevatedLevel;
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
isGroup: boolean;
defaultGroupActivation: () => "always" | "mention";
}): Promise<ReplyPayload | undefined> {
const {
cfg,
command,
sessionEntry,
sessionKey,
sessionScope,
provider,
model,
contextTokens,
resolvedThinkLevel,
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
isGroup,
defaultGroupActivation,
} = params;
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return undefined;
}
let usageLine: string | null = null;
try {
const usageProvider = resolveUsageProviderId(provider);
if (usageProvider) {
const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500,
providers: [usageProvider],
});
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
}
} catch {
usageLine = null;
}
const queueSettings = resolveQueueSettings({
cfg,
provider: command.provider,
sessionEntry,
});
const queueKey = sessionKey ?? sessionEntry?.sessionId;
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
const queueOverrides = Boolean(
sessionEntry?.queueDebounceMs ??
sessionEntry?.queueCap ??
sessionEntry?.queueDrop,
);
const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation())
: undefined;
const statusText = buildStatusMessage({
agent: {
...cfg.agent,
model: {
...cfg.agent?.model,
primary: `${provider}/${model}`,
},
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
},
sessionEntry,
sessionKey,
sessionScope,
groupActivation,
resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry),
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,
depth: queueDepth,
debounceMs: queueSettings.debounceMs,
cap: queueSettings.cap,
dropPolicy: queueSettings.dropPolicy,
showDetails: queueOverrides,
},
includeTranscriptUsage: false,
});
return { text: statusText };
}
function formatApiKeySnippet(apiKey: string): string {
const compact = apiKey.replace(/\s+/g, "");
if (!compact) return "unknown";
const edge = compact.length >= 12 ? 6 : 4;
const head = compact.slice(0, edge);
const tail = compact.slice(-edge);
return `${head}${tail}`;
}
function resolveModelAuthLabel( function resolveModelAuthLabel(
provider?: string, provider?: string,
cfg?: ClawdbotConfig, cfg?: ClawdbotConfig,
sessionEntry?: SessionEntry,
): string | undefined { ): string | undefined {
const resolved = provider?.trim(); const resolved = provider?.trim();
if (!resolved) return undefined; if (!resolved) return undefined;
const providerKey = normalizeProviderId(resolved);
const store = ensureAuthProfileStore(); const store = ensureAuthProfileStore();
const profiles = listProfilesForProvider(store, resolved); const profileOverride = sessionEntry?.authProfileOverride?.trim();
if (profiles.length > 0) { const lastGood = store.lastGood?.[providerKey] ?? store.lastGood?.[resolved];
const modes = new Set( const order = resolveAuthProfileOrder({
profiles cfg,
.map((id) => store.profiles[id]?.type) store,
.filter((mode): mode is "api_key" | "oauth" => Boolean(mode)), provider: providerKey,
); preferredProfile: profileOverride,
if (modes.has("oauth") && modes.has("api_key")) return "mixed"; });
if (modes.has("oauth")) return "oauth"; const candidates = [profileOverride, lastGood, ...order].filter(
if (modes.has("api_key")) return "api-key"; Boolean,
) as string[];
for (const profileId of candidates) {
const profile = store.profiles[profileId];
if (!profile || normalizeProviderId(profile.provider) !== providerKey) {
continue;
}
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
if (profile.type === "oauth") {
return `oauth${label ? ` (${label})` : ""}`;
}
const snippet = formatApiKeySnippet(profile.key);
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
} }
const envKey = resolveEnvApiKey(resolved); const envKey = resolveEnvApiKey(providerKey);
if (envKey?.apiKey) { if (envKey?.apiKey) {
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; if (envKey.source.includes("OAUTH_TOKEN")) {
return `oauth (${envKey.source})`;
}
return `api-key ${formatApiKeySnippet(envKey.apiKey)} (${envKey.source})`;
} }
if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; const customKey = getCustomProviderApiKey(cfg, providerKey);
if (customKey) {
return `api-key ${formatApiKeySnippet(customKey)} (models.json)`;
}
return "unknown"; return "unknown";
} }
@ -426,71 +563,24 @@ export async function handleCommands(params: {
directives.hasStatusDirective || directives.hasStatusDirective ||
command.commandBodyNormalized === "/status"; command.commandBodyNormalized === "/status";
if (allowTextCommands && statusRequested) { if (allowTextCommands && statusRequested) {
if (!command.isAuthorizedSender) { const reply = await buildStatusReply({
logVerbose(
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
let usageLine: string | null = null;
try {
const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500,
});
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
} catch {
usageLine = null;
}
const queueSettings = resolveQueueSettings({
cfg, cfg,
provider: command.provider, command,
sessionEntry,
});
const queueKey = sessionKey ?? sessionEntry?.sessionId;
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
const queueOverrides = Boolean(
sessionEntry?.queueDebounceMs ??
sessionEntry?.queueCap ??
sessionEntry?.queueDrop,
);
const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation())
: undefined;
const statusText = buildStatusMessage({
agent: {
...cfg.agent,
model: {
...cfg.agent?.model,
primary: `${provider}/${model}`,
},
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
},
sessionEntry, sessionEntry,
sessionKey, sessionKey,
sessionScope, sessionScope,
groupActivation, provider,
resolvedThink: model,
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), contextTokens,
resolvedVerbose: resolvedVerboseLevel, resolvedThinkLevel,
resolvedReasoning: resolvedReasoningLevel, resolvedVerboseLevel,
resolvedElevated: resolvedElevatedLevel, resolvedReasoningLevel,
modelAuth: resolveModelAuthLabel(provider, cfg), resolvedElevatedLevel,
usageLine: usageLine ?? undefined, resolveDefaultThinkingLevel,
queue: { isGroup,
mode: queueSettings.mode, defaultGroupActivation,
depth: queueDepth,
debounceMs: queueSettings.debounceMs,
cap: queueSettings.cap,
dropPolicy: queueSettings.dropPolicy,
showDetails: queueOverrides,
},
includeTranscriptUsage: false,
}); });
return { shouldContinue: false, reply: { text: statusText } }; return { shouldContinue: false, reply };
} }
const stopRequested = command.commandBodyNormalized === "/stop"; const stopRequested = command.commandBodyNormalized === "/stop";

View File

@ -24,7 +24,12 @@ import {
resolveModelRefFromString, resolveModelRefFromString,
} from "../../agents/model-selection.js"; } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import {
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey,
type SessionEntry,
saveSessionStore,
} from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
import { shortenHomePath } from "../../utils.js"; import { shortenHomePath } from "../../utils.js";
import { extractModelDirective } from "../model.js"; import { extractModelDirective } from "../model.js";
@ -57,6 +62,8 @@ const SYSTEM_MARK = "⚙️";
const formatOptionsLine = (options: string) => `Options: ${options}.`; const formatOptionsLine = (options: string) => `Options: ${options}.`;
const withOptions = (line: string, options: string) => const withOptions = (line: string, options: string) =>
`${line}\n${formatOptionsLine(options)}`; `${line}\n${formatOptionsLine(options)}`;
const formatElevatedRuntimeHint = () =>
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
const maskApiKey = (value: string): string => { const maskApiKey = (value: string): string => {
const trimmed = value.trim(); const trimmed = value.trim();
@ -350,6 +357,21 @@ export async function handleDirectiveOnly(params: {
currentReasoningLevel, currentReasoningLevel,
currentElevatedLevel, currentElevatedLevel,
} = params; } = params;
const runtimeIsSandboxed = (() => {
const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off") return false;
const sessionKey = params.sessionKey?.trim();
if (!sessionKey) return false;
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const mainKey = resolveAgentMainSessionKey({
cfg: params.cfg,
agentId,
});
if (sandboxMode === "all") return true;
return sessionKey !== mainKey;
})();
const shouldHintDirectRuntime =
directives.hasElevatedDirective && !runtimeIsSandboxed;
if (directives.hasModelDirective) { if (directives.hasModelDirective) {
const modelDirective = directives.rawModelDirective?.trim().toLowerCase(); const modelDirective = directives.rawModelDirective?.trim().toLowerCase();
@ -463,7 +485,12 @@ export async function handleDirectiveOnly(params: {
} }
const level = currentElevatedLevel ?? "off"; const level = currentElevatedLevel ?? "off";
return { return {
text: withOptions(`Current elevated level: ${level}.`, "on, off"), text: [
withOptions(`Current elevated level: ${level}.`, "on, off"),
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
]
.filter(Boolean)
.join("\n"),
}; };
} }
return { return {
@ -681,6 +708,7 @@ export async function handleDirectiveOnly(params: {
? `${SYSTEM_MARK} Elevated mode disabled.` ? `${SYSTEM_MARK} Elevated mode disabled.`
: `${SYSTEM_MARK} Elevated mode enabled.`, : `${SYSTEM_MARK} Elevated mode enabled.`,
); );
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
} }
if (modelSelection) { if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`; const label = `${modelSelection.provider}/${modelSelection.model}`;
@ -716,6 +744,7 @@ export async function handleDirectiveOnly(params: {
parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`); parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`);
} }
const ack = parts.join(" ").trim(); const ack = parts.join(" ").trim();
if (!ack && directives.hasStatusDirective) return undefined;
return { text: ack || "OK." }; return { text: ack || "OK." };
} }

View File

@ -194,6 +194,7 @@ export async function initSessionState(params: {
// Persist previously stored thinking/verbose levels when present. // Persist previously stored thinking/verbose levels when present.
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
responseUsage: baseEntry?.responseUsage,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
sendPolicy: baseEntry?.sendPolicy, sendPolicy: baseEntry?.sendPolicy,

View File

@ -63,20 +63,34 @@ describe("buildStatusMessage", () => {
resolvedThink: "medium", resolvedThink: "medium",
resolvedVerbose: "off", resolvedVerbose: "off",
queue: { mode: "collect", depth: 0 }, queue: { mode: "collect", depth: 0 },
now: 10 * 60_000, // 10 minutes later modelAuth: "api-key",
}); });
expect(text).toContain("🦞 ClawdBot"); expect(text).toContain("status agent:main:main");
expect(text).toContain("🧠 Model:"); expect(text).toContain("model anthropic/pi:opus (api-key)");
expect(text).toContain("Runtime: direct"); expect(text).toContain("Context 16k/32k (50%)");
expect(text).toContain("Context: 16k/32k (50%)"); expect(text).toContain("compactions 2");
expect(text).toContain("🧹 Compactions: 2"); expect(text).toContain("think medium");
expect(text).toContain("Session: agent:main:main"); expect(text).toContain("verbose off");
expect(text).toContain("updated 10m ago"); expect(text).toContain("reasoning off");
expect(text).toContain("Think: medium"); expect(text).toContain("elevated on");
expect(text).toContain("Verbose: off"); expect(text).toContain("queue collect");
expect(text).toContain("Elevated: on"); });
expect(text).toContain("Queue: collect");
it("shows verbose/elevated labels only when enabled", () => {
const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5" },
sessionEntry: { sessionId: "v1", updatedAt: 0 },
sessionKey: "agent:main:main",
sessionScope: "per-sender",
resolvedThink: "low",
resolvedVerbose: "on",
resolvedElevated: "on",
queue: { mode: "collect", depth: 0 },
});
expect(text).toContain("verbose on");
expect(text).toContain("elevated on");
}); });
it("prefers model overrides over last-run model", () => { it("prefers model overrides over last-run model", () => {
@ -97,9 +111,10 @@ describe("buildStatusMessage", () => {
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
sessionScope: "per-sender", sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 }, queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
}); });
expect(text).toContain("🧠 Model: openai/gpt-4.1-mini"); expect(text).toContain("model openai/gpt-4.1-mini");
}); });
it("keeps provider prefix from configured model", () => { it("keeps provider prefix from configured model", () => {
@ -109,21 +124,23 @@ describe("buildStatusMessage", () => {
}, },
sessionScope: "per-sender", sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 }, queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
}); });
expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5"); expect(text).toContain("model google-antigravity/claude-sonnet-4-5");
}); });
it("handles missing agent config gracefully", () => { it("handles missing agent config gracefully", () => {
const text = buildStatusMessage({ const text = buildStatusMessage({
agent: {}, agent: {},
sessionScope: "per-sender", sessionScope: "per-sender",
webLinked: false, queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
}); });
expect(text).toContain("🧠 Model:"); expect(text).toContain("model");
expect(text).toContain("Context:"); expect(text).toContain("Context");
expect(text).toContain("Queue:"); expect(text).toContain("queue collect");
}); });
it("includes group activation for group sessions", () => { it("includes group activation for group sessions", () => {
@ -138,9 +155,10 @@ describe("buildStatusMessage", () => {
sessionKey: "agent:main:whatsapp:group:123@g.us", sessionKey: "agent:main:whatsapp:group:123@g.us",
sessionScope: "per-sender", sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 }, queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
}); });
expect(text).toContain("Activation: always"); expect(text).toContain("activation always");
}); });
it("shows queue details when overridden", () => { it("shows queue details when overridden", () => {
@ -157,10 +175,11 @@ describe("buildStatusMessage", () => {
dropPolicy: "old", dropPolicy: "old",
showDetails: true, showDetails: true,
}, },
modelAuth: "api-key",
}); });
expect(text).toContain( expect(text).toContain(
"Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)", "queue collect (depth 3 · debounce 2s · cap 5 · drop old)",
); );
}); });
@ -172,12 +191,10 @@ describe("buildStatusMessage", () => {
sessionScope: "per-sender", sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 }, queue: { mode: "collect", depth: 0 },
usageLine: "📊 Usage: Claude 80% left (5h)", usageLine: "📊 Usage: Claude 80% left (5h)",
modelAuth: "api-key",
}); });
const lines = text.split("\n"); expect(text).toContain("📊 Usage: Claude 80% left (5h)");
const contextIndex = lines.findIndex((line) => line.startsWith("📚 "));
expect(contextIndex).toBeGreaterThan(-1);
expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)");
}); });
it("prefers cached prompt tokens from the session log", async () => { it("prefers cached prompt tokens from the session log", async () => {
@ -237,9 +254,10 @@ describe("buildStatusMessage", () => {
sessionScope: "per-sender", sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 }, queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true, includeTranscriptUsage: true,
modelAuth: "api-key",
}); });
expect(text).toContain("Context: 1.0k/32k"); expect(text).toContain("Context 1.0k/32k");
} finally { } finally {
restoreHomeEnv(previousHome); restoreHomeEnv(previousHome);
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });

View File

@ -6,6 +6,7 @@ import {
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
} from "../agents/defaults.js"; } from "../agents/defaults.js";
import { resolveModelAuthMode } from "../agents/model-auth.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { import {
derivePromptTokens, derivePromptTokens,
@ -14,7 +15,6 @@ import {
} from "../agents/usage.js"; } from "../agents/usage.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
resolveMainSessionKey,
resolveSessionFilePath, resolveSessionFilePath,
type SessionEntry, type SessionEntry,
type SessionScope, type SessionScope,
@ -22,6 +22,12 @@ import {
import { resolveCommitHash } from "../infra/git-commit.js"; import { resolveCommitHash } from "../infra/git-commit.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { listChatCommands } from "./commands-registry.js"; import { listChatCommands } from "./commands-registry.js";
import {
estimateUsageCost,
formatTokenCount as formatTokenCountShared,
formatUsd,
resolveModelCostConfig,
} from "../utils/usage-format.js";
import type { import type {
ElevatedLevel, ElevatedLevel,
ReasoningLevel, ReasoningLevel,
@ -31,6 +37,8 @@ import type {
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>; type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
export const formatTokenCount = formatTokenCountShared;
type QueueStatus = { type QueueStatus = {
mode?: string; mode?: string;
depth?: number; depth?: number;
@ -41,6 +49,7 @@ type QueueStatus = {
}; };
type StatusArgs = { type StatusArgs = {
config?: ClawdbotConfig;
agent: AgentConfig; agent: AgentConfig;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionKey?: string; sessionKey?: string;
@ -54,37 +63,20 @@ type StatusArgs = {
usageLine?: string; usageLine?: string;
queue?: QueueStatus; queue?: QueueStatus;
includeTranscriptUsage?: boolean; includeTranscriptUsage?: boolean;
now?: number;
}; };
const formatAge = (ms?: number | null) => {
if (!ms || ms < 0) return "unknown";
const minutes = Math.round(ms / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
};
const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
export const formatTokenCount = (value: number) => formatKTokens(value);
const formatTokens = ( const formatTokens = (
total: number | null | undefined, total: number | null | undefined,
contextTokens: number | null, contextTokens: number | null,
) => { ) => {
const ctx = contextTokens ?? null; const ctx = contextTokens ?? null;
if (total == null) { if (total == null) {
const ctxLabel = ctx ? formatKTokens(ctx) : "?"; const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
return `unknown/${ctxLabel}`; return `?/${ctxLabel}`;
} }
const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null; const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null;
const totalLabel = formatKTokens(total); const totalLabel = formatTokenCount(total);
const ctxLabel = ctx ? formatKTokens(ctx) : "?"; const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`; return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`;
}; };
@ -172,8 +164,15 @@ const readUsageFromSessionLog = (
} }
}; };
const formatUsagePair = (input?: number | null, output?: number | null) => {
if (input == null && output == null) return null;
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
const outputLabel =
typeof output === "number" ? formatTokenCount(output) : "?";
return `usage ${inputLabel} in / ${outputLabel} out`;
};
export function buildStatusMessage(args: StatusArgs): string { export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now();
const entry = args.sessionEntry; const entry = args.sessionEntry;
const resolved = resolveConfiguredModelRef({ const resolved = resolveConfiguredModelRef({
cfg: { agent: args.agent ?? {} }, cfg: { agent: args.agent ?? {} },
@ -189,6 +188,8 @@ export function buildStatusMessage(args: StatusArgs): string {
lookupContextTokens(model) ?? lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS; DEFAULT_CONTEXT_TOKENS;
let inputTokens = entry?.inputTokens;
let outputTokens = entry?.outputTokens;
let totalTokens = let totalTokens =
entry?.totalTokens ?? entry?.totalTokens ??
(entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0); (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
@ -206,6 +207,8 @@ export function buildStatusMessage(args: StatusArgs): string {
if (!contextTokens && logUsage.model) { if (!contextTokens && logUsage.model) {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens; contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
} }
if (!inputTokens || inputTokens === 0) inputTokens = logUsage.input;
if (!outputTokens || outputTokens === 0) outputTokens = logUsage.output;
} }
} }
@ -219,33 +222,6 @@ export function buildStatusMessage(args: StatusArgs): string {
args.agent?.elevatedDefault ?? args.agent?.elevatedDefault ??
"on"; "on";
const runtime = (() => {
const sandboxMode = args.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off") return { label: "direct" };
const sessionScope = args.sessionScope ?? "per-sender";
const mainKey = resolveMainSessionKey({
session: { scope: sessionScope },
});
const sessionKey = args.sessionKey?.trim();
const sandboxed = sessionKey
? sandboxMode === "all" || sessionKey !== mainKey.trim()
: false;
const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
return {
label: `${runtime}/${sandboxMode}`,
};
})();
const updatedAt = entry?.updatedAt;
const sessionLine = [
`Session: ${args.sessionKey ?? "unknown"}`,
typeof updatedAt === "number"
? `updated ${formatAge(now - updatedAt)}`
: "no activity",
]
.filter(Boolean)
.join(" • ");
const isGroupSession = const isGroupSession =
entry?.chatType === "group" || entry?.chatType === "group" ||
entry?.chatType === "room" || entry?.chatType === "room" ||
@ -256,54 +232,68 @@ export function buildStatusMessage(args: StatusArgs): string {
? (args.groupActivation ?? entry?.groupActivation ?? "mention") ? (args.groupActivation ?? entry?.groupActivation ?? "mention")
: undefined; : undefined;
const contextLine = [ const authMode =
`Context: ${formatTokens(totalTokens, contextTokens ?? null)}`, args.modelAuth ?? resolveModelAuthMode(provider, args.config);
`🧹 Compactions: ${entry?.compactionCount ?? 0}`, const showCost = authMode === "api-key";
] const costConfig = showCost
.filter(Boolean) ? resolveModelCostConfig({
.join(" · "); provider,
model,
config: args.config,
})
: undefined;
const hasUsage =
typeof inputTokens === "number" || typeof outputTokens === "number";
const cost =
showCost && hasUsage
? estimateUsageCost({
usage: {
input: inputTokens ?? undefined,
output: outputTokens ?? undefined,
},
cost: costConfig,
})
: undefined;
const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined;
const parts: Array<string | null> = [];
parts.push(`status ${args.sessionKey ?? "unknown"}`);
const modelLabel = model ? `${provider}/${model}` : "unknown";
const authLabel = authMode && authMode !== "unknown" ? ` (${authMode})` : "";
parts.push(`model ${modelLabel}${authLabel}`);
const usagePair = formatUsagePair(inputTokens, outputTokens);
if (usagePair) parts.push(usagePair);
if (costLabel) parts.push(`cost ${costLabel}`);
const contextSummary = formatContextUsageShort(
totalTokens && totalTokens > 0 ? totalTokens : null,
contextTokens ?? null,
);
parts.push(contextSummary);
parts.push(`compactions ${entry?.compactionCount ?? 0}`);
parts.push(`think ${thinkLevel}`);
parts.push(`verbose ${verboseLevel}`);
parts.push(`reasoning ${reasoningLevel}`);
parts.push(`elevated ${elevatedLevel}`);
if (groupActivationValue) parts.push(`activation ${groupActivationValue}`);
const queueMode = args.queue?.mode ?? "unknown"; const queueMode = args.queue?.mode ?? "unknown";
const queueDetails = formatQueueDetails(args.queue); const queueDetails = formatQueueDetails(args.queue);
const optionParts = [ parts.push(`queue ${queueMode}${queueDetails}`);
`Runtime: ${runtime.label}`,
`Think: ${thinkLevel}`,
`Verbose: ${verboseLevel}`,
reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null,
`Elevated: ${elevatedLevel}`,
];
const optionsLine = optionParts.filter(Boolean).join(" · ");
const activationParts = [
groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null,
`🪢 Queue: ${queueMode}${queueDetails}`,
];
const activationLine = activationParts.filter(Boolean).join(" · ");
const modelLabel = model ? `${provider}/${model}` : "unknown"; if (args.usageLine) parts.push(args.usageLine);
const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : "";
const modelLine = `🧠 Model: ${modelLabel}${authLabel}`;
const commit = resolveCommitHash();
const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`;
return [ return parts.filter(Boolean).join(" · ");
versionLine,
modelLine,
`📚 ${contextLine}`,
args.usageLine,
`🧵 ${sessionLine}`,
`⚙️ ${optionsLine}`,
activationLine,
]
.filter(Boolean)
.join("\n");
} }
export function buildHelpMessage(): string { export function buildHelpMessage(): string {
return [ return [
" Help", " Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink", "Shortcuts: /new reset | /compact [instructions] | /restart relink",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id>", "Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off",
"More: /commands for all slash commands", "More: /commands for all slash commands"
].join("\n"); ].join("\n");
} }

View File

@ -2,6 +2,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
export type VerboseLevel = "off" | "on"; export type VerboseLevel = "off" | "on";
export type ElevatedLevel = "off" | "on"; export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream"; export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "on";
// Normalize user-provided thinking level strings to the canonical enum. // Normalize user-provided thinking level strings to the canonical enum.
export function normalizeThinkLevel( export function normalizeThinkLevel(
@ -46,6 +47,19 @@ export function normalizeVerboseLevel(
return undefined; return undefined;
} }
// Normalize response-usage display flags used to toggle cost/token lines.
export function normalizeUsageDisplay(
raw?: string | null,
): UsageDisplayLevel | undefined {
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0", "disable", "disabled"].includes(key))
return "off";
if (["on", "true", "yes", "1", "enable", "enabled"].includes(key))
return "on";
return undefined;
}
// Normalize elevated flags used to toggle elevated bash permissions. // Normalize elevated flags used to toggle elevated bash permissions.
export function normalizeElevatedLevel( export function normalizeElevatedLevel(
raw?: string | null, raw?: string | null,

View File

@ -52,7 +52,10 @@ async function fetchLogs(
return payload as LogsTailPayload; return payload as LogsTailPayload;
} }
function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain") { function formatLogTimestamp(
value?: string,
mode: "pretty" | "plain" = "plain",
) {
if (!value) return ""; if (!value) return "";
const parsed = new Date(value); const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value; if (Number.isNaN(parsed.getTime())) return value;
@ -70,7 +73,10 @@ function formatLogLine(
const parsed = parseLogLine(raw); const parsed = parseLogLine(raw);
if (!parsed) return raw; if (!parsed) return raw;
const label = parsed.subsystem ?? parsed.module ?? ""; const label = parsed.subsystem ?? parsed.module ?? "";
const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain"); const time = formatLogTimestamp(
parsed.time,
opts.pretty ? "pretty" : "plain",
);
const level = parsed.level ?? ""; const level = parsed.level ?? "";
const levelLabel = level.padEnd(5).trim(); const levelLabel = level.padEnd(5).trim();
const message = parsed.message || parsed.raw; const message = parsed.message || parsed.raw;

View File

@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { getResolvedLoggerSettings } from "../../logging.js";
import { parseLogLine } from "../../logging/parse-log-line.js"; import { parseLogLine } from "../../logging/parse-log-line.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";

View File

@ -87,6 +87,7 @@ export type SessionEntry = {
verboseLevel?: string; verboseLevel?: string;
reasoningLevel?: string; reasoningLevel?: string;
elevatedLevel?: string; elevatedLevel?: string;
responseUsage?: "on" | "off";
providerOverride?: string; providerOverride?: string;
modelOverride?: string; modelOverride?: string;
authProfileOverride?: string; authProfileOverride?: string;

View File

@ -325,6 +325,9 @@ export const SessionsPatchParamsSchema = Type.Object(
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
responseUsage: Type.Optional(
Type.Union([Type.Literal("on"), Type.Literal("off"), Type.Null()]),
),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),

View File

@ -19,6 +19,7 @@ import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
import { import {
normalizeReasoningLevel, normalizeReasoningLevel,
normalizeThinkLevel, normalizeThinkLevel,
normalizeUsageDisplay,
normalizeVerboseLevel, normalizeVerboseLevel,
} from "../../auto-reply/thinking.js"; } from "../../auto-reply/thinking.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
@ -234,6 +235,28 @@ export const sessionsHandlers: GatewayRequestHandlers = {
} }
} }
if ("responseUsage" in p) {
const raw = p.responseUsage;
if (raw === null) {
delete next.responseUsage;
} else if (raw !== undefined) {
const normalized = normalizeUsageDisplay(String(raw));
if (!normalized) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
'invalid responseUsage (use "on"|"off")',
),
);
return;
}
if (normalized === "off") delete next.responseUsage;
else next.responseUsage = normalized;
}
}
if ("model" in p) { if ("model" in p) {
const raw = p.model; const raw = p.model;
if (raw === null) { if (raw === null) {
@ -394,6 +417,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
thinkingLevel: entry?.thinkingLevel, thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel, verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel, reasoningLevel: entry?.reasoningLevel,
responseUsage: entry?.responseUsage,
model: entry?.model, model: entry?.model,
contextTokens: entry?.contextTokens, contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy, sendPolicy: entry?.sendPolicy,

View File

@ -51,6 +51,8 @@ export type GatewaySessionRow = {
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
totalTokens?: number; totalTokens?: number;
responseUsage?: "on" | "off";
modelProvider?: string;
model?: string; model?: string;
contextTokens?: number; contextTokens?: number;
lastProvider?: SessionEntry["lastProvider"]; lastProvider?: SessionEntry["lastProvider"];
@ -503,6 +505,8 @@ export function listSessionsFromStore(params: {
inputTokens: entry?.inputTokens, inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens, outputTokens: entry?.outputTokens,
totalTokens: total, totalTokens: total,
responseUsage: entry?.responseUsage,
modelProvider: entry?.modelProvider,
model: entry?.model, model: entry?.model,
contextTokens: entry?.contextTokens, contextTokens: entry?.contextTokens,
lastProvider: entry?.lastProvider, lastProvider: entry?.lastProvider,

View File

@ -129,6 +129,16 @@ const usageProviders: UsageProviderId[] = [
"zai", "zai",
]; ];
export function resolveUsageProviderId(
provider?: string | null,
): UsageProviderId | undefined {
if (!provider) return undefined;
const normalized = normalizeProviderId(provider);
return usageProviders.includes(normalized as UsageProviderId)
? (normalized as UsageProviderId)
: undefined;
}
const ignoredErrors = new Set([ const ignoredErrors = new Set([
"No credentials", "No credentials",
"No token", "No token",

View File

@ -20,7 +20,9 @@ describe("parseLogLine", () => {
expect(parsed?.time).toBe("2026-01-09T01:38:41.523Z"); expect(parsed?.time).toBe("2026-01-09T01:38:41.523Z");
expect(parsed?.level).toBe("info"); expect(parsed?.level).toBe("info");
expect(parsed?.subsystem).toBe("gateway/providers/whatsapp"); expect(parsed?.subsystem).toBe("gateway/providers/whatsapp");
expect(parsed?.message).toBe("{\"subsystem\":\"gateway/providers/whatsapp\"} connected"); expect(parsed?.message).toBe(
'{"subsystem":"gateway/providers/whatsapp"} connected',
);
expect(parsed?.raw).toBe(line); expect(parsed?.raw).toBe(line);
}); });
@ -28,7 +30,7 @@ describe("parseLogLine", () => {
const line = JSON.stringify({ const line = JSON.stringify({
0: "hello", 0: "hello",
_meta: { _meta: {
name: "{\"subsystem\":\"gateway\"}", name: '{"subsystem":"gateway"}',
logLevelName: "WARN", logLevelName: "WARN",
date: "2026-01-09T02:10:00.000Z", date: "2026-01-09T02:10:00.000Z",
}, },

View File

@ -21,9 +21,7 @@ function extractMessage(value: Record<string, unknown>): string {
return parts.join(" "); return parts.join(" ");
} }
function parseMetaName( function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } {
raw?: unknown,
): { subsystem?: string; module?: string } {
if (typeof raw !== "string") return {}; if (typeof raw !== "string") return {};
try { try {
const parsed = JSON.parse(raw) as Record<string, unknown>; const parsed = JSON.parse(raw) as Record<string, unknown>;

View File

@ -46,7 +46,7 @@ describe("google-shared convertTools", () => {
converted?.[0]?.functionDeclarations?.[0]?.parameters, converted?.[0]?.functionDeclarations?.[0]?.parameters,
); );
expect(params.type).toBeUndefined(); expect(params.type).toBe("object");
expect(params.properties).toBeDefined(); expect(params.properties).toBeDefined();
expect(params.required).toEqual(["action"]); expect(params.required).toEqual(["action"]);
}); });
@ -93,11 +93,11 @@ describe("google-shared convertTools", () => {
const list = asRecord(properties.list); const list = asRecord(properties.list);
const items = asRecord(list.items); const items = asRecord(list.items);
expect(params).toHaveProperty("patternProperties"); expect(params.patternProperties).toBeUndefined();
expect(params).toHaveProperty("additionalProperties"); expect(params.additionalProperties).toBeUndefined();
expect(mode).toHaveProperty("const"); expect(mode.const).toBeUndefined();
expect(options).toHaveProperty("anyOf"); expect(options.anyOf).toBeUndefined();
expect(items).toHaveProperty("const"); expect(items.const).toBeUndefined();
expect(params.required).toEqual(["mode"]); expect(params.required).toEqual(["mode"]);
}); });
@ -184,13 +184,7 @@ describe("google-shared convertMessages", () => {
} as unknown as Context; } as unknown as Context;
const contents = convertMessages(model, context); const contents = convertMessages(model, context);
expect(contents).toHaveLength(1); expect(contents).toHaveLength(0);
expect(contents[0].role).toBe("model");
expect(contents[0].parts).toHaveLength(1);
expect(contents[0].parts?.[0]).toMatchObject({
thought: true,
thoughtSignature: "sig",
});
}); });
it("keeps thought signatures for Claude models", () => { it("keeps thought signatures for Claude models", () => {
@ -254,9 +248,9 @@ describe("google-shared convertMessages", () => {
} as unknown as Context; } as unknown as Context;
const contents = convertMessages(model, context); const contents = convertMessages(model, context);
expect(contents).toHaveLength(2); expect(contents).toHaveLength(1);
expect(contents[0].role).toBe("user"); expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("user"); expect(contents[0].parts).toHaveLength(2);
}); });
it("does not merge consecutive user messages for non-Gemini Google models", () => { it("does not merge consecutive user messages for non-Gemini Google models", () => {
@ -275,9 +269,9 @@ describe("google-shared convertMessages", () => {
} as unknown as Context; } as unknown as Context;
const contents = convertMessages(model, context); const contents = convertMessages(model, context);
expect(contents).toHaveLength(2); expect(contents).toHaveLength(1);
expect(contents[0].role).toBe("user"); expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("user"); expect(contents[0].parts).toHaveLength(2);
}); });
it("does not merge consecutive model messages for Gemini", () => { it("does not merge consecutive model messages for Gemini", () => {
@ -338,10 +332,10 @@ describe("google-shared convertMessages", () => {
} as unknown as Context; } as unknown as Context;
const contents = convertMessages(model, context); const contents = convertMessages(model, context);
expect(contents).toHaveLength(3); expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user"); expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model"); expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("model"); expect(contents[1].parts).toHaveLength(2);
}); });
it("handles user message after tool result without model response in between", () => { it("handles user message after tool result without model response in between", () => {
@ -398,11 +392,10 @@ describe("google-shared convertMessages", () => {
} as unknown as Context; } as unknown as Context;
const contents = convertMessages(model, context); const contents = convertMessages(model, context);
expect(contents).toHaveLength(4); expect(contents).toHaveLength(3);
expect(contents[0].role).toBe("user"); expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model"); expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("user"); expect(contents[2].role).toBe("user");
expect(contents[3].role).toBe("user");
const toolResponsePart = contents[2].parts?.find( const toolResponsePart = contents[2].parts?.find(
(part) => (part) =>
typeof part === "object" && part !== null && "functionResponse" in part, typeof part === "object" && part !== null && "functionResponse" in part,
@ -476,11 +469,10 @@ describe("google-shared convertMessages", () => {
} as unknown as Context; } as unknown as Context;
const contents = convertMessages(model, context); const contents = convertMessages(model, context);
expect(contents).toHaveLength(3); expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user"); expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model"); expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("model"); const toolCallPart = contents[1].parts?.find(
const toolCallPart = contents[2].parts?.find(
(part) => (part) =>
typeof part === "object" && part !== null && "functionCall" in part, typeof part === "object" && part !== null && "functionCall" in part,
); );

View File

@ -64,6 +64,14 @@ export function getSlashCommands(): SlashCommand[] {
(value) => ({ value, label: value }), (value) => ({ value, label: value }),
), ),
}, },
{
name: "cost",
description: "Toggle per-response usage line",
getArgumentCompletions: (prefix) =>
TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map(
(value) => ({ value, label: value }),
),
},
{ {
name: "elevated", name: "elevated",
description: "Set elevated on/off", description: "Set elevated on/off",
@ -116,6 +124,7 @@ export function helpText(): string {
"/think <off|minimal|low|medium|high>", "/think <off|minimal|low|medium|high>",
"/verbose <on|off>", "/verbose <on|off>",
"/reasoning <on|off>", "/reasoning <on|off>",
"/cost <on|off>",
"/elevated <on|off>", "/elevated <on|off>",
"/elev <on|off>", "/elev <on|off>",
"/activation <mention|always>", "/activation <mention|always>",

View File

@ -44,7 +44,11 @@ export type GatewaySessionList = {
sendPolicy?: string; sendPolicy?: string;
model?: string; model?: string;
contextTokens?: number | null; contextTokens?: number | null;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null; totalTokens?: number | null;
responseUsage?: "on" | "off";
modelProvider?: string;
displayName?: string; displayName?: string;
provider?: string; provider?: string;
room?: string; room?: string;

View File

@ -6,12 +6,14 @@ import {
Text, Text,
TUI, TUI,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { normalizeUsageDisplay } from "../auto-reply/thinking.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { import {
buildAgentMainSessionKey, buildAgentMainSessionKey,
normalizeAgentId, normalizeAgentId,
parseAgentSessionKey, parseAgentSessionKey,
} from "../routing/session-key.js"; } from "../routing/session-key.js";
import { formatTokenCount } from "../utils/usage-format.js";
import { getSlashCommands, helpText, parseCommand } from "./commands.js"; import { getSlashCommands, helpText, parseCommand } from "./commands.js";
import { ChatLog } from "./components/chat-log.js"; import { ChatLog } from "./components/chat-log.js";
import { CustomEditor } from "./components/custom-editor.js"; import { CustomEditor } from "./components/custom-editor.js";
@ -52,8 +54,12 @@ type SessionInfo = {
verboseLevel?: string; verboseLevel?: string;
reasoningLevel?: string; reasoningLevel?: string;
model?: string; model?: string;
modelProvider?: string;
contextTokens?: number | null; contextTokens?: number | null;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null; totalTokens?: number | null;
responseUsage?: "on" | "off";
updatedAt?: number | null; updatedAt?: number | null;
displayName?: string; displayName?: string;
}; };
@ -99,13 +105,16 @@ function extractTextFromMessage(
} }
function formatTokens(total?: number | null, context?: number | null) { function formatTokens(total?: number | null, context?: number | null) {
if (!total && !context) return "tokens ?"; if (total == null && context == null) return "tokens ?";
if (!context) return `tokens ${total ?? 0}`; const totalLabel = total == null ? "?" : formatTokenCount(total);
if (context == null) return `tokens ${totalLabel}`;
const pct = const pct =
typeof total === "number" && context > 0 typeof total === "number" && context > 0
? Math.min(999, Math.round((total / context) * 100)) ? Math.min(999, Math.round((total / context) * 100))
: null; : null;
return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`; return `tokens ${totalLabel}/${formatTokenCount(context)}${
pct !== null ? ` (${pct}%)` : ""
}`;
} }
function asString(value: unknown, fallback = ""): string { function asString(value: unknown, fallback = ""): string {
@ -213,7 +222,11 @@ export async function runTui(opts: TuiOptions) {
? `${sessionKeyLabel} (${sessionInfo.displayName})` ? `${sessionKeyLabel} (${sessionInfo.displayName})`
: sessionKeyLabel; : sessionKeyLabel;
const agentLabel = formatAgentLabel(currentAgentId); const agentLabel = formatAgentLabel(currentAgentId);
const modelLabel = sessionInfo.model ?? "unknown"; const modelLabel = sessionInfo.model
? sessionInfo.modelProvider
? `${sessionInfo.modelProvider}/${sessionInfo.model}`
: sessionInfo.model
: "unknown";
const tokens = formatTokens( const tokens = formatTokens(
sessionInfo.totalTokens ?? null, sessionInfo.totalTokens ?? null,
sessionInfo.contextTokens ?? null, sessionInfo.contextTokens ?? null,
@ -321,8 +334,12 @@ export async function runTui(opts: TuiOptions) {
verboseLevel: entry?.verboseLevel, verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel, reasoningLevel: entry?.reasoningLevel,
model: entry?.model ?? result.defaults?.model ?? undefined, model: entry?.model ?? result.defaults?.model ?? undefined,
modelProvider: entry?.modelProvider,
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens, contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
inputTokens: entry?.inputTokens ?? null,
outputTokens: entry?.outputTokens ?? null,
totalTokens: entry?.totalTokens ?? null, totalTokens: entry?.totalTokens ?? null,
responseUsage: entry?.responseUsage,
updatedAt: entry?.updatedAt ?? null, updatedAt: entry?.updatedAt ?? null,
displayName: entry?.displayName, displayName: entry?.displayName,
}; };
@ -773,6 +790,28 @@ export async function runTui(opts: TuiOptions) {
chatLog.addSystem(`reasoning failed: ${String(err)}`); chatLog.addSystem(`reasoning failed: ${String(err)}`);
} }
break; break;
case "cost": {
const normalized = args ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized) {
chatLog.addSystem("usage: /cost <on|off>");
break;
}
const current = sessionInfo.responseUsage === "on" ? "on" : "off";
const next = normalized ?? (current === "on" ? "off" : "on");
try {
await client.patchSession({
key: currentSessionKey,
responseUsage: next === "off" ? null : next,
});
chatLog.addSystem(
next === "on" ? "usage line enabled" : "usage line disabled",
);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`cost failed: ${String(err)}`);
}
break;
}
case "elevated": case "elevated":
if (!args) { if (!args) {
chatLog.addSystem("usage: /elevated <on|off>"); chatLog.addSystem("usage: /elevated <on|off>");

View File

@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
estimateUsageCost,
formatTokenCount,
formatUsd,
resolveModelCostConfig,
} from "./usage-format.js";
describe("usage-format", () => {
it("formats token counts", () => {
expect(formatTokenCount(999)).toBe("999");
expect(formatTokenCount(1234)).toBe("1.2k");
expect(formatTokenCount(12000)).toBe("12k");
expect(formatTokenCount(2_500_000)).toBe("2.5m");
});
it("formats USD values", () => {
expect(formatUsd(1.234)).toBe("$1.23");
expect(formatUsd(0.5)).toBe("$0.50");
expect(formatUsd(0.0042)).toBe("$0.0042");
});
it("resolves model cost config and estimates usage cost", () => {
const config = {
models: {
providers: {
test: {
models: [
{
id: "m1",
cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 },
},
],
},
},
},
} as ClawdbotConfig;
const cost = resolveModelCostConfig({
provider: "test",
model: "m1",
config,
});
expect(cost).toEqual({
input: 1,
output: 2,
cacheRead: 0.5,
cacheWrite: 0,
});
const total = estimateUsageCost({
usage: { input: 1000, output: 500, cacheRead: 2000 },
cost,
});
expect(total).toBeCloseTo(0.003);
});
});

69
src/utils/usage-format.ts Normal file
View File

@ -0,0 +1,69 @@
import type { NormalizedUsage } from "../agents/usage.js";
import type { ClawdbotConfig } from "../config/config.js";
export type ModelCostConfig = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
export type UsageTotals = {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
export function formatTokenCount(value?: number): string {
if (value === undefined || !Number.isFinite(value)) return "0";
const safe = Math.max(0, value);
if (safe >= 1_000_000) return `${(safe / 1_000_000).toFixed(1)}m`;
if (safe >= 1_000)
return `${(safe / 1_000).toFixed(safe >= 10_000 ? 0 : 1)}k`;
return String(Math.round(safe));
}
export function formatUsd(value?: number): string | undefined {
if (value === undefined || !Number.isFinite(value)) return undefined;
if (value >= 1) return `$${value.toFixed(2)}`;
if (value >= 0.01) return `$${value.toFixed(2)}`;
return `$${value.toFixed(4)}`;
}
export function resolveModelCostConfig(params: {
provider?: string;
model?: string;
config?: ClawdbotConfig;
}): ModelCostConfig | undefined {
const provider = params.provider?.trim();
const model = params.model?.trim();
if (!provider || !model) return undefined;
const providers = params.config?.models?.providers ?? {};
const entry = providers[provider]?.models?.find((item) => item.id === model);
return entry?.cost;
}
const toNumber = (value: number | undefined): number =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
export function estimateUsageCost(params: {
usage?: NormalizedUsage | UsageTotals | null;
cost?: ModelCostConfig;
}): number | undefined {
const usage = params.usage;
const cost = params.cost;
if (!usage || !cost) return undefined;
const input = toNumber(usage.input);
const output = toNumber(usage.output);
const cacheRead = toNumber(usage.cacheRead);
const cacheWrite = toNumber(usage.cacheWrite);
const total =
input * cost.input +
output * cost.output +
cacheRead * cost.cacheRead +
cacheWrite * cost.cacheWrite;
if (!Number.isFinite(total)) return undefined;
return total / 1_000_000;
}