Compare commits

..
Author SHA1 Message Date
Peter Steinberger 491a6e02fb Merge branch 'main' into pr-402 2026-01-07 15:36:34 +00:00
Peter Steinberger 2c4c5907bb docs: add 2026.1.7 changelog 2026-01-07 15:30:08 +00:00
Peter Steinberger 8804a80111 chore: bump version 2026.1.7 2026-01-07 15:30:05 +00:00
Peter Steinberger 7f6b98929f build(android): bump 2026.1.7 + apk naming 2026-01-07 15:30:03 +00:00
François Catuhe d3ae92aaa8 android: set version 2026.1.5, add APK naming convention, remove duplicate asset 2026-01-07 16:05:26 +01:00
Peter Steinberger 53c037a197 style(telegram): format activation log 2026-01-07 11:21:12 +00:00
Peter Steinberger 4bd7ca305a fix(telegram): honor session activation overrides 2026-01-07 11:19:09 +00:00
Peter Steinberger 3cbced01fa test(telegram): cover routed activation 2026-01-07 11:17:12 +00:00
Julian Engel 45dc4ef3cf fix(telegram): make /activation command work by checking session state
The /activation command now properly controls group activation mode:
- Loads session state before filtering messages
- Checks groupActivation field (from /activation command)
- Falls back to config telegram.groups requireMention setting

Previously, the bot only checked config and ignored session state,
making the /activation command appear to work but have no effect.

Changes:
- Add resolveGroupActivation() to check session before config
- Import loadSessionStore to read session state early
- Pass messageThreadId to support forum topics correctly
2026-01-07 11:16:35 +00:00
Julian Engel 1601be5480 docs(telegram): clarify group activation and access control
- Add detailed explanation of group activation modes (requireMention)
- Document /activation command (mention vs always)
- Clarify two-level access control: group allowlist + sender policy
- Add troubleshooting section for common issues
- Explain that telegram.groups creates an allowlist
- Add instructions for getting group chat ID

Fixes confusion around group setup where /activation command
updates session state but doesn't persist or take effect.
2026-01-07 11:13:13 +00:00
Peter Steinbergerandkitze 1011640a13 refactor: drop autoReply, add topic requireMention
Co-authored-by: kitze <kristijan.mkd@gmail.com>
2026-01-07 12:07:15 +01:00
Peter Steinberger 25edac96cf docs: add note about tricky code comments 2026-01-07 12:04:55 +01:00
Peter Steinberger 85e536f3ff fix(macos): validate remote ports 2026-01-07 11:00:21 +00:00
Nima Karimi a5b29623b8 fix(macos): honor discovered gateway ports 2026-01-07 10:51:06 +00:00
Peter Steinberger eef90b47a3 chore: satisfy lint 2026-01-07 11:49:01 +01:00
Peter Steinberger c74f89c871 docs: update changelog for PR #286 2026-01-07 11:47:24 +01:00
Peter Steinberger 87e08fc7d5 docs: document channel/topic overrides 2026-01-07 11:44:37 +01:00
Peter Steinberger 43c6bb7595 feat: add channel/topic overrides for skills + auto-reply 2026-01-07 11:44:37 +01:00
Peter Steinberger 61f720b945 feat: add skill filter + group system prompt plumbing 2026-01-07 11:44:37 +01:00
Peter Steinberger 9bf6684366 feat: add provider usage tracking 2026-01-07 11:42:46 +01:00
Josh Palmer 4e14123edd Merge pull request #378 from timkrase/system-prompt-weekday
Agents: add weekday to user time (codex assisted)
2026-01-07 11:27:07 +01:00
Peter Steinberger a700f9896d feat: telegram draft streaming 2026-01-07 11:08:32 +01:00
Peter Steinberger e8420bd047 fix: refine bootstrap injections 2026-01-07 10:04:23 +00:00
Tobias Bischoff 412990a139 Reduce prompt token overhead with leaner context injections 2026-01-07 10:04:23 +00:00
Tim Krase e58e13708d Agents: add weekday to user time 2026-01-07 11:02:39 +01:00
Peter Steinbergerandpepicrft 7a917602c5 feat(auth): sync OAuth from Claude/Codex CLIs
Add source profiles anthropic:claude-cli and openai-codex:codex-cli; surface them in onboarding/configure.

Co-authored-by: pepicrft <pepicrft@users.noreply.github.com>
2026-01-07 10:47:57 +01:00
Peter Steinberger 0914517ee3 feat(sandbox): add workspace access mode 2026-01-07 09:33:38 +00:00
Peter Steinberger 94d3a9742b fix: clean agents lint warnings 2026-01-07 10:13:03 +01:00
Peter Steinberger 7973fd4caf feat: add agents command 2026-01-07 10:03:53 +01:00
Shadow 9df8af855b chore: bump carbon version
Closes #349
Closes #354
2026-01-07 02:58:40 -06:00
Peter Steinberger a50ffa69b0 fix(discord): handle multi-attachment inbound media 2026-01-07 09:01:57 +01:00
Peter Steinberger 7d2dde6ea6 docs: thank contributor for #369 2026-01-07 07:57:19 +00:00
Peter Steinbergerandmneves75 e5dbe1db9d fix: ensure output for non-streaming models (#369)
Co-authored-by: mneves75 <mneves75@users.noreply.github.com>
2026-01-07 07:47:18 +00:00
Peter Steinberger 34cac1beb0 fix: land PR #350 2026-01-07 07:19:48 +00:00
Peter Steinberger 1b81805d63 fix: align heartbeat session store with default agent 2026-01-07 07:14:24 +00:00
Peter Steinberger 7176b114da fix(auth): harden legacy auth.json cleanup 2026-01-07 06:51:17 +00:00
Peter Steinberger 0707b1e487 Merge PR #368: delete legacy auth.json after migration 2026-01-07 06:47:46 +00:00
Peter Steinberger 2937c4861f fix(auth): doctor-migrate anthropic oauth profiles 2026-01-07 06:31:02 +00:00
Randy Torres ff79db0a99 fix(auth): use anthropic oauth email profile
Use Anthropic OAuth profile email as the profile identifier when available. This fixes cases where Anthropic returns an email-based profile id rather than an explicit id field.
2026-01-07 06:31:02 +00:00
Peter Steinberger 8b1263ce11 fix: split status activation line 2026-01-07 07:26:52 +01:00
Peter Steinberger dc941b7e57 fix: refresh status output 2026-01-07 07:22:06 +01:00
Matthew Dicembrino 4f10279ac3 fix: delete legacy auth.json after migration to prevent stale token overwrites (#363) 2026-01-07 01:15:38 -05:00
Sash Zats f1643a5b8d Heartbeat: resolve main session key for session store 2026-01-06 20:14:30 -05:00
Sash Zats eb8d7a19af Cron: enqueue system events in main session 2026-01-06 19:55:03 -05:00
108 changed files with 6250 additions and 1172 deletions

No files matched your search

+1
View File
@@ -25,6 +25,7 @@
## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits.
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
+20
View File
@@ -15,15 +15,21 @@
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
### Fixes
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
- CLI: add `clawdbot docs` live docs search with pretty output.
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
- Agent: deliver final replies for non-streaming models when block chunking is enabled. Thank you @mneves75 for PR #369!
- Agent: trim bootstrap context injections and keep group guidance concise (emoji reactions allowed). Thanks @tobiasbischoff for PR #370.
- Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298.
- Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent.
- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.
@@ -33,6 +39,7 @@
- Docs: add ClawdHub guide and hubs link for browsing, install, and sync workflows.
- Docs: add FAQ for PNPM/Bun lockfile migration warning; link AgentSkills spec + ClawdHub guide (`/clawdhub`) from skills docs.
- Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312.
- Status: add provider usage snapshots to `/status`, `clawdbot status --usage`, and the macOS menu bar.
- Build: fix macOS packaging QR smoke test for the bun-compiled relay. Thanks @dbhurley for PR #358.
- Browser: fix `browser snapshot`/`browser act` timeouts under Bun by patching Playwright’s CDP WebSocket selection. Thanks @azade-c for PR #307.
- Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324.
@@ -40,17 +47,23 @@
- Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322.
- Telegram: include sender identity in group envelope headers. (#336)
- Telegram: support forum topics with topic-isolated sessions and message_thread_id routing. Thanks @HazAT, @nachoiacovino, @RandyVentures for PR #321/#333/#334.
- Telegram: add draft streaming via `sendMessageDraft` with `telegram.streamMode`, plus `/reasoning stream` for draft-only reasoning.
- Telegram: honor `/activation` session mode for group mention gating and clarify group activation docs. Thanks @julianengel for PR #377.
- iMessage: ignore disconnect errors during shutdown (avoid unhandled promise rejections). Thanks @antons for PR #359.
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
- Auto-reply: add `/reasoning on|off` to expose model reasoning blocks (italic).
- Auto-reply: place reasoning blocks before the final reply text when appended.
- Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331.
- Auto-reply: add per-channel/topic skill filters + system prompts for Discord/Slack/Telegram. Thanks @kitze for PR #286.
- Auto-reply: refresh `/status` output with build info, compact context, and queue depth.
- Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295.
- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
- Auth/Doctor: migrate Anthropic OAuth configs from `anthropic:default` → `anthropic:<email>` and surface a doctor hint on refresh failures. Thanks @RandyVentures for PR #361. (#363)
- Auth: delete legacy `auth.json` after migration to prevent stale OAuth token overwrites. Thanks @reeltimeapps for PR #368.
- Auth: auto-sync OAuth creds from Claude CLI/Codex CLI into `anthropic:claude-cli`/`openai-codex:codex-cli` and offer them as onboarding/config choices (avoids `refresh_token_reused`). Thanks @pepicrft for PR #374.
- Gateway/CLI: stop forcing localhost URL in remote mode so remote gateway config works. Thanks @oswalpalash for PR #293.
- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins.
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
@@ -72,6 +85,7 @@
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running.
- Cron/Heartbeat: enqueue cron system events in the resolved main session so heartbeats drain the right queue. Thanks @zats for PR #350.
- Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229.
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
- Auth/CLI: normalize provider ids and Z.AI aliases across auth profile ordering and models list/status. Thanks @mneves75 for PR #303.
@@ -85,6 +99,7 @@
- TUI: add `/elev` alias for `/elevated`.
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
- macOS: keep app connection settings local in remote mode to avoid overwriting gateway config. Thanks @ngutman for PR #310.
- macOS: honor discovered gateway ports (Bonjour TXT) so remote tunnels connect to the right ports. Thanks @kkarimi for PR #375.
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
@@ -156,6 +171,11 @@
- Refactor: centralize group allowlist/mention policy across providers.
- Deps: update to latest across the repo.
## 2026.1.7
### Fixes
- Android: bump version to 2026.1.7, add version code, and name APK outputs. Thanks @fcatuhe for PR #402.
## 2026.1.5-3
### Fixes
+3 -2
View File
@@ -453,5 +453,6 @@ Thanks to all clawtributors:
<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/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/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/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/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/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
</p>
<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/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/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/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/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/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/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/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/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/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/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/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
</p>
+12 -2
View File
@@ -19,8 +19,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 1
versionName = "2.0.0-beta3"
versionCode = 20260107
versionName = "2026.1.7"
}
buildTypes {
@@ -54,6 +54,16 @@ android {
}
}
androidComponents {
onVariants { variant ->
variant.outputs.forEach { output ->
val apkOutput = output as? com.android.build.api.variant.ApkVariantOutput ?: return@forEach
val versionName = variant.versionName.orNull ?: "0"
apkOutput.outputFileName.set("clawdbot-${versionName}-${variant.name}.apk")
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
@@ -1,197 +0,0 @@
{
"version": 1,
"fallback": {
"emoji": "🧩",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"id",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"emoji": "🛠️",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"emoji": "📖",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"emoji": "✍️",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"emoji": "📝",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"emoji": "📎",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"emoji": "📱",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"emoji": "⏰",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["id"] },
"remove": { "label": "remove", "detailKeys": ["id"] },
"run": { "label": "run", "detailKeys": ["id"] },
"runs": { "label": "runs", "detailKeys": ["id"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
}
}
}
@@ -123,6 +123,70 @@ enum ClawdbotConfigFile {
return nil
}
static func remoteGatewayPort() -> Int? {
guard let url = self.remoteGatewayUrl(),
let port = url.port,
port > 0
else { return nil }
return port
}
static func remoteGatewayPort(matchingHost sshHost: String) -> Int? {
let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSshHost.isEmpty,
let url = self.remoteGatewayUrl(),
let port = url.port,
port > 0,
let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!urlHost.isEmpty
else {
return nil
}
let sshKey = Self.hostKey(trimmedSshHost)
let urlKey = Self.hostKey(urlHost)
guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil }
return port
}
static func setRemoteGatewayUrl(host: String, port: Int?) {
guard let port, port > 0 else { return }
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedHost.isEmpty else { return }
self.updateGatewayDict { gateway in
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let scheme = URL(string: existingUrl)?.scheme ?? "ws"
remote["url"] = "\(scheme)://\(trimmedHost):\(port)"
gateway["remote"] = remote
}
}
private static func remoteGatewayUrl() -> URL? {
let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let raw = remote["url"] as? String
else {
return nil
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
return url
}
private static func hostKey(_ host: String) -> String {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return "" }
if trimmed.contains(":") { return trimmed }
let digits = CharacterSet(charactersIn: "0123456789.")
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
return trimmed
}
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
}
private static func parseConfigData(_ data: Data) -> [String: Any]? {
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return root
@@ -0,0 +1,27 @@
import Foundation
enum GatewayAgentChannel: String, CaseIterable, Sendable {
case last
case webchat
case whatsapp
case telegram
init(raw: String?) {
let trimmed = raw?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? ""
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
}
func shouldDeliver(_ isLast: Bool) -> Bool {
switch self {
case .webchat:
return false
case .last:
return isLast
case .whatsapp, .telegram:
return true
}
}
}
@@ -18,6 +18,7 @@ final class GatewayDiscoveryModel {
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
@@ -138,6 +139,7 @@ final class GatewayDiscoveryModel {
lanHost: parsedTXT.lanHost,
tailnetDns: parsedTXT.tailnetDns,
sshPort: parsedTXT.sshPort,
gatewayPort: parsedTXT.gatewayPort,
cliPath: parsedTXT.cliPath,
stableID: stableID,
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
@@ -207,11 +209,12 @@ final class GatewayDiscoveryModel {
}
static func parseGatewayTXT(_ txt: [String: String])
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, cliPath: String?)
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, gatewayPort: Int?, cliPath: String?)
{
var lanHost: String?
var tailnetDns: String?
var sshPort = 22
var gatewayPort: Int?
var cliPath: String?
if let value = txt["lanHost"] {
@@ -228,12 +231,18 @@ final class GatewayDiscoveryModel {
{
sshPort = parsed
}
if let value = txt["gatewayPort"],
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
gatewayPort = parsed
}
if let value = txt["cliPath"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
cliPath = trimmed.isEmpty ? nil : trimmed
}
return (lanHost, tailnetDns, sshPort, cliPath)
return (lanHost, tailnetDns, sshPort, gatewayPort, cliPath)
}
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
@@ -694,6 +694,7 @@ extension GeneralSettings {
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
}
}
@@ -22,6 +22,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var cachedErrorText: String?
private var cacheUpdatedAt: Date?
private let refreshIntervalSeconds: TimeInterval = 12
private var cachedUsageSummary: GatewayUsageSummary?
private var cachedUsageErrorText: String?
private var usageCacheUpdatedAt: Date?
private let usageRefreshIntervalSeconds: TimeInterval = 30
private let nodesStore = NodesStore.shared
#if DEBUG
private var testControlChannelConnected: Bool?
@@ -58,6 +62,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.loadTask = Task { [weak self] in
guard let self else { return }
await self.refreshCache(force: forceRefresh)
await self.refreshUsageCache(force: forceRefresh)
await MainActor.run {
guard self.isMenuOpen else { return }
self.inject(into: menu)
@@ -108,66 +113,70 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard self.isControlChannelConnected else { return }
guard let snapshot = self.cachedSnapshot else {
var cursor = insertIndex
var headerView: NSView?
if let snapshot = self.cachedSnapshot {
let now = Date()
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
width: width,
highlighted: false)
headerItem.view = hosted
headerView = hosted
menu.insertItem(headerItem, at: cursor)
cursor += 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
at: cursor)
cursor += 1
} else {
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
item.view = self.makeHostedView(
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
cursor += 1
}
}
} else {
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
statusText: self.cachedErrorText ?? "Loading sessions…")),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: insertIndex)
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
guard let self, let view else { return }
self.captureMenuWidthIfAvailable(from: view)
}
return
}
let now = Date()
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let headerView = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
width: width,
highlighted: false)
headerItem.view = headerView
menu.insertItem(headerItem, at: insertIndex)
var cursor = insertIndex + 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
at: cursor)
return
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
item.view = self.makeHostedView(
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
headerItem.view = hosted
headerView = hosted
menu.insertItem(headerItem, at: cursor)
cursor += 1
}
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return }
self.captureMenuWidthIfAvailable(from: headerView)
@@ -240,6 +249,55 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
_ = cursor
}
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
let rows = self.usageRows
let errorText = self.cachedUsageErrorText
if rows.isEmpty && errorText == nil {
return cursor
}
var cursor = cursor
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView(
count: rows.count,
statusText: errorText)),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: cursor)
cursor += 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
at: cursor)
cursor += 1
return cursor
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = false
item.view = self.makeHostedView(
rootView: AnyView(UsageMenuLabelView(row: row, width: width)),
width: width,
highlighted: false)
menu.insertItem(item, at: cursor)
cursor += 1
}
return cursor
}
private var usageRows: [UsageRow] {
guard let summary = self.cachedUsageSummary else { return [] }
return summary.primaryRows()
}
private var isControlChannelConnected: Bool {
#if DEBUG
if let override = self.testControlChannelConnected { return override }
@@ -364,6 +422,40 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
}
private func refreshUsageCache(force: Bool) async {
if !force,
let updated = self.usageCacheUpdatedAt,
Date().timeIntervalSince(updated) < self.usageRefreshIntervalSeconds
{
return
}
guard self.isControlChannelConnected else {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
return
}
do {
self.cachedUsageSummary = try await UsageLoader.loadSummary()
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
} catch {
if self.cachedUsageSummary == nil {
self.cachedUsageErrorText = self.compactUsageError(error)
}
self.usageCacheUpdatedAt = Date()
}
}
private func compactUsageError(_ error: Error) -> String {
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
if message.isEmpty { return "Usage unavailable" }
if message.count > 90 { return "\(message.prefix(87))…" }
return message
}
private func compactError(_ error: Error) -> String {
if let loadError = error as? SessionLoadError {
switch loadError {
@@ -0,0 +1,45 @@
import SwiftUI
struct MenuUsageHeaderView: View {
let count: Int
let statusText: String?
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6
private let paddingTrailing: CGFloat = 10
private let paddingLeading: CGFloat = 20
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Usage")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
}
private var subtitle: String {
if self.count == 1 { return "1 provider" }
return "\(self.count) providers"
}
}
@@ -202,6 +202,23 @@ final class MacNodeModeCoordinator {
return 18790
}
static func remoteBridgePort() -> Int {
let fallback = Int(Self.loopbackBridgePort() ?? 18790)
let settings = CommandResolver.connectionSettings()
let sshHost = CommandResolver.parseSSHTarget(settings.target)?.host ?? ""
let base =
ClawdbotConfigFile.remoteGatewayPort(matchingHost: sshHost) ??
GatewayEnvironment.gatewayPort()
guard base > 0 else { return fallback }
return Self.derivePort(base: base, offset: 1, fallback: fallback)
}
private static func derivePort(base: Int, offset: Int, fallback: Int) -> Int {
let derived = base + offset
guard derived > 0, derived <= Int(UInt16.max) else { return fallback }
return derived
}
static func probeEndpoint(_ endpoint: NWEndpoint, timeoutSeconds: Double) async -> Bool {
let connection = NWConnection(to: endpoint, using: .tcp)
let stream = Self.makeStateStream(for: connection)
@@ -269,7 +286,10 @@ final class MacNodeModeCoordinator {
if mode == .remote {
do {
if self.tunnel == nil || self.tunnel?.process.isRunning == false {
self.tunnel = try await RemotePortTunnel.create(remotePort: 18790)
let remotePort = Self.remoteBridgePort()
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
allowRemoteUrlOverride: false)
}
if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort)
@@ -29,6 +29,7 @@ extension OnboardingView {
user: user,
host: host,
port: gateway.sshPort)
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
}
self.state.remoteCliPath = gateway.cliPath ?? ""
@@ -12,6 +12,7 @@ extension OnboardingView {
lanHost: "bridge.local",
tailnetDns: "bridge.ts.net",
sshPort: 2222,
gatewayPort: 18789,
cliPath: "/usr/local/bin/clawdbot",
stableID: "bridge-1",
debugID: "bridge-1",
@@ -38,7 +38,11 @@ final class RemotePortTunnel {
Task { await PortGuardian.shared.removeRecord(pid: pid) }
}
static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> RemotePortTunnel {
static func create(
remotePort: Int,
preferredLocalPort: UInt16? = nil,
allowRemoteUrlOverride: Bool = true
) async throws -> RemotePortTunnel {
let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
throw NSError(
@@ -49,7 +53,7 @@ final class RemotePortTunnel {
let localPort = try await Self.findPort(preferred: preferredLocalPort)
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
let remotePortOverride = Self.resolveRemotePortOverride(for: sshHost)
let remotePortOverride = allowRemoteUrlOverride ? Self.resolveRemotePortOverride(for: sshHost) : nil
let resolvedRemotePort = remotePortOverride ?? remotePort
if let override = remotePortOverride {
Self.logger.info(
@@ -127,42 +131,9 @@ final class RemotePortTunnel {
}
private static func resolveRemotePortOverride(for sshHost: String) -> Int? {
let root = ClawdbotConfigFile.loadDict()
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let urlRaw = remote["url"] as? String
else {
return nil
}
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else {
return nil
}
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
return nil
}
let sshKey = Self.hostKey(sshHost)
let urlKey = Self.hostKey(host)
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
guard sshKey == urlKey else {
Self.logger.debug(
"remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)")
return nil
}
return port
}
private static func hostKey(_ host: String) -> String {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return "" }
if trimmed.contains(":") { return trimmed }
let digits = CharacterSet(charactersIn: "0123456789.")
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
return trimmed
}
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
let trimmed = sshHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return ClawdbotConfigFile.remoteGatewayPort(matchingHost: trimmed)
}
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
+110
View File
@@ -0,0 +1,110 @@
import Foundation
struct GatewayUsageWindow: Codable {
let label: String
let usedPercent: Double
let resetAt: Double?
}
struct GatewayUsageProvider: Codable {
let provider: String
let displayName: String
let windows: [GatewayUsageWindow]
let plan: String?
let error: String?
}
struct GatewayUsageSummary: Codable {
let updatedAt: Double
let providers: [GatewayUsageProvider]
}
struct UsageRow: Identifiable {
let id: String
let displayName: String
let plan: String?
let windowLabel: String?
let usedPercent: Double?
let resetAt: Date?
let error: String?
var titleText: String {
if let plan, !plan.isEmpty { return "\(displayName) (\(plan))" }
return displayName
}
var remainingPercent: Int? {
guard let usedPercent, usedPercent.isFinite else { return nil }
let remaining = max(0, min(100, Int(round(100 - usedPercent))))
return remaining
}
func detailText(now: Date = .init()) -> String {
if let error, !error.isEmpty { return error }
guard let remaining = self.remainingPercent else { return "No data" }
var parts = ["\(remaining)% left"]
if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) }
if let resetAt {
let reset = UsageRow.formatResetRemaining(target: resetAt, now: now)
if let reset { parts.append("⏱\(reset)") }
}
return parts.joined(separator: " · ")
}
private static func formatResetRemaining(target: Date, now: Date) -> String? {
let diff = target.timeIntervalSince(now)
if diff <= 0 { return "now" }
let minutes = Int(floor(diff / 60))
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
let mins = minutes % 60
if hours < 24 { return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" }
let days = hours / 24
if days < 7 { return "\(days)d \(hours % 24)h" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter.string(from: target)
}
}
extension GatewayUsageSummary {
func primaryRows() -> [UsageRow] {
self.providers.compactMap { provider in
if let error = provider.error, provider.windows.isEmpty {
return UsageRow(
id: provider.provider,
displayName: provider.displayName,
plan: provider.plan,
windowLabel: nil,
usedPercent: nil,
resetAt: nil,
error: error)
}
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
return nil
}
return UsageRow(
id: "\(provider.provider)-\(window.label)",
displayName: provider.displayName,
plan: provider.plan,
windowLabel: window.label,
usedPercent: window.usedPercent,
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
error: provider.error)
}
}
}
@MainActor
enum UsageLoader {
static func loadSummary() async throws -> GatewayUsageSummary {
let data = try await ControlChannel.shared.request(
method: "usage.status",
params: nil,
timeoutMs: 5000)
return try JSONDecoder().decode(GatewayUsageSummary.self, from: data)
}
}
@@ -0,0 +1,46 @@
import SwiftUI
struct UsageMenuLabelView: View {
let row: UsageRow
let width: CGFloat
private let paddingLeading: CGFloat = 22
private let paddingTrailing: CGFloat = 14
private let barHeight: CGFloat = 6
private var primaryTextColor: Color { .primary }
private var secondaryTextColor: Color { .secondary }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let used = row.usedPercent {
ContextUsageBar(
usedTokens: Int(round(used)),
contextTokens: 100,
width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
height: self.barHeight)
}
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(row.titleText)
.font(.caption.weight(.semibold))
.foregroundStyle(self.primaryTextColor)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 4)
Text(row.detailText())
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
}
.padding(.vertical, 10)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
}
}
@@ -16,6 +16,52 @@ struct ClawdbotConfigFileTests {
}
}
@MainActor
@Test
func remoteGatewayPortParsesAndMatchesHost() {
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
"url": "ws://bridge.ts.net:19999",
],
],
])
#expect(ClawdbotConfigFile.remoteGatewayPort() == 19999)
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge.ts.net") == 19999)
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge") == 19999)
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
}
}
@MainActor
@Test
func setRemoteGatewayUrlPreservesScheme() {
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
"url": "wss://old-host:111",
],
],
])
ClawdbotConfigFile.setRemoteGatewayUrl(host: "new-host", port: 2222)
let root = ClawdbotConfigFile.loadDict()
let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String
#expect(url == "wss://new-host:2222")
}
}
@Test
func stateDirOverrideSetsConfigPath() {
let dir = FileManager.default.temporaryDirectory
@@ -69,11 +69,13 @@ struct GatewayDiscoveryModelTests {
"lanHost": " studio.local ",
"tailnetDns": " peters-mac-studio-1.ts.net ",
"sshPort": " 2222 ",
"gatewayPort": " 18799 ",
"cliPath": " /opt/clawdbot "
])
#expect(parsed.lanHost == "studio.local")
#expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net")
#expect(parsed.sshPort == 2222)
#expect(parsed.gatewayPort == 18799)
#expect(parsed.cliPath == "/opt/clawdbot")
}
@@ -81,11 +83,13 @@ struct GatewayDiscoveryModelTests {
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
"lanHost": " ",
"tailnetDns": "\n",
"gatewayPort": "nope",
"sshPort": "nope"
])
#expect(parsed.lanHost == nil)
#expect(parsed.tailnetDns == nil)
#expect(parsed.sshPort == 22)
#expect(parsed.gatewayPort == nil)
#expect(parsed.cliPath == nil)
}
@@ -45,6 +45,70 @@ import Testing
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4)
#expect(ok == false)
}
@MainActor
@Test func remoteBridgePortUsesMatchingRemoteUrlPort() {
let configPath = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
let defaults = UserDefaults.standard
let prevTarget = defaults.string(forKey: remoteTargetKey)
defer {
if let prevTarget {
defaults.set(prevTarget, forKey: remoteTargetKey)
} else {
defaults.removeObject(forKey: remoteTargetKey)
}
}
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
defaults.set("user@bridge.ts.net", forKey: remoteTargetKey)
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
"url": "ws://bridge.ts.net:25000",
],
],
])
#expect(MacNodeModeCoordinator.remoteBridgePort() == 25001)
}
}
}
@MainActor
@Test func remoteBridgePortFallsBackWhenRemoteUrlHostMismatch() {
let configPath = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
let defaults = UserDefaults.standard
let prevTarget = defaults.string(forKey: remoteTargetKey)
defer {
if let prevTarget {
defaults.set(prevTarget, forKey: remoteTargetKey)
} else {
defaults.removeObject(forKey: remoteTargetKey)
}
}
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
defaults.set("user@other.ts.net", forKey: remoteTargetKey)
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
"url": "ws://bridge.ts.net:25000",
],
],
])
#expect(MacNodeModeCoordinator.remoteBridgePort() == 20001)
}
}
}
}
private struct TestError: Error {
+43 -1
View File
@@ -30,6 +30,10 @@ clawdbot [--dev] [--profile <name>] <command>
send
poll
agent
agents
list
add
delete
status
health
sessions
@@ -147,7 +151,7 @@ Options:
- `--workspace <dir>`
- `--non-interactive`
- `--mode <local|remote>`
- `--auth-choice <oauth|apiKey|minimax|skip>`
- `--auth-choice <oauth|openai-codex|antigravity|apiKey|minimax|skip>`
- `--anthropic-api-key <key>`
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto>`
@@ -272,15 +276,53 @@ Options:
- `--json`
- `--timeout <seconds>`
### `agents`
Manage isolated agents (workspaces + auth + routing).
#### `agents list`
List configured agents.
Options:
- `--json`
#### `agents add [name]`
Add a new isolated agent. If `--workspace` is omitted, runs the guided wizard.
Options:
- `--workspace <dir>`
- `--json`
#### `agents delete <id>`
Delete an agent and prune its workspace + state.
Options:
- `--force`
- `--json`
### `status`
Show linked session health and recent recipients.
Options:
- `--json`
- `--deep` (probe providers)
- `--usage` (show provider usage/quota)
- `--timeout <ms>`
- `--verbose`
### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
Surfaces:
- `/status` (adds a short usage line when available)
- `clawdbot status --usage` (prints full provider breakdown)
- macOS menu bar (Usage section under Context)
Notes:
- Data comes directly from provider usage endpoints (no estimates).
- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
- If no matching credentials exist, usage is hidden.
- Details: see [Usage tracking](/concepts/usage-tracking).
### `health`
Fetch health from the running Gateway.
+2
View File
@@ -31,6 +31,8 @@ Inside `agent.workspace`, CLAWDBOT expects these user-editable files:
On the first turn of a new session, CLAWDBOT injects the contents of these files directly into the agent context.
Blank files are skipped. Large files are trimmed and truncated with a marker so prompts stay lean (read the file for full content).
If a file is missing, CLAWDBOT injects a single “missing file” marker line (and `clawdbot setup` will create a safe default template).
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). If you delete it after completing the ritual, it should not be recreated on later restarts.
+12
View File
@@ -28,6 +28,16 @@ If you do nothing, Clawdbot runs a single agent:
- Workspace defaults to `~/clawd` (or `~/clawd-<profile>` when `CLAWDBOT_PROFILE` is set).
- State defaults to `~/.clawdbot/agents/main/agent`.
## Agent helper
Use the agent wizard to add a new isolated agent:
```bash
clawdbot agents add work
```
Then add `routing.bindings` (or let the wizard do it) to route inbound messages.
## Multiple agents = multiple people, multiple personalities
With **multiple agents**, each `agentId` becomes a **fully isolated persona**:
@@ -73,10 +83,12 @@ multiple phone numbers without mixing sessions.
agents: {
home: {
name: "Home",
workspace: "~/clawd-home",
agentDir: "~/.clawdbot/agents/home/agent",
},
work: {
name: "Work",
workspace: "~/clawd-work",
agentDir: "~/.clawdbot/agents/work/agent",
},
+27
View File
@@ -0,0 +1,27 @@
---
summary: "Usage tracking surfaces and credential requirements"
read_when:
- You are wiring provider usage/quota surfaces
- You need to explain usage tracking behavior or auth requirements
---
# Usage tracking
## What it is
- Pulls provider usage/quota directly from their usage endpoints.
- No estimated costs; only the provider-reported windows.
## Where it shows up
- `/status` in chats: adds a short “Usage” line (only if available).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- macOS menu bar: “Usage” section under Context (only if available).
## Providers + credentials
- **Anthropic (Claude)**: OAuth tokens in auth profiles.
- **GitHub Copilot**: OAuth tokens in auth profiles.
- **Gemini CLI**: OAuth tokens in auth profiles.
- **Antigravity**: OAuth tokens in auth profiles.
- **OpenAI Codex**: OAuth tokens in auth profiles (accountId used when present).
- **z.ai**: API key via env/config/auth store.
Usage is hidden if no matching OAuth/API credentials exist.
+43 -4
View File
@@ -330,8 +330,10 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o
- `routing.defaultAgentId`: fallback when no binding matches (default: `main`).
- `routing.agents.<agentId>`: per-agent overrides.
- `name`: display name for the agent.
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to legacy `agent.workspace`).
- `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
- `model`: per-agent default model (provider/model), overrides `agent.model` for that agent.
- `routing.bindings[]`: routes inbound messages to an `agentId`.
- `match.provider` (required)
- `match.accountId` (optional; `*` = any account; omitted = default account)
@@ -465,7 +467,21 @@ Set `telegram.enabled: false` to disable automatic startup.
botToken: "your-bot-token",
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["tg:123456789"], // optional; "open" requires ["*"]
groups: { "*": { requireMention: true } },
groups: {
"*": { requireMention: true },
"-1001234567890": {
allowFrom: ["@admin"],
systemPrompt: "Keep answers brief.",
topics: {
"99": {
requireMention: false,
skills: ["search"],
systemPrompt: "Stay on topic."
}
}
}
},
streamMode: "partial", // off | partial | block (draft streaming)
actions: { reactions: true }, // tool action gates (false disables)
mediaMaxMb: 5,
proxy: "socks5://localhost:9050",
@@ -476,6 +492,11 @@ Set `telegram.enabled: false` to disable automatic startup.
}
```
Draft streaming notes:
- Uses Telegram `sendMessageDraft` (draft bubble, not a real message).
- Requires **private chat topics** (message_thread_id in DMs; bot has topics enabled).
- `/reasoning stream` streams reasoning into the draft, then sends the final answer.
### `discord` (bot transport)
Configure the Discord bot by setting the bot token and optional gating:
@@ -519,7 +540,13 @@ Configure the Discord bot by setting the bot token and optional gating:
users: ["987654321098765432"], // optional per-guild user allowlist
channels: {
general: { allow: true },
help: { allow: true, requireMention: true }
help: {
allow: true,
requireMention: true,
users: ["987654321098765432"],
skills: ["docs"],
systemPrompt: "Short answers only."
}
}
}
},
@@ -555,7 +582,13 @@ Slack runs in Socket Mode and requires both a bot token and app token:
},
channels: {
C123: { allow: true, requireMention: true },
"#general": { allow: true, requireMention: false }
"#general": {
allow: true,
requireMention: true,
users: ["U123"],
skills: ["docs"],
systemPrompt: "Short answers only."
}
},
reactionNotifications: "own", // off | own | all | allowlist
reactionAllowlist: ["U123"],
@@ -865,7 +898,10 @@ sessions so they cannot access your host system.
Defaults (if enabled):
- scope: `"agent"` (one container + workspace per agent)
- Debian bookworm-slim based image
- workspace per agent under `~/.clawdbot/sandboxes`
- agent workspace access: `workspaceAccess: "none"` (default)
- `"none"`: use a per-scope sandbox workspace under `~/.clawdbot/sandboxes`
- `"ro"`: keep the sandbox workspace at `/workspace`, and mount the agent workspace read-only at `/agent` (disables `write`/`edit`)
- `"rw"`: mount the agent workspace read/write at `/workspace`
- auto-prune: idle > 24h OR age > 7d
- tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins)
- optional sandboxed browser (Chromium + CDP, noVNC observer)
@@ -883,6 +919,7 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
sandbox: {
mode: "non-main", // off | non-main | all
scope: "agent", // session | agent | shared (agent is default)
workspaceAccess: "none", // none | ro | rw
workspaceRoot: "~/.clawdbot/sandboxes",
docker: {
image: "clawdbot-sandbox:bookworm-slim",
@@ -939,6 +976,8 @@ scripts/sandbox-setup.sh
Note: sandbox containers default to `network: "none"`; set `agent.sandbox.docker.network`
to `"bridge"` (or your custom network) if the agent needs outbound access.
Note: inbound attachments are staged into the active workspace at `media/inbound/*`. With `workspaceAccess: "rw"`, that means files are written into the agent workspace.
Build the optional browser image with:
```bash
scripts/sandbox-browser-setup.sh
+5
View File
@@ -146,6 +146,11 @@ Note: to prevent cross-agent access, keep `sandbox.scope` at `"agent"` (default)
or `"session"` for stricter per-session isolation. `scope: "shared"` uses a
single container/workspace.
Also consider agent workspace access inside the sandbox:
- `agent.sandbox.workspaceAccess: "none"` (default) keeps the agent workspace off-limits; tools run against a sandbox workspace under `~/.clawdbot/sandboxes`
- `workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`)
- `workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace`
Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers.
## What to Tell Your AI
+6 -2
View File
@@ -79,8 +79,9 @@ container. The gateway stays on your host, but the tool execution is isolated:
- scope: `"agent"` by default (one container + workspace per agent)
- scope: `"session"` for per-session isolation
- per-scope workspace folder mounted at `/workspace`
- optional agent workspace access (`agent.sandbox.workspaceAccess`)
- allow/deny tool policy (deny wins)
- inbound media is copied into the sandbox workspace (`media/inbound/*`) so tools can read it
- inbound media is copied into the active sandbox workspace (`media/inbound/*`) so tools can read it (with `workspaceAccess: "rw"`, this lands in the agent workspace)
Warning: `scope: "shared"` disables cross-session isolation. All sessions share
one container and one workspace.
@@ -89,7 +90,9 @@ one container and one workspace.
- Image: `clawdbot-sandbox:bookworm-slim`
- One container per agent
- Workspace per agent under `~/.clawdbot/sandboxes`
- Agent workspace access: `workspaceAccess: "none"` (default) uses `~/.clawdbot/sandboxes`
- `"ro"` keeps the sandbox workspace at `/workspace` and mounts the agent workspace read-only at `/agent` (disables `write`/`edit`)
- `"rw"` mounts the agent workspace read/write at `/workspace`
- Auto-prune: idle > 24h OR age > 7d
- Network: `none` by default (explicitly opt-in if you need egress)
- Default allow: `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`
@@ -103,6 +106,7 @@ one container and one workspace.
sandbox: {
mode: "non-main", // off | non-main | all
scope: "agent", // session | agent | shared (agent is default)
workspaceAccess: "none", // none | ro | rw
workspaceRoot: "~/.clawdbot/sandboxes",
docker: {
image: "clawdbot-sandbox:bookworm-slim",
+1 -1
View File
@@ -97,7 +97,7 @@ Runbook + exact service labels: [Gateway runbook](/gateway)
Install a known-good version:
```bash
npm i -g clawdbot@2026.1.5-3
npm i -g clawdbot@2026.1.7
```
Then restart + re-run doctor:
+1
View File
@@ -9,6 +9,7 @@ read_when:
- We surface the current agent work state in the menu bar icon and in the first status row of the menu.
- Health status is hidden while work is active; it returns when all sessions are idle.
- The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries.
- A “Usage” section appears under Context when provider usage snapshots are available.
## State model
- Sessions: events arrive with `runId` (per-run) plus `sessionKey` in the payload. The “main” session is the key `main`; if absent, we fall back to the most recently updated session.
+13 -1
View File
@@ -197,7 +197,13 @@ Notes:
users: ["987654321098765432", "steipete"],
channels: {
general: { allow: true },
help: { allow: true, requireMention: true }
help: {
allow: true,
requireMention: true,
users: ["987654321098765432"],
skills: ["search", "docs"],
systemPrompt: "Keep answers short."
}
}
}
}
@@ -218,6 +224,12 @@ Ack reactions are controlled globally via `messages.ackReaction` +
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
- `guilds.<id>.slug`: optional friendly slug used for display names.
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel (combined with channel topic).
- `guilds.<id>.channels.<channel>.enabled`: set `false` to disable the channel.
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
+2 -1
View File
@@ -5,7 +5,7 @@ read_when:
---
# grammY Integration (Telegram Bot API)
Updated: 2025-12-07
Updated: 2026-01-07
# Why grammY
- TS-first Bot API client with built-in long-poll + webhook helpers, middleware, error handling, rate limiter.
@@ -19,6 +19,7 @@ Updated: 2025-12-07
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls).
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same provider.
- **Config knobs:** `telegram.botToken`, `telegram.dmPolicy`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`.
- **Draft streaming:** optional `telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+).
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
Open questions
+15 -1
View File
@@ -157,7 +157,13 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
},
"channels": {
"C123": { "allow": true, "requireMention": true },
"#general": { "allow": true, "requireMention": false }
"#general": {
"allow": true,
"requireMention": true,
"users": ["U123"],
"skills": ["search", "docs"],
"systemPrompt": "Keep answers short."
}
},
"reactionNotifications": "own",
"reactionAllowlist": ["U123"],
@@ -203,6 +209,14 @@ Ack reactions are controlled globally via `messages.ackReaction` +
- `slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
- `allowlist` requires channels to be listed in `slack.channels`.
Channel options (`slack.channels.<id>` or `slack.channels.<name>`):
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `requireMention`: mention gating for the channel.
- `users`: optional per-channel user allowlist.
- `skills`: skill filter (omit = all skills, empty = none).
- `systemPrompt`: extra system prompt for the channel (combined with topic/purpose).
- `enabled`: set `false` to disable the channel.
## Delivery targets
Use these with cron/CLI sends:
- `user:<id>` for DMs
+125 -4
View File
@@ -5,7 +5,7 @@ read_when:
---
# Telegram (Bot API)
Updated: 2026-01-06
Updated: 2026-01-07
Status: production-ready for bot DMs + groups via grammY. Long-polling by default; webhook optional.
@@ -38,22 +38,93 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
- Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`).
- Replies always route back to the same Telegram chat.
## Group activation modes
By default, the bot only responds to mentions in groups (`@botname` or patterns in `routing.groupChat.mentionPatterns`). To change this behavior:
### Via config (recommended)
```json5
{
telegram: {
groups: {
"-1001234567890": { requireMention: false } // always respond in this group
}
}
}
```
**Important:** Setting `telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted.
To allow all groups with always-respond:
```json5
{
telegram: {
groups: {
"*": { requireMention: false } // all groups, always respond
}
}
}
```
To keep mention-only for all groups (default behavior):
```json5
{
telegram: {
groups: {
"*": { requireMention: true } // or omit groups entirely
}
}
}
```
### Via command (session-level)
Send in the group:
- `/activation always` - respond to all messages
- `/activation mention` - require mentions (default)
**Note:** Commands update session state only. For persistent behavior across restarts, use config.
### Getting the group chat ID
Forward any message from the group to `@userinfobot` or `@getidsbot` on Telegram to see the chat ID (negative number like `-1001234567890`).
## Topics (forum supergroups)
Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
- Sends typing indicators and replies with `message_thread_id` so responses stay in the topic.
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
- Topic-specific configuration is available under `telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
Private topics (DM forum mode) also include `message_thread_id`. Clawdbot:
- Appends `:topic:<threadId>` to **DM** session keys for isolation.
- Uses the thread id for draft streaming + replies.
## Access control (DMs + groups)
### DM access
- Default: `telegram.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via:
- `clawdbot pairing list --provider telegram`
- `clawdbot pairing approve --provider telegram <CODE>`
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
Group gating:
- `telegram.groupPolicy = open | allowlist | disabled`.
- `telegram.groups` doubles as a group allowlist when set (include `"*"` to allow all).
### Group access
Two independent controls:
**1. Which groups are allowed** (group allowlist via `telegram.groups`):
- No `groups` config = all groups allowed
- With `groups` config = only listed groups or `"*"` are allowed
- Example: `"groups": { "-1001234567890": {}, "*": {} }` allows all groups
**2. Which senders are allowed** (sender filtering via `telegram.groupPolicy`):
- `"open"` (default) = all senders in allowed groups can message
- `"allowlist"` = only senders in `telegram.groupAllowFrom` can message
- `"disabled"` = no group messages accepted at all
Most users want: `groupPolicy: "open"` + specific groups listed in `telegram.groups`
## Long-polling vs webhook
- Default: long-polling (no public URL required).
@@ -69,6 +140,27 @@ Telegram supports optional threaded replies via tags:
Controlled by `telegram.replyToMode`:
- `off` (default), `first`, `all`.
## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
final reply as a normal message.
Requirements (Telegram Bot API 9.3+):
- **Private chats with topics enabled** (forum topic mode for the bot).
- Incoming messages must include `message_thread_id` (private topic thread).
- Streaming is ignored for groups/supergroups/channels.
Config:
- `telegram.streamMode: "off" | "partial" | "block"` (default: `partial`)
- `partial`: update the draft bubble with the latest streaming text.
- `block`: update the draft bubble in larger blocks (chunked).
- `off`: disable draft streaming.
Reasoning stream (Telegram only):
- `/reasoning stream` streams reasoning into the draft bubble while the reply is
generating, then sends the final answer without reasoning.
- If `telegram.streamMode` is `off`, reasoning stream is disabled.
## Agent tool (reactions)
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
@@ -78,6 +170,27 @@ Controlled by `telegram.replyToMode`:
- Use a chat id (`123456789`) or a username (`@name`) as the target.
- Example: `clawdbot send --provider telegram --to 123456789 "hi"`.
## Troubleshooting
**Bot doesn't respond to non-mention messages in group:**
- Check if group is in `telegram.groups` with `requireMention: false`
- Or use `"*": { "requireMention": false }` to enable for all groups
- Test with `/activation always` command (requires config change to persist)
**Bot not seeing group messages at all:**
- If `telegram.groups` is set, the group must be listed or use `"*"`
- Check Privacy Settings in @BotFather → "Group Privacy" should be **OFF**
- Verify bot is actually a member (not just an admin with no read access)
- Check gateway logs: `journalctl --user -u clawdbot -f` (look for "skipping group message")
**Bot responds to mentions but not `/activation always`:**
- The `/activation` command updates session state but doesn't persist to config
- For persistent behavior, add group to `telegram.groups` with `requireMention: false`
**Commands like `/status` don't work:**
- Make sure your Telegram user ID is authorized (via pairing or `telegram.allowFrom`)
- Commands require authorization even in groups with `groupPolicy: "open"`
## Configuration reference (Telegram)
Full configuration: [Configuration](/gateway/configuration)
@@ -90,8 +203,16 @@ Provider options:
- `telegram.groupPolicy`: `open | allowlist | disabled` (default: open).
- `telegram.groupAllowFrom`: group sender allowlist (ids/usernames).
- `telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
- `telegram.groups.<id>.requireMention`: mention gating default.
- `telegram.groups.<id>.skills`: skill filter (omit = all skills, empty = none).
- `telegram.groups.<id>.allowFrom`: per-group sender allowlist override.
- `telegram.groups.<id>.systemPrompt`: extra system prompt for the group.
- `telegram.groups.<id>.enabled`: disable the group when `false`.
- `telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
- `telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- `telegram.replyToMode`: `off | first | all`.
- `telegram.textChunkLimit`: outbound chunk size (chars).
- `telegram.streamMode`: `off | partial | block` (draft streaming).
- `telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
- `telegram.webhookUrl`: enable webhook mode.
+1 -1
View File
@@ -603,7 +603,7 @@ Quick reference (send these in chat):
| `/activation mention\|always` | Group activation (owner-only) |
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
| `/verbose on\|off` | Toggle verbose mode |
| `/reasoning on\|off` | Toggle reasoning visibility |
| `/reasoning on\|off\|stream` | Toggle reasoning visibility (stream = Telegram draft only) |
| `/elevated on\|off` | Toggle elevated bash mode (approved senders only) |
| `/model <name>` | Switch AI model (see below) |
| `/queue <mode>` | Queue mode (see below) |
+28
View File
@@ -38,6 +38,12 @@ clawdbot configure
**Remote mode** only configures the local client to connect to a Gateway elsewhere.
It does **not** install or change anything on the remote host.
To add more isolated agents (separate workspace + sessions + auth), use:
```bash
clawdbot agents add <name>
```
## Flow details (local)
1) **Existing config detection**
@@ -110,6 +116,20 @@ Notes:
- macOS: Bonjour (`dns-sd`)
- Linux: Avahi (`avahi-browse`)
## Add another agent
Use `clawdbot agents add <name>` to create a separate agent with its own workspace,
sessions, and auth profiles. Running without `--workspace` launches the wizard.
What it sets:
- `routing.agents.<agentId>.name`
- `routing.agents.<agentId>.workspace`
- `routing.agents.<agentId>.agentDir`
Notes:
- Default workspaces follow `~/clawd-<agentId>`.
- Add `routing.bindings` to route inbound messages (the wizard can do this).
## Non‑interactive mode
Use `--non-interactive` to automate or script onboarding:
@@ -128,6 +148,12 @@ clawdbot onboard --non-interactive \
Add `--json` for a machine‑readable summary.
Add agent (non‑interactive) example:
```bash
clawdbot agents add work --workspace ~/clawd-work
```
## Gateway wizard RPC
The Gateway exposes the wizard flow over RPC (`wizard.start`, `wizard.next`, `wizard.cancel`, `wizard.status`).
@@ -159,6 +185,8 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
- `wizard.lastRunCommand`
- `wizard.lastRunMode`
`clawdbot agents add` writes `routing.agents.<agentId>` and optional `routing.bindings`.
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.
+1 -1
View File
@@ -40,7 +40,7 @@ Text + native (when enabled):
- `/reset` or `/new`
- `/think <level>` (aliases: `/thinking`, `/t`)
- `/verbose on|off` (alias: `/v`)
- `/reasoning on|off` (alias: `/reason`)
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
- `/model <name>`
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`)
+2 -1
View File
@@ -35,9 +35,10 @@ read_when:
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
## Reasoning visibility (/reasoning)
- Levels: `on|off`.
- Levels: `on|off|stream`.
- Directive-only message toggles whether thinking blocks are shown as italic text in replies.
- When enabled, any model-provided reasoning content is appended as a separate italic block.
- `stream` (Telegram only): streams reasoning into the Telegram draft bubble while the reply is generating, then sends the final answer without reasoning.
- Alias: `/reason`.
## Related
+2 -2
View File
@@ -6,7 +6,7 @@ read_when:
---
# TUI (Gateway chat client)
Updated: 2026-01-03
Updated: 2026-01-07
## What it is
- A terminal UI that connects to the Gateway WebSocket and speaks the same chat APIs as WebChat.
@@ -51,7 +51,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/model <provider/model>` (or `/model list`, `/models`)
- `/think <off|minimal|low|medium|high>`
- `/verbose <on|off>`
- `/reasoning <on|off>`
- `/reasoning <on|off|stream>` (stream = Telegram draft only)
- `/elevated <on|off>`
- `/elev <on|off>`
- `/activation <mention|always>`
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.5-3",
"version": "2026.1.7",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -83,7 +83,7 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@buape/carbon": "^0.13.0",
"@buape/carbon": "0.0.0-beta-20260107085330",
"@clack/prompts": "^0.11.0",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
+75 -55
View File
@@ -23,8 +23,8 @@ importers:
.:
dependencies:
'@buape/carbon':
specifier: ^0.13.0
version: 0.13.0(@types/react@19.2.7)(hono@4.11.3)
specifier: 0.0.0-beta-20260107085330
version: 0.0.0-beta-20260107085330(hono@4.11.3)
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
@@ -334,8 +334,8 @@ packages:
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
'@buape/carbon@0.13.0':
resolution: {integrity: sha512-N52sGIJj832IezL+JmekC4gE7cCORj8r8mCJ1vsHOZiyr3O2pvsUA930E1j+rjStkd67TLxURPRMrpyqAFveIg==}
'@buape/carbon@0.0.0-beta-20260107085330':
resolution: {integrity: sha512-KLRwmH6aZHmKzEv2/cFb8qIqCrU9RBitj0iqWHSqzBLVrkFo65LR7zo7TsM8GVG6Si5YOKgLTVQSwjhXQcTwWw==}
'@cacheable/memory@2.0.7':
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
@@ -353,8 +353,8 @@ packages:
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@cloudflare/workers-types@4.20250513.0':
resolution: {integrity: sha512-TXaQyWLqhxEmi/DHx+VSaHZ4DHF/uJCPVv/hRyC7M/eWBo/I7mBtAkUEsrhqcKKO9oCeeRUHUHoeRLh5Gd96Gg==}
'@cloudflare/workers-types@4.20251205.0':
resolution: {integrity: sha512-7pup7fYkuQW5XD8RUS/vkxF9SXlrGyCXuZ4ro3uVQvca/GTeSa+8bZ8T4wbq1Aea5lmLIGSlKbhl2msME7bRBA==}
'@crosscopy/clipboard-darwin-arm64@0.2.8':
resolution: {integrity: sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==}
@@ -407,6 +407,10 @@ packages:
resolution: {integrity: sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==}
engines: {node: '>= 10'}
'@discordjs/voice@0.19.0':
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
engines: {node: '>=22.12.0'}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -606,8 +610,8 @@ packages:
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
hasBin: true
'@hono/node-server@1.18.2':
resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==}
'@hono/node-server@1.19.6':
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -1205,8 +1209,8 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/bun@1.2.23':
resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==}
'@types/bun@1.3.3':
resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1253,8 +1257,8 @@ packages:
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@22.19.3':
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
'@types/node@24.10.4':
resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==}
'@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
@@ -1271,9 +1275,6 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@@ -1517,10 +1518,8 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bun-types@1.2.23:
resolution: {integrity: sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw==}
peerDependencies:
'@types/react': ^19
bun-types@1.3.3:
resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
@@ -1632,9 +1631,6 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
curve25519-js@0.0.4:
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
@@ -1674,8 +1670,8 @@ packages:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
discord-api-types@0.38.29:
resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==}
discord-api-types@0.38.36:
resolution: {integrity: sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==}
discord-api-types@0.38.37:
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
@@ -2546,6 +2542,23 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prism-media@1.3.5:
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
peerDependencies:
'@discordjs/opus': '>=0.8.0 <1.0.0'
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
node-opus: ^0.3.3
opusscript: ^0.0.8
peerDependenciesMeta:
'@discordjs/opus':
optional: true
ffmpeg-static:
optional: true
node-opus:
optional: true
opusscript:
optional: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -2923,9 +2936,6 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -3190,20 +3200,24 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.13.0(@types/react@19.2.7)(hono@4.11.3)':
'@buape/carbon@0.0.0-beta-20260107085330(hono@4.11.3)':
dependencies:
'@types/node': 22.19.3
discord-api-types: 0.38.29
'@types/node': 24.10.4
discord-api-types: 0.38.36
optionalDependencies:
'@cloudflare/workers-types': 4.20250513.0
'@hono/node-server': 1.18.2(hono@4.11.3)
'@types/bun': 1.2.23(@types/react@19.2.7)
'@cloudflare/workers-types': 4.20251205.0
'@discordjs/voice': 0.19.0
'@hono/node-server': 1.19.6(hono@4.11.3)
'@types/bun': 1.3.3
'@types/ws': 8.18.1
ws: 8.18.3
transitivePeerDependencies:
- '@types/react'
- '@discordjs/opus'
- bufferutil
- ffmpeg-static
- hono
- node-opus
- opusscript
- utf-8-validate
'@cacheable/memory@2.0.7':
@@ -3235,7 +3249,7 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
'@cloudflare/workers-types@4.20250513.0':
'@cloudflare/workers-types@4.20251205.0':
optional: true
'@crosscopy/clipboard-darwin-arm64@0.2.8':
@@ -3273,6 +3287,22 @@ snapshots:
'@crosscopy/clipboard-win32-arm64-msvc': 0.2.8
'@crosscopy/clipboard-win32-x64-msvc': 0.2.8
'@discordjs/voice@0.19.0':
dependencies:
'@types/ws': 8.18.1
discord-api-types: 0.38.37
prism-media: 1.3.5
tslib: 2.8.1
ws: 8.19.0
transitivePeerDependencies:
- '@discordjs/opus'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
optional: true
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -3403,7 +3433,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@hono/node-server@1.18.2(hono@4.11.3)':
'@hono/node-server@1.19.6(hono@4.11.3)':
dependencies:
hono: 4.11.3
optional: true
@@ -3956,11 +3986,9 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.0.3
'@types/bun@1.2.23(@types/react@19.2.7)':
'@types/bun@1.3.3':
dependencies:
bun-types: 1.2.23(@types/react@19.2.7)
transitivePeerDependencies:
- '@types/react'
bun-types: 1.3.3
optional: true
'@types/chai@5.2.3':
@@ -4013,9 +4041,9 @@ snapshots:
'@types/node@10.17.60': {}
'@types/node@22.19.3':
'@types/node@24.10.4':
dependencies:
undici-types: 6.21.0
undici-types: 7.16.0
'@types/node@25.0.3':
dependencies:
@@ -4031,11 +4059,6 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react@19.2.7':
dependencies:
csstype: 3.2.3
optional: true
'@types/retry@0.12.0': {}
'@types/retry@0.12.5': {}
@@ -4350,10 +4373,9 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
bun-types@1.2.23(@types/react@19.2.7):
bun-types@1.3.3:
dependencies:
'@types/node': 25.0.3
'@types/react': 19.2.7
optional: true
bytes@3.1.2: {}
@@ -4471,9 +4493,6 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
csstype@3.2.3:
optional: true
curve25519-js@0.0.4: {}
data-uri-to-buffer@4.0.1: {}
@@ -4495,7 +4514,7 @@ snapshots:
diff@8.0.2: {}
discord-api-types@0.38.29: {}
discord-api-types@0.38.36: {}
discord-api-types@0.38.37: {}
@@ -5385,6 +5404,9 @@ snapshots:
react-is: 17.0.2
optional: true
prism-media@1.3.5:
optional: true
process-nextick-args@2.0.1: {}
process-warning@5.0.0: {}
@@ -5856,8 +5878,6 @@ snapshots:
uint8array-extras@1.5.0: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@7.18.0: {}
+10 -1
View File
@@ -21,16 +21,25 @@ export function resolveAgentIdFromSessionKey(
export function resolveAgentConfig(
cfg: ClawdbotConfig,
agentId: string,
): { workspace?: string; agentDir?: string } | undefined {
):
| {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
}
| undefined {
const id = normalizeAgentId(agentId);
const agents = cfg.routing?.agents;
if (!agents || typeof agents !== "object") return undefined;
const entry = agents[id];
if (!entry || typeof entry !== "object") return undefined;
return {
name: typeof entry.name === "string" ? entry.name : undefined,
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined,
};
}
+312
View File
@@ -1,8 +1,15 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
calculateAuthProfileCooldownMs,
ensureAuthProfileStore,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
@@ -280,6 +287,51 @@ describe("resolveAuthProfileOrder", () => {
});
});
describe("ensureAuthProfileStore", () => {
it("migrates legacy auth.json and deletes it (PR #368)", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-auth-profiles-"),
);
try {
const legacyPath = path.join(agentDir, "auth.json");
fs.writeFileSync(
legacyPath,
`${JSON.stringify(
{
anthropic: {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
null,
2,
)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toMatchObject({
type: "oauth",
provider: "anthropic",
});
const migratedPath = path.join(agentDir, "auth-profiles.json");
expect(fs.existsSync(migratedPath)).toBe(true);
expect(fs.existsSync(legacyPath)).toBe(false);
// idempotent
const store2 = ensureAuthProfileStore(agentDir);
expect(store2.profiles["anthropic:default"]).toBeDefined();
expect(fs.existsSync(legacyPath)).toBe(false);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
describe("auth profile cooldowns", () => {
it("applies exponential backoff with a 1h cap", () => {
expect(calculateAuthProfileCooldownMs(1)).toBe(60_000);
@@ -289,3 +341,263 @@ describe("auth profile cooldowns", () => {
expect(calculateAuthProfileCooldownMs(5)).toBe(60 * 60_000);
});
});
describe("external CLI credential sync", () => {
it("syncs Claude CLI credentials into anthropic:claude-cli", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-sync-"),
);
const originalHome = process.env.HOME;
try {
// Create a temp home with Claude CLI credentials
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
process.env.HOME = tempHome;
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "fresh-access-token",
refreshToken: "fresh-refresh-token",
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
},
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
// Load the store - should sync from CLI
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toBeDefined();
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe(
"sk-default",
);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
).toBe("fresh-access-token");
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires,
).toBeGreaterThan(Date.now());
} finally {
process.env.HOME = originalHome;
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("syncs Codex CLI credentials into openai-codex:codex-cli", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-codex-sync-"),
);
const originalHome = process.env.HOME;
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
process.env.HOME = tempHome;
// Create Codex CLI credentials
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexCreds = {
tokens: {
access_token: "codex-access-token",
refresh_token: "codex-refresh-token",
},
};
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds));
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access,
).toBe("codex-access-token");
} finally {
process.env.HOME = originalHome;
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("does not overwrite API keys when syncing external CLI creds", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-no-overwrite-"),
);
const originalHome = process.env.HOME;
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
process.env.HOME = tempHome;
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
// Create auth-profiles.json with an API key
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-store",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
// Should keep the store's API key and still add the CLI profile.
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe(
"sk-store",
);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
} finally {
process.env.HOME = originalHome;
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("does not overwrite fresher store OAuth with older Claude CLI credentials", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
);
const originalHome = process.env.HOME;
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
process.env.HOME = tempHome;
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
}),
);
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "anthropic",
access: "store-access",
refresh: "store-refresh",
expires: Date.now() + 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
).toBe("store-access");
} finally {
process.env.HOME = originalHome;
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("updates codex-cli profile when Codex CLI refresh token changes", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"),
);
const originalHome = process.env.HOME;
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
process.env.HOME = tempHome;
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: { access_token: "same-access", refresh_token: "new-refresh" },
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "same-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh,
).toBe("new-refresh");
} finally {
process.env.HOME = originalHome;
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
+210 -4
View File
@@ -11,6 +11,7 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthPath } from "../config/paths.js";
import type { AuthProfileConfig } from "../config/types.js";
import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import { normalizeProviderId } from "./model-selection.js";
@@ -18,6 +19,14 @@ import { normalizeProviderId } from "./model-selection.js";
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = "auth-profiles.json";
const LEGACY_AUTH_FILENAME = "auth.json";
// External CLI credential file locations
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
const CODEX_CLI_AUTH_RELATIVE_PATH = ".codex/auth.json";
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
const AUTH_STORE_LOCK_OPTIONS = {
retries: {
retries: 10,
@@ -29,6 +38,8 @@ const AUTH_STORE_LOCK_OPTIONS = {
stale: 30_000,
} as const;
const log = createSubsystemLogger("agents/auth-profiles");
export type ApiKeyCredential = {
type: "api_key";
provider: string;
@@ -264,11 +275,177 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
return mutated;
}
/**
* Read Anthropic OAuth credentials from Claude CLI's credential file.
* Claude CLI stores credentials at ~/.claude/.credentials.json
*/
function readClaudeCliCredentials(): OAuthCredential | null {
const credPath = path.join(
resolveUserPath("~"),
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
);
const raw = loadJsonFile(credPath);
if (!raw || typeof raw !== "object") return null;
const data = raw as Record<string, unknown>;
const claudeOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
type: "oauth",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
};
}
/**
* Read OpenAI Codex OAuth credentials from Codex CLI's auth file.
* Codex CLI stores credentials at ~/.codex/auth.json
*/
function readCodexCliCredentials(): OAuthCredential | null {
const authPath = path.join(
resolveUserPath("~"),
CODEX_CLI_AUTH_RELATIVE_PATH,
);
const raw = loadJsonFile(authPath);
if (!raw || typeof raw !== "object") return null;
const data = raw as Record<string, unknown>;
const tokens = data.tokens as Record<string, unknown> | undefined;
if (!tokens || typeof tokens !== "object") return null;
const accessToken = tokens.access_token;
const refreshToken = tokens.refresh_token;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
// Codex CLI doesn't store expiry, estimate 1 hour from file mtime or now
let expires: number;
try {
const stat = fs.statSync(authPath);
// Assume token is valid for ~1 hour from when the file was last modified
expires = stat.mtimeMs + 60 * 60 * 1000;
} catch {
expires = Date.now() + 60 * 60 * 1000;
}
return {
type: "oauth",
provider: "openai-codex" as unknown as OAuthProvider,
access: accessToken,
refresh: refreshToken,
expires,
};
}
function shallowEqualOAuthCredentials(
a: OAuthCredential | undefined,
b: OAuthCredential,
): boolean {
if (!a) return false;
if (a.type !== "oauth") return false;
return (
a.provider === b.provider &&
a.access === b.access &&
a.refresh === b.refresh &&
a.expires === b.expires &&
a.email === b.email &&
a.enterpriseUrl === b.enterpriseUrl &&
a.projectId === b.projectId &&
a.accountId === b.accountId
);
}
/**
* Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring
* separate authentication, and keeps credentials in sync when CLI tools refresh tokens.
*
* Returns true if any credentials were updated.
*/
function syncExternalCliCredentials(store: AuthProfileStore): boolean {
let mutated = false;
const now = Date.now();
// Sync from Claude CLI
const claudeCreds = readClaudeCliCredentials();
if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "anthropic" ||
existingOAuth.expires <= now ||
(claudeCreds.expires > now &&
claudeCreds.expires > existingOAuth.expires);
if (
shouldUpdate &&
!shallowEqualOAuthCredentials(existingOAuth, claudeCreds)
) {
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
mutated = true;
log.info("synced anthropic credentials from claude cli", {
profileId: CLAUDE_CLI_PROFILE_ID,
expires: new Date(claudeCreds.expires).toISOString(),
});
}
}
// Sync from Codex CLI
const codexCreds = readCodexCliCredentials();
if (codexCreds) {
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) ||
existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires;
if (
shouldUpdate &&
!shallowEqualOAuthCredentials(existingOAuth, codexCreds)
) {
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
mutated = true;
log.info("synced openai-codex credentials from codex cli", {
profileId: CODEX_CLI_PROFILE_ID,
expires: new Date(codexCreds.expires).toISOString(),
});
}
}
return mutated;
}
export function loadAuthProfileStore(): AuthProfileStore {
const authPath = resolveAuthStorePath();
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) return asStore;
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore);
if (synced) {
saveJsonFile(authPath, asStore);
}
return asStore;
}
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
const legacy = coerceLegacyStore(legacyRaw);
@@ -300,17 +477,27 @@ export function loadAuthProfileStore(): AuthProfileStore {
};
}
}
syncExternalCliCredentials(store);
return store;
}
return { version: AUTH_STORE_VERSION, profiles: {} };
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
syncExternalCliCredentials(store);
return store;
}
export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) return asStore;
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore);
if (synced) {
saveJsonFile(authPath, asStore);
}
return asStore;
}
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
const legacy = coerceLegacyStore(legacyRaw);
@@ -345,10 +532,29 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
const shouldWrite = legacy !== null || mergedOAuth;
const syncedCli = syncExternalCliCredentials(store);
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
if (shouldWrite) {
saveJsonFile(authPath, store);
}
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
// overwriting fresh OAuth creds with stale tokens (fixes #363). Delete only
// after we've successfully written auth-profiles.json.
if (shouldWrite && legacy !== null) {
const legacyPath = resolveLegacyAuthStorePath(agentDir);
try {
fs.unlinkSync(legacyPath);
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
log.warn("failed to delete legacy auth.json after migration", {
err,
legacyPath,
});
}
}
}
return store;
}
+14
View File
@@ -132,6 +132,20 @@ describe("bash tool backgrounding", () => {
).rejects.toThrow("elevated is not available right now.");
});
it("does not default to elevated when not allowed", async () => {
const customBash = createBashTool({
elevated: { enabled: true, allowed: false, defaultLevel: "on" },
backgroundMs: 1000,
timeoutSec: 5,
});
const result = await customBash.execute("call1", {
command: "echo hi",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("hi");
});
it("logs line-based slices and defaults to last lines", async () => {
const result = await bashTool.execute("call1", {
command:
+5 -1
View File
@@ -165,10 +165,14 @@ export function createBashTool(
const sessionId = randomUUID();
const warnings: string[] = [];
const elevatedDefaults = defaults?.elevated;
const elevatedDefaultOn =
elevatedDefaults?.defaultLevel === "on" &&
elevatedDefaults.enabled &&
elevatedDefaults.allowed;
const elevatedRequested =
typeof params.elevated === "boolean"
? params.elevated
: elevatedDefaults?.defaultLevel === "on";
: elevatedDefaultOn;
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
throw new Error("elevated is not available right now.");
+37 -66
View File
@@ -1,77 +1,48 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import { buildBootstrapContextFiles } from "./pi-embedded-helpers.js";
import {
isRateLimitAssistantError,
pickFallbackThinkingLevel,
} from "./pi-embedded-helpers.js";
DEFAULT_AGENTS_FILENAME,
type WorkspaceBootstrapFile,
} from "./workspace.js";
const asAssistant = (overrides: Partial<AssistantMessage>) =>
({
role: "assistant",
stopReason: "error",
...overrides,
}) as AssistantMessage;
describe("isRateLimitAssistantError", () => {
it("detects 429 rate limit payloads", () => {
const msg = asAssistant({
errorMessage:
'429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account\'s rate limit. Please try again later."}}',
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("detects human-readable rate limit messages", () => {
const msg = asAssistant({
errorMessage: "Too many requests. Rate limit exceeded.",
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("detects quota exceeded messages", () => {
const msg = asAssistant({
errorMessage:
"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("returns false for non-error messages", () => {
const msg = asAssistant({
stopReason: "end_turn",
errorMessage: "rate limit",
});
expect(isRateLimitAssistantError(msg)).toBe(false);
});
const makeFile = (
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("pickFallbackThinkingLevel", () => {
it("selects the first supported thinking level", () => {
const attempted = new Set<ThinkLevel>(["low"]);
const next = pickFallbackThinkingLevel({
message:
"Unsupported value: 'low' is not supported with the 'gpt-5.2-pro' model. Supported values are: 'medium', 'high', and 'xhigh'.",
attempted,
});
expect(next).toBe("medium");
describe("buildBootstrapContextFiles", () => {
it("keeps missing markers", () => {
const files = [makeFile({ missing: true, content: undefined })];
expect(buildBootstrapContextFiles(files)).toEqual([
{
path: DEFAULT_AGENTS_FILENAME,
content: "[MISSING] Expected at: /tmp/AGENTS.md",
},
]);
});
it("skips already attempted levels", () => {
const attempted = new Set<ThinkLevel>(["low", "medium"]);
const next = pickFallbackThinkingLevel({
message: "Supported values are: 'medium', 'high', and 'xhigh'.",
attempted,
});
expect(next).toBe("high");
it("skips empty or whitespace-only content", () => {
const files = [makeFile({ content: " \n " })];
expect(buildBootstrapContextFiles(files)).toEqual([]);
});
it("returns undefined when no supported values are found", () => {
const attempted = new Set<ThinkLevel>(["low"]);
const next = pickFallbackThinkingLevel({
message: "Request failed.",
attempted,
});
expect(next).toBeUndefined();
it("truncates large bootstrap content", () => {
const head = `HEAD-${"a".repeat(6000)}`;
const tail = `${"b".repeat(3000)}-TAIL`;
const long = `${head}${tail}`;
const files = [makeFile({ content: long })];
const [result] = buildBootstrapContextFiles(files);
expect(result?.content).toContain(
"[...truncated, read AGENTS.md for full content...]",
);
expect(result?.content.length).toBeLessThan(long.length);
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
expect(result?.content.endsWith(long.slice(-120))).toBe(true);
});
});
+36 -6
View File
@@ -16,6 +16,25 @@ import type { WorkspaceBootstrapFile } from "./workspace.js";
export type EmbeddedContextFile = { path: string; content: string };
const MAX_BOOTSTRAP_CHARS = 4000;
const BOOTSTRAP_HEAD_CHARS = 2800;
const BOOTSTRAP_TAIL_CHARS = 800;
function trimBootstrapContent(content: string, fileName: string): string {
const trimmed = content.trimEnd();
if (trimmed.length <= MAX_BOOTSTRAP_CHARS) return trimmed;
const head = trimmed.slice(0, BOOTSTRAP_HEAD_CHARS);
const tail = trimmed.slice(-BOOTSTRAP_TAIL_CHARS);
return [
head,
"",
`[...truncated, read ${fileName} for full content...]`,
"",
tail,
].join("\n");
}
export async function ensureSessionHeader(params: {
sessionFile: string;
sessionId: string;
@@ -88,12 +107,23 @@ export async function sanitizeSessionMessagesImages(
export function buildBootstrapContextFiles(
files: WorkspaceBootstrapFile[],
): EmbeddedContextFile[] {
return files.map((file) => ({
path: file.name,
content: file.missing
? `[MISSING] Expected at: ${file.path}`
: (file.content ?? ""),
}));
const result: EmbeddedContextFile[] = [];
for (const file of files) {
if (file.missing) {
result.push({
path: file.name,
content: `[MISSING] Expected at: ${file.path}`,
});
continue;
}
const trimmed = trimBootstrapContent(file.content ?? "", file.name);
if (!trimmed) continue;
result.push({
path: file.name,
content: trimmed,
});
}
return result;
}
export function formatAssistantErrorText(
+4
View File
@@ -17,6 +17,8 @@ describe("buildEmbeddedSandboxInfo", () => {
enabled: true,
sessionKey: "session:test",
workspaceDir: "/tmp/clawdbot-sandbox",
agentWorkspaceDir: "/tmp/clawdbot-workspace",
workspaceAccess: "none",
containerName: "clawdbot-sbx-test",
containerWorkdir: "/workspace",
docker: {
@@ -44,6 +46,8 @@ describe("buildEmbeddedSandboxInfo", () => {
expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({
enabled: true,
workspaceDir: "/tmp/clawdbot-sandbox",
workspaceAccess: "none",
agentWorkspaceMount: undefined,
browserControlUrl: "http://localhost:9222",
browserNoVncUrl: "http://localhost:6080",
});
+61 -32
View File
@@ -177,6 +177,8 @@ const isAbortError = (err: unknown): boolean => {
type EmbeddedSandboxInfo = {
enabled: boolean;
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
};
@@ -211,6 +213,7 @@ function formatUserTime(date: Date, timeZone: string): string | undefined {
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
weekday: "long",
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -222,10 +225,17 @@ function formatUserTime(date: Date, timeZone: string): string | undefined {
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (!map.year || !map.month || !map.day || !map.hour || !map.minute) {
if (
!map.weekday ||
!map.year ||
!map.month ||
!map.day ||
!map.hour ||
!map.minute
) {
return undefined;
}
return `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
} catch {
return undefined;
}
@@ -249,6 +259,9 @@ export function buildEmbeddedSandboxInfo(
return {
enabled: true,
workspaceDir: sandbox.workspaceDir,
workspaceAccess: sandbox.workspaceAccess,
agentWorkspaceMount:
sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
browserControlUrl: sandbox.browser?.controlUrl,
browserNoVncUrl: sandbox.browser?.noVncUrl,
};
@@ -466,32 +479,38 @@ export async function compactEmbeddedPiSession(params: {
}
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
const effectiveWorkspace = sandbox?.enabled
? sandbox.workspaceAccess === "rw"
? resolvedWorkspace
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: resolvedWorkspace,
cwd: effectiveWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace);
process.chdir(effectiveWorkspace);
try {
const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(resolvedWorkspace)
? loadWorkspaceSkillEntries(effectiveWorkspace)
: [];
const skillsSnapshot =
params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
buildWorkspaceSkillSnapshot(effectiveWorkspace, {
config: params.config,
entries: skillEntries,
});
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
@@ -503,7 +522,7 @@ export async function compactEmbeddedPiSession(params: {
});
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
await loadWorkspaceBootstrapFiles(effectiveWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdbotCodingTools({
@@ -533,7 +552,7 @@ export async function compactEmbeddedPiSession(params: {
const userTime = formatUserTime(new Date(), userTimezone);
const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace,
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
@@ -550,13 +569,13 @@ export async function compactEmbeddedPiSession(params: {
}),
contextFiles,
skills: promptSkills,
cwd: resolvedWorkspace,
cwd: effectiveWorkspace,
tools,
});
const sessionManager = SessionManager.open(params.sessionFile);
const settingsManager = SettingsManager.create(
resolvedWorkspace,
effectiveWorkspace,
agentDir,
);
@@ -648,6 +667,10 @@ export async function runEmbeddedPiAgent(params: {
}) => void | Promise<void>;
blockReplyBreak?: "text_end" | "message_end";
blockReplyChunking?: BlockReplyChunking;
onReasoningStream?: (payload: {
text?: string;
mediaUrls?: string[];
}) => void | Promise<void>;
onToolResult?: (payload: {
text?: string;
mediaUrls?: string[];
@@ -760,33 +783,38 @@ export async function runEmbeddedPiAgent(params: {
);
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
const effectiveWorkspace = sandbox?.enabled
? sandbox.workspaceAccess === "rw"
? resolvedWorkspace
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: resolvedWorkspace,
cwd: effectiveWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace);
process.chdir(effectiveWorkspace);
try {
const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(resolvedWorkspace)
? loadWorkspaceSkillEntries(effectiveWorkspace)
: [];
const skillsSnapshot =
params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
buildWorkspaceSkillSnapshot(effectiveWorkspace, {
config: params.config,
entries: skillEntries,
});
const sandboxSessionKey =
params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
@@ -798,7 +826,7 @@ export async function runEmbeddedPiAgent(params: {
});
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
await loadWorkspaceBootstrapFiles(effectiveWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(
skillsSnapshot,
@@ -833,7 +861,7 @@ export async function runEmbeddedPiAgent(params: {
const userTime = formatUserTime(new Date(), userTimezone);
const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace,
workspaceDir: effectiveWorkspace,
defaultThinkLevel: thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
@@ -850,13 +878,13 @@ export async function runEmbeddedPiAgent(params: {
}),
contextFiles,
skills: promptSkills,
cwd: resolvedWorkspace,
cwd: effectiveWorkspace,
tools,
});
const sessionManager = SessionManager.open(params.sessionFile);
const settingsManager = SettingsManager.create(
resolvedWorkspace,
effectiveWorkspace,
agentDir,
);
@@ -901,9 +929,10 @@ export async function runEmbeddedPiAgent(params: {
session,
runId: params.runId,
verboseLevel: params.verboseLevel,
includeReasoning: params.reasoningLevel === "on",
reasoningMode: params.reasoningLevel ?? "off",
shouldEmitToolResult: params.shouldEmitToolResult,
onToolResult: params.onToolResult,
onReasoningStream: params.onReasoningStream,
onBlockReply: params.onBlockReply,
blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking,
+35 -1
View File
@@ -147,7 +147,7 @@ describe("subscribeEmbeddedPiSession", () => {
runId: "run",
onBlockReply,
blockReplyBreak: "message_end",
includeReasoning: true,
reasoningMode: "on",
});
const assistantMessage = {
@@ -400,6 +400,40 @@ describe("subscribeEmbeddedPiSession", () => {
expect(subscription.assistantTexts).toEqual(["Hello world"]);
});
it("populates assistantTexts for non-streaming models with chunking enabled", () => {
// Non-streaming models (e.g. zai/glm-4.7): no text_delta events; message_end
// must still populate assistantTexts so providers can deliver a final reply.
let handler: SessionEventHandler | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run",
blockReplyChunking: { minChars: 50, maxChars: 200 }, // Chunking enabled
});
// Simulate non-streaming model: only message_start and message_end, no text_delta
handler?.({ type: "message_start", message: { role: "assistant" } });
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Response from non-streaming model" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
expect(subscription.assistantTexts).toEqual([
"Response from non-streaming model",
]);
});
it("does not append when text_end content is a prefix of deltas", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
+71 -11
View File
@@ -1,7 +1,7 @@
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel } from "../auto-reply/thinking.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging.js";
@@ -18,6 +18,8 @@ import {
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
const THINKING_OPEN_GLOBAL_RE = /<\s*think(?:ing)?\s*>/gi;
const THINKING_CLOSE_GLOBAL_RE = /<\s*\/\s*think(?:ing)?\s*>/gi;
const TOOL_RESULT_MAX_CHARS = 8000;
const log = createSubsystemLogger("agent/embedded");
@@ -87,12 +89,16 @@ export function subscribeEmbeddedPiSession(params: {
session: AgentSession;
runId: string;
verboseLevel?: "off" | "on";
includeReasoning?: boolean;
reasoningMode?: ReasoningLevel;
shouldEmitToolResult?: () => boolean;
onToolResult?: (payload: {
text?: string;
mediaUrls?: string[];
}) => void | Promise<void>;
onReasoningStream?: (payload: {
text?: string;
mediaUrls?: string[];
}) => void | Promise<void>;
onBlockReply?: (payload: {
text?: string;
mediaUrls?: string[];
@@ -114,9 +120,15 @@ export function subscribeEmbeddedPiSession(params: {
const toolMetaById = new Map<string, string | undefined>();
const toolSummaryById = new Set<string>();
const blockReplyBreak = params.blockReplyBreak ?? "text_end";
const reasoningMode = params.reasoningMode ?? "off";
const includeReasoning = reasoningMode === "on";
const streamReasoning =
reasoningMode === "stream" &&
typeof params.onReasoningStream === "function";
let deltaBuffer = "";
let blockBuffer = "";
let lastStreamedAssistant: string | undefined;
let lastStreamedReasoning: string | undefined;
let lastBlockReplyText: string | undefined;
let assistantTextBaseline = 0;
let compactionInFlight = false;
@@ -238,6 +250,39 @@ export function subscribeEmbeddedPiSession(params: {
return result.trim();
};
const extractThinkingFromStream = (text: string): string => {
if (!text) return "";
const closed = extractThinkingFromText(text);
if (closed) return closed;
const openMatches = [...text.matchAll(THINKING_OPEN_GLOBAL_RE)];
if (openMatches.length === 0) return "";
const closeMatches = [...text.matchAll(THINKING_CLOSE_GLOBAL_RE)];
const lastOpen = openMatches[openMatches.length - 1];
const lastClose = closeMatches[closeMatches.length - 1];
if (lastClose && (lastClose.index ?? -1) > (lastOpen.index ?? -1)) {
return closed;
}
const start = (lastOpen.index ?? 0) + lastOpen[0].length;
return text.slice(start).trim();
};
const formatReasoningDraft = (text: string): string => {
const trimmed = text.trim();
if (!trimmed) return "";
return `Reasoning:\n${trimmed}`;
};
const emitReasoningStream = (text: string) => {
if (!streamReasoning || !params.onReasoningStream) return;
const formatted = formatReasoningDraft(text);
if (!formatted) return;
if (formatted === lastStreamedReasoning) return;
lastStreamedReasoning = formatted;
void params.onReasoningStream({
text: formatted,
});
};
const resetForCompactionRetry = () => {
assistantTexts.length = 0;
toolMetas.length = 0;
@@ -247,6 +292,7 @@ export function subscribeEmbeddedPiSession(params: {
blockBuffer = "";
blockChunker?.reset();
lastStreamedAssistant = undefined;
lastStreamedReasoning = undefined;
lastBlockReplyText = undefined;
assistantTextBaseline = 0;
};
@@ -266,6 +312,7 @@ export function subscribeEmbeddedPiSession(params: {
blockChunker?.reset();
lastStreamedAssistant = undefined;
lastBlockReplyText = undefined;
lastStreamedReasoning = undefined;
lastReasoningSent = undefined;
assistantTextBaseline = assistantTexts.length;
}
@@ -436,6 +483,11 @@ export function subscribeEmbeddedPiSession(params: {
}
}
if (streamReasoning) {
// Handle partial <think> tags: stream whatever reasoning is visible so far.
emitReasoningStream(extractThinkingFromStream(deltaBuffer));
}
const cleaned = params.enforceFinalTag
? stripThinkingSegments(stripUnpairedThinkingTags(deltaBuffer))
: stripThinkingSegments(deltaBuffer);
@@ -502,22 +554,26 @@ export function subscribeEmbeddedPiSession(params: {
params.enforceFinalTag && cleaned
? (extractFinalText(cleaned)?.trim() ?? cleaned)
: cleaned;
const rawThinking = params.includeReasoning
? extractAssistantThinking(assistantMessage) ||
extractThinkingFromText(rawText)
: "";
const rawThinking =
includeReasoning || streamReasoning
? extractAssistantThinking(assistantMessage) ||
extractThinkingFromText(rawText)
: "";
const formattedReasoning = rawThinking
? formatReasoningMarkdown(rawThinking)
: "";
const text =
baseText && formattedReasoning
const text = includeReasoning
? baseText && formattedReasoning
? `${formattedReasoning}\n\n${baseText}`
: formattedReasoning || baseText;
: formattedReasoning || baseText
: baseText;
const addedDuringMessage =
assistantTexts.length > assistantTextBaseline;
const chunkingEnabled = Boolean(blockChunking);
if (!chunkingEnabled && !addedDuringMessage && text) {
const chunkerHasBuffered = blockChunker?.hasBuffered() ?? false;
// Non-streaming models (no text_delta): ensure assistantTexts gets the
// final text when the chunker has nothing buffered to drain.
if (!addedDuringMessage && !chunkerHasBuffered && text) {
const last = assistantTexts.at(-1);
if (!last || last !== text) assistantTexts.push(text);
}
@@ -548,6 +604,7 @@ export function subscribeEmbeddedPiSession(params: {
}
const onBlockReply = params.onBlockReply;
const shouldEmitReasoningBlock =
includeReasoning &&
Boolean(formattedReasoning) &&
Boolean(onBlockReply) &&
formattedReasoning !== lastReasoningSent &&
@@ -556,6 +613,9 @@ export function subscribeEmbeddedPiSession(params: {
lastReasoningSent = formattedReasoning;
void onBlockReply({ text: formattedReasoning });
}
if (streamReasoning && rawThinking) {
emitReasoningStream(rawThinking);
}
deltaBuffer = "";
blockBuffer = "";
blockChunker?.reset();
+33
View File
@@ -240,6 +240,8 @@ describe("createClawdbotCodingTools", () => {
enabled: true,
sessionKey: "sandbox:test",
workspaceDir: path.join(os.tmpdir(), "clawdbot-sandbox"),
agentWorkspaceDir: path.join(os.tmpdir(), "clawdbot-workspace"),
workspaceAccess: "none",
containerName: "clawdbot-sbx-test",
containerWorkdir: "/workspace",
docker: {
@@ -264,6 +266,37 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
it("hard-disables write/edit when sandbox workspaceAccess is ro", () => {
const sandbox = {
enabled: true,
sessionKey: "sandbox:test",
workspaceDir: path.join(os.tmpdir(), "clawdbot-sandbox"),
agentWorkspaceDir: path.join(os.tmpdir(), "clawdbot-workspace"),
workspaceAccess: "ro",
containerName: "clawdbot-sbx-test",
containerWorkdir: "/workspace",
docker: {
image: "clawdbot-sandbox:bookworm-slim",
containerPrefix: "clawdbot-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: [],
network: "none",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
tools: {
allow: ["read", "write", "edit"],
deny: [],
},
};
const tools = createClawdbotCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "read")).toBe(true);
expect(tools.some((tool) => tool.name === "write")).toBe(false);
expect(tools.some((tool) => tool.name === "edit")).toBe(false);
});
it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } },
+7 -4
View File
@@ -526,6 +526,7 @@ export function createClawdbotCodingTools(options?: {
const bashToolName = "bash";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const sandboxRoot = sandbox?.workspaceDir;
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => {
if (tool.name === readTool.name) {
return sandboxRoot
@@ -555,10 +556,12 @@ export function createClawdbotCodingTools(options?: {
const tools: AnyAgentTool[] = [
...base,
...(sandboxRoot
? [
createSandboxedEditTool(sandboxRoot),
createSandboxedWriteTool(sandboxRoot),
]
? allowWorkspaceWrites
? [
createSandboxedEditTool(sandboxRoot),
createSandboxedWriteTool(sandboxRoot),
]
: []
: []),
bashTool as unknown as AnyAgentTool,
processTool as unknown as AnyAgentTool,
+83 -18
View File
@@ -23,6 +23,7 @@ import {
DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
@@ -35,6 +36,8 @@ export type SandboxToolPolicy = {
deny?: string[];
};
export type SandboxWorkspaceAccess = "none" | "ro" | "rw";
export type SandboxBrowserConfig = {
enabled: boolean;
image: string;
@@ -78,6 +81,7 @@ export type SandboxScope = "session" | "agent" | "shared";
export type SandboxConfig = {
mode: "off" | "non-main" | "all";
scope: SandboxScope;
workspaceAccess: SandboxWorkspaceAccess;
workspaceRoot: string;
docker: SandboxDockerConfig;
browser: SandboxBrowserConfig;
@@ -95,6 +99,8 @@ export type SandboxContext = {
enabled: boolean;
sessionKey: string;
workspaceDir: string;
agentWorkspaceDir: string;
workspaceAccess: SandboxWorkspaceAccess;
containerName: string;
containerWorkdir: string;
docker: SandboxDockerConfig;
@@ -144,6 +150,7 @@ const DEFAULT_SANDBOX_BROWSER_PREFIX = "clawdbot-sbx-browser-";
const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080;
const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent";
const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox");
const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json");
@@ -227,6 +234,7 @@ function defaultSandboxConfig(cfg?: ClawdbotConfig): SandboxConfig {
scope: agent?.scope,
perSession: agent?.perSession,
}),
workspaceAccess: agent?.workspaceAccess ?? "none",
workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT,
docker: {
image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE,
@@ -474,6 +482,7 @@ async function ensureSandboxWorkspace(
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
];
for (const name of files) {
const src = path.join(seed, name);
@@ -582,6 +591,8 @@ async function createSandboxContainer(params: {
name: string;
cfg: SandboxDockerConfig;
workspaceDir: string;
workspaceAccess: SandboxWorkspaceAccess;
agentWorkspaceDir: string;
scopeKey: string;
}) {
const { name, cfg, workspaceDir, scopeKey } = params;
@@ -593,7 +604,21 @@ async function createSandboxContainer(params: {
scopeKey,
});
args.push("--workdir", cfg.workdir);
args.push("-v", `${workspaceDir}:${cfg.workdir}`);
const mainMountSuffix =
params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir
? ":ro"
: "";
args.push("-v", `${workspaceDir}:${cfg.workdir}${mainMountSuffix}`);
if (
params.workspaceAccess !== "none" &&
workspaceDir !== params.agentWorkspaceDir
) {
const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : "";
args.push(
"-v",
`${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
);
}
args.push(cfg.image, "sleep", "infinity");
await execDocker(args);
@@ -607,6 +632,7 @@ async function createSandboxContainer(params: {
async function ensureSandboxContainer(params: {
sessionKey: string;
workspaceDir: string;
agentWorkspaceDir: string;
cfg: SandboxConfig;
}) {
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey);
@@ -620,6 +646,8 @@ async function ensureSandboxContainer(params: {
name: containerName,
cfg: params.cfg.docker,
workspaceDir: params.workspaceDir,
workspaceAccess: params.cfg.workspaceAccess,
agentWorkspaceDir: params.agentWorkspaceDir,
scopeKey,
});
} else if (!state.running) {
@@ -675,6 +703,7 @@ function buildSandboxBrowserResolvedConfig(params: {
async function ensureSandboxBrowser(params: {
scopeKey: string;
workspaceDir: string;
agentWorkspaceDir: string;
cfg: SandboxConfig;
}): Promise<SandboxBrowserContext | null> {
if (!params.cfg.browser.enabled) return null;
@@ -695,7 +724,25 @@ async function ensureSandboxBrowser(params: {
scopeKey: params.scopeKey,
labels: { "clawdbot.sandboxBrowser": "1" },
});
args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}`);
const mainMountSuffix =
params.cfg.workspaceAccess === "ro" &&
params.workspaceDir === params.agentWorkspaceDir
? ":ro"
: "";
args.push(
"-v",
`${params.workspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`,
);
if (
params.cfg.workspaceAccess !== "none" &&
params.workspaceDir !== params.agentWorkspaceDir
) {
const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : "";
args.push(
"-v",
`${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
);
}
args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`);
if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) {
args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`);
@@ -883,29 +930,38 @@ export async function resolveSandboxContext(params: {
await maybePruneSandboxes(cfg);
const agentWorkspaceDir = resolveUserPath(
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR,
);
const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey);
const workspaceDir =
const sandboxWorkspaceDir =
cfg.scope === "shared"
? workspaceRoot
: resolveSandboxWorkspaceDir(workspaceRoot, scopeKey);
const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
await ensureSandboxWorkspace(
workspaceDir,
seedWorkspace,
params.config?.agent?.skipBootstrap,
);
const workspaceDir =
cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir;
if (workspaceDir === sandboxWorkspaceDir) {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
);
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
const containerName = await ensureSandboxContainer({
sessionKey: rawSessionKey,
workspaceDir,
agentWorkspaceDir,
cfg,
});
const browser = await ensureSandboxBrowser({
scopeKey,
workspaceDir,
agentWorkspaceDir,
cfg,
});
@@ -913,6 +969,8 @@ export async function resolveSandboxContext(params: {
enabled: true,
sessionKey: rawSessionKey,
workspaceDir,
agentWorkspaceDir,
workspaceAccess: cfg.workspaceAccess,
containerName,
containerWorkdir: cfg.docker.workdir,
docker: cfg.docker,
@@ -932,19 +990,26 @@ export async function ensureSandboxWorkspaceForSession(params: {
const mainKey = params.config?.session?.mainKey?.trim() || "main";
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
const agentWorkspaceDir = resolveUserPath(
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR,
);
const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey);
const workspaceDir =
const sandboxWorkspaceDir =
cfg.scope === "shared"
? workspaceRoot
: resolveSandboxWorkspaceDir(workspaceRoot, scopeKey);
const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
await ensureSandboxWorkspace(
workspaceDir,
seedWorkspace,
params.config?.agent?.skipBootstrap,
);
const workspaceDir =
cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir;
if (workspaceDir === sandboxWorkspaceDir) {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
);
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
return {
workspaceDir,
+27
View File
@@ -165,6 +165,33 @@ describe("buildWorkspaceSkillsPrompt", () => {
}
});
it("applies skill filters, including empty lists", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
await writeSkill({
dir: path.join(workspaceDir, "skills", "alpha"),
name: "alpha",
description: "Alpha skill",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "beta"),
name: "beta",
description: "Beta skill",
});
const filteredPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
skillFilter: ["alpha"],
});
expect(filteredPrompt).toContain("alpha");
expect(filteredPrompt).not.toContain("beta");
const emptyPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
skillFilter: [],
});
expect(emptyPrompt).toBe("");
});
it("prefers workspace skills over managed skills", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const managedDir = path.join(workspaceDir, ".managed");
+34 -3
View File
@@ -382,8 +382,27 @@ function shouldIncludeSkill(params: {
function filterSkillEntries(
entries: SkillEntry[],
config?: ClawdbotConfig,
skillFilter?: string[],
): SkillEntry[] {
return entries.filter((entry) => shouldIncludeSkill({ entry, config }));
let filtered = entries.filter((entry) =>
shouldIncludeSkill({ entry, config }),
);
// If skillFilter is provided, only include skills in the filter list.
if (skillFilter !== undefined) {
const normalized = skillFilter
.map((entry) => String(entry).trim())
.filter(Boolean);
const label = normalized.length > 0 ? normalized.join(", ") : "(none)";
console.log(`[skills] Applying skill filter: ${label}`);
filtered =
normalized.length > 0
? filtered.filter((entry) => normalized.includes(entry.skill.name))
: [];
console.log(
`[skills] After filter: ${filtered.map((entry) => entry.skill.name).join(", ")}`,
);
}
return filtered;
}
export function applySkillEnvOverrides(params: {
@@ -548,10 +567,16 @@ export function buildWorkspaceSkillSnapshot(
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
/** If provided, only include skills with these names */
skillFilter?: string[];
},
): SkillSnapshot {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(skillEntries, opts?.config);
const eligible = filterSkillEntries(
skillEntries,
opts?.config,
opts?.skillFilter,
);
const resolvedSkills = eligible.map((entry) => entry.skill);
return {
prompt: formatSkillsForPrompt(resolvedSkills),
@@ -570,10 +595,16 @@ export function buildWorkspaceSkillsPrompt(
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
/** If provided, only include skills with these names */
skillFilter?: string[];
},
): string {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(skillEntries, opts?.config);
const eligible = filterSkillEntries(
skillEntries,
opts?.config,
opts?.skillFilter,
);
return formatSkillsForPrompt(eligible.map((entry) => entry.skill));
}
+3 -4
View File
@@ -34,7 +34,7 @@ describe("buildAgentSystemPromptAppend", () => {
expect(prompt).toContain("<final>...</final>");
});
it("lists available and unavailable tools when provided", () => {
it("lists available tools when provided", () => {
const prompt = buildAgentSystemPromptAppend({
workspaceDir: "/tmp/clawd",
toolNames: ["bash", "sessions_list", "sessions_history", "sessions_send"],
@@ -44,19 +44,18 @@ describe("buildAgentSystemPromptAppend", () => {
expect(prompt).toContain("sessions_list");
expect(prompt).toContain("sessions_history");
expect(prompt).toContain("sessions_send");
expect(prompt).toContain("Unavailable tools (do not call):");
});
it("includes user time when provided", () => {
const prompt = buildAgentSystemPromptAppend({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
userTime: "2026-01-05 15:26",
userTime: "Monday 2026-01-05 15:26",
});
expect(prompt).toContain("## Time");
expect(prompt).toContain("User timezone: America/Chicago");
expect(prompt).toContain("Current user time: 2026-01-05 15:26");
expect(prompt).toContain("Current user time: Monday 2026-01-05 15:26");
});
it("includes model alias guidance when aliases are provided", () => {
+9 -4
View File
@@ -21,6 +21,8 @@ export function buildAgentSystemPromptAppend(params: {
sandboxInfo?: {
enabled: boolean;
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
};
@@ -83,7 +85,6 @@ export function buildAgentSystemPromptAppend(params: {
new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))),
);
const enabledTools = toolOrder.filter((tool) => availableTools.has(tool));
const disabledTools = toolOrder.filter((tool) => !availableTools.has(tool));
const toolLines = enabledTools.map((tool) => {
const summary = toolSummaries[tool];
return summary ? `- ${tool}: ${summary}` : `- ${tool}`;
@@ -158,9 +159,6 @@ export function buildAgentSystemPromptAppend(params: {
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
].join("\n"),
disabledTools.length > 0
? `Unavailable tools (do not call): ${disabledTools.join(", ")}`
: "",
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
"",
params.modelAliasLines && params.modelAliasLines.length > 0
@@ -185,6 +183,13 @@ export function buildAgentSystemPromptAppend(params: {
params.sandboxInfo.workspaceDir
? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}`
: "",
params.sandboxInfo.workspaceAccess
? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${
params.sandboxInfo.agentWorkspaceMount
? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})`
: ""
}`
: "",
params.sandboxInfo.browserControlUrl
? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
: "",
+6
View File
@@ -106,6 +106,12 @@ describe("directive parsing", () => {
expect(res.reasoningLevel).toBe("on");
});
it("matches reasoning stream directive", () => {
const res = extractReasoningDirective("/reasoning stream please");
expect(res.hasDirective).toBe(true);
expect(res.reasoningLevel).toBe("stream");
});
it("matches elevated with leading space", () => {
const res = extractElevatedDirective(" please /elevated on now");
expect(res.hasDirective).toBe(true);
+2 -2
View File
@@ -175,7 +175,7 @@ describe("trigger handling", () => {
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Status");
expect(text).toContain("ClawdBot");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -1006,7 +1006,7 @@ describe("trigger handling", () => {
describe("group intro prompts", () => {
const groupParticipationNote =
"Be a good group participant: lurk and follow the conversation, but only chime in when you have something genuinely helpful or relevant to add. Don't feel obligated to respond to every message — quality over quantity. Even when lurking silently, you can use emoji reactions to acknowledge messages, show support, or react to humor — reactions are always appreciated and don't clutter the chat.";
"Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available.";
it("labels Discord groups using the surface metadata", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
+10 -3
View File
@@ -199,10 +199,12 @@ export async function getReplyFromConfig(
configOverride?: ClawdbotConfig,
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
const cfg = configOverride ?? loadConfig();
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const agentCfg = cfg.agent;
const sessionCfg = cfg.session;
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
cfg,
agentId,
});
let provider = defaultProvider;
let model = defaultModel;
@@ -221,7 +223,6 @@ export async function getReplyFromConfig(
}
}
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const workspaceDirRaw =
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({
@@ -400,7 +401,8 @@ export async function getReplyFromConfig(
agentCfg?.blockStreamingBreak === "message_end"
? "message_end"
: "text_end";
const blockStreamingEnabled = resolvedBlockStreaming === "on";
const blockStreamingEnabled =
resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true;
const blockReplyChunking = blockStreamingEnabled
? resolveBlockStreamingChunking(cfg, sessionCtx.Provider)
: undefined;
@@ -591,6 +593,10 @@ export async function getReplyFromConfig(
silentToken: SILENT_REPLY_TOKEN,
})
: "";
const groupSystemPrompt = sessionCtx.GroupSystemPrompt?.trim() ?? "";
const extraSystemPrompt = [groupIntro, groupSystemPrompt]
.filter(Boolean)
.join("\n\n");
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const rawBodyTrimmed = (ctx.Body ?? "").trim();
const baseBodyTrimmedRaw = baseBody.trim();
@@ -649,6 +655,7 @@ export async function getReplyFromConfig(
isFirstTurnInSession,
workspaceDir,
cfg,
skillFilter: opts?.skillFilter,
});
sessionEntry = skillResult.sessionEntry ?? sessionEntry;
systemSent = skillResult.systemSent;
@@ -757,7 +764,7 @@ export async function getReplyFromConfig(
blockReplyBreak: resolvedBlockStreamingBreak,
ownerNumbers:
command.ownerList.length > 0 ? command.ownerList : undefined,
extraSystemPrompt: groupIntro || undefined,
extraSystemPrompt: extraSystemPrompt || undefined,
...(provider === "ollama" ? { enforceFinalTag: true } : {}),
},
};
+36 -24
View File
@@ -197,6 +197,9 @@ export async function runReplyAgent(params: {
let fallbackProvider = followupRun.run.provider;
let fallbackModel = followupRun.run.model;
try {
const allowPartialStream = !(
followupRun.run.reasoningLevel === "stream" && opts?.onReasoningStream
);
const fallbackResult = await runWithModelFallback({
cfg: followupRun.run.config,
provider: followupRun.run.provider,
@@ -227,32 +230,41 @@ export async function runReplyAgent(params: {
runId,
blockReplyBreak: resolvedBlockStreamingBreak,
blockReplyChunking,
onPartialReply: opts?.onPartialReply
? async (payload) => {
let text = payload.text;
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
onPartialReply:
opts?.onPartialReply && allowPartialStream
? async (payload) => {
let text = payload.text;
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose(
"Stripped stray HEARTBEAT_OK token from reply",
);
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
if (!isHeartbeat) {
await typing.startTypingOnText(text);
}
await opts.onPartialReply?.({
text,
mediaUrls: payload.mediaUrls,
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose(
"Stripped stray HEARTBEAT_OK token from reply",
);
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
if (!isHeartbeat) {
await typing.startTypingOnText(text);
}
await opts.onPartialReply?.({
text,
: undefined,
onReasoningStream: opts?.onReasoningStream
? async (payload) => {
await opts.onReasoningStream?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
}
+36 -10
View File
@@ -20,13 +20,15 @@ import {
saveSessionStore,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import {
formatUsageSummaryLine,
loadProviderUsageSummary,
} from "../../infra/provider-usage.js";
import { triggerClawdbotRestart } from "../../infra/restart.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { normalizeE164 } from "../../utils.js";
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import {
@@ -51,6 +53,7 @@ import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js";
import type { InlineDirectives } from "./directive-handling.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
import { incrementCompactionCount } from "./session-updates.js";
function resolveSessionEntryForKey(
@@ -384,9 +387,27 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
const webLinked = await webAuthExists();
const webAuthAgeMs = getWebAuthAgeMs();
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
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,
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())
@@ -403,11 +424,9 @@ export async function handleCommands(params: {
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
},
workspaceDir,
sessionEntry,
sessionKey,
sessionScope,
storePath,
groupActivation,
resolvedThink:
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
@@ -415,9 +434,16 @@ export async function handleCommands(params: {
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider, cfg),
webLinked,
webAuthAgeMs,
heartbeatSeconds,
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,
depth: queueDepth,
debounceMs: queueSettings.debounceMs,
cap: queueSettings.cap,
dropPolicy: queueSettings.dropPolicy,
showDetails: queueOverrides,
},
includeTranscriptUsage: false,
});
return { shouldContinue: false, reply: { text: statusText } };
}
+29 -5
View File
@@ -1,4 +1,5 @@
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import {
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
@@ -384,7 +385,7 @@ export async function handleDirectiveOnly(params: {
}
if (directives.hasReasoningDirective && !directives.reasoningLevel) {
return {
text: `Unrecognized reasoning level "${directives.rawReasoningLevel ?? ""}". Valid levels: on, off.`,
text: `Unrecognized reasoning level "${directives.rawReasoningLevel ?? ""}". Valid levels: on, off, stream.`,
};
}
if (directives.hasElevatedDirective && !directives.elevatedLevel) {
@@ -562,7 +563,9 @@ export async function handleDirectiveOnly(params: {
parts.push(
directives.reasoningLevel === "off"
? `${SYSTEM_MARK} Reasoning visibility disabled.`
: `${SYSTEM_MARK} Reasoning visibility enabled.`,
: directives.reasoningLevel === "stream"
? `${SYSTEM_MARK} Reasoning stream enabled (Telegram only).`
: `${SYSTEM_MARK} Reasoning visibility enabled.`,
);
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
@@ -768,20 +771,41 @@ export async function persistInlineDirectives(params: {
};
}
export function resolveDefaultModel(params: { cfg: ClawdbotConfig }): {
export function resolveDefaultModel(params: {
cfg: ClawdbotConfig;
agentId?: string;
}): {
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
} {
const agentModelOverride = params.agentId
? resolveAgentConfig(params.cfg, params.agentId)?.model?.trim()
: undefined;
const cfg =
agentModelOverride && agentModelOverride.length > 0
? {
...params.cfg,
agent: {
...params.cfg.agent,
model: {
...(typeof params.cfg.agent?.model === "object"
? params.cfg.agent.model
: undefined),
primary: agentModelOverride,
},
},
}
: params.cfg;
const mainModel = resolveConfiguredModelRef({
cfg: params.cfg,
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const defaultProvider = mainModel.provider;
const defaultModel = mainModel.model;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
cfg,
defaultProvider,
});
return { defaultProvider, defaultModel, aliasIndex };
+63 -7
View File
@@ -26,6 +26,52 @@ function normalizeSlackSlug(raw?: string | null) {
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
}
function parseTelegramGroupId(value?: string | null) {
const raw = value?.trim() ?? "";
if (!raw) return { chatId: undefined, topicId: undefined };
const parts = raw.split(":").filter(Boolean);
if (
parts.length >= 3 &&
parts[1] === "topic" &&
/^-?\d+$/.test(parts[0]) &&
/^\d+$/.test(parts[2])
) {
return { chatId: parts[0], topicId: parts[2] };
}
if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
return { chatId: parts[0], topicId: parts[1] };
}
return { chatId: raw, topicId: undefined };
}
function resolveTelegramRequireMention(params: {
cfg: ClawdbotConfig;
chatId?: string;
topicId?: string;
}): boolean | undefined {
const { cfg, chatId, topicId } = params;
if (!chatId) return undefined;
const groupConfig = cfg.telegram?.groups?.[chatId];
const groupDefault = cfg.telegram?.groups?.["*"];
const topicConfig =
topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined;
const defaultTopicConfig =
topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined;
if (typeof topicConfig?.requireMention === "boolean") {
return topicConfig.requireMention;
}
if (typeof defaultTopicConfig?.requireMention === "boolean") {
return defaultTopicConfig.requireMention;
}
if (typeof groupConfig?.requireMention === "boolean") {
return groupConfig.requireMention;
}
if (typeof groupDefault?.requireMention === "boolean") {
return groupDefault.requireMention;
}
return undefined;
}
function resolveDiscordGuildEntry(
guilds: NonNullable<ClawdbotConfig["discord"]>["guilds"],
groupSpace?: string,
@@ -55,11 +101,21 @@ export function resolveGroupRequireMention(params: {
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
if (
provider === "telegram" ||
provider === "whatsapp" ||
provider === "imessage"
) {
if (provider === "telegram") {
const { chatId, topicId } = parseTelegramGroupId(groupId);
const requireMention = resolveTelegramRequireMention({
cfg,
chatId,
topicId,
});
if (typeof requireMention === "boolean") return requireMention;
return resolveProviderGroupRequireMention({
cfg,
provider,
groupId: chatId ?? groupId,
});
}
if (provider === "whatsapp" || provider === "imessage") {
return resolveProviderGroupRequireMention({
cfg,
provider,
@@ -161,10 +217,10 @@ export function buildGroupIntro(params: {
: undefined;
const cautionLine =
activation === "always"
? "Be extremely selective: reply only when you are directly addressed, asked a question, or can add clear value. Otherwise stay silent."
? "Be extremely selective: reply only when directly addressed or clearly helpful. Otherwise stay silent."
: undefined;
const lurkLine =
"Be a good group participant: lurk and follow the conversation, but only chime in when you have something genuinely helpful or relevant to add. Don't feel obligated to respond to every message — quality over quantity. Even when lurking silently, you can use emoji reactions to acknowledge messages, show support, or react to humor — reactions are always appreciated and don't clutter the chat.";
"Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available.";
return [
subjectLine,
membersLine,
+8
View File
@@ -586,3 +586,11 @@ export function resolveQueueSettings(params: {
dropPolicy: dropRaw,
};
}
export function getFollowupQueueDepth(key: string): number {
const cleaned = key.trim();
if (!cleaned) return 0;
const queue = FOLLOWUP_QUEUES.get(cleaned);
if (!queue) return 0;
return queue.items.length;
}
+11 -2
View File
@@ -49,6 +49,8 @@ export async function ensureSkillSnapshot(params: {
isFirstTurnInSession: boolean;
workspaceDir: string;
cfg: ClawdbotConfig;
/** If provided, only load skills with these names (for per-channel skill filtering) */
skillFilter?: string[];
}): Promise<{
sessionEntry?: SessionEntry;
skillsSnapshot?: SessionEntry["skillsSnapshot"];
@@ -63,6 +65,7 @@ export async function ensureSkillSnapshot(params: {
isFirstTurnInSession,
workspaceDir,
cfg,
skillFilter,
} = params;
let nextEntry = sessionEntry;
@@ -76,7 +79,10 @@ export async function ensureSkillSnapshot(params: {
};
const skillSnapshot =
isFirstTurnInSession || !current.skillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
skillFilter,
})
: current.skillsSnapshot;
nextEntry = {
...current,
@@ -96,7 +102,10 @@ export async function ensureSkillSnapshot(params: {
nextEntry?.skillsSnapshot ??
(isFirstTurnInSession
? undefined
: buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }));
: buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
skillFilter,
}));
if (
skillsSnapshot &&
sessionStore &&
+52 -27
View File
@@ -26,27 +26,23 @@ describe("buildStatusMessage", () => {
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
storePath: "/tmp/sessions.json",
resolvedThink: "medium",
resolvedVerbose: "off",
queue: { mode: "collect", depth: 0 },
now: 10 * 60_000, // 10 minutes later
webLinked: true,
webAuthAgeMs: 5 * 60_000,
heartbeatSeconds: 45,
});
expect(text).toContain("⚙️ Status");
expect(text).toContain("Agent: embedded pi");
expect(text).toContain("🦞 ClawdBot");
expect(text).toContain("🧠 Model:");
expect(text).toContain("Runtime: direct");
expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("🧹 Compactions: 2");
expect(text).toContain("Session: agent:main:main");
expect(text).toContain("compactions 2");
expect(text).toContain("Web: linked");
expect(text).toContain("heartbeat 45s");
expect(text).toContain("thinking=medium");
expect(text).toContain("verbose=off");
expect(text).not.toContain("Shortcuts:");
expect(text).not.toContain("set with");
expect(text).toContain("updated 10m ago");
expect(text).toContain("Think: medium");
expect(text).toContain("Verbose: off");
expect(text).toContain("Elevated: on");
expect(text).toContain("Queue: collect");
});
it("handles missing agent config gracefully", () => {
@@ -56,9 +52,9 @@ describe("buildStatusMessage", () => {
webLinked: false,
});
expect(text).toContain("Agent: embedded pi");
expect(text).toContain("🧠 Model:");
expect(text).toContain("Context:");
expect(text).toContain("Web: not linked");
expect(text).toContain("Queue:");
});
it("includes group activation for group sessions", () => {
@@ -72,10 +68,47 @@ describe("buildStatusMessage", () => {
},
sessionKey: "agent:main:whatsapp:group:123@g.us",
sessionScope: "per-sender",
webLinked: true,
queue: { mode: "collect", depth: 0 },
});
expect(text).toContain("Group activation: always");
expect(text).toContain("Activation: always");
});
it("shows queue details when overridden", () => {
const text = buildStatusMessage({
agent: {},
sessionEntry: { sessionId: "q1", updatedAt: 0 },
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: {
mode: "collect",
depth: 3,
debounceMs: 2000,
cap: 5,
dropPolicy: "old",
showDetails: true,
},
});
expect(text).toContain(
"Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)",
);
});
it("inserts usage summary beneath context line", () => {
const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5", contextTokens: 32_000 },
sessionEntry: { sessionId: "u1", updatedAt: 0, totalTokens: 1000 },
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
usageLine: "📊 Usage: Claude 80% left (5h)",
});
const lines = text.split("\n");
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 () => {
@@ -88,14 +121,6 @@ describe("buildStatusMessage", () => {
"./status.js"
);
const storePath = path.join(
dir,
".clawdbot",
"agents",
"main",
"sessions",
"sessions.json",
);
const sessionId = "sess-1";
const logPath = path.join(
dir,
@@ -141,8 +166,8 @@ describe("buildStatusMessage", () => {
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
storePath,
webLinked: true,
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
});
expect(text).toContain("Context: 1.0k/32k");
+155 -65
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { lookupContextTokens } from "../agents/context.js";
import {
@@ -19,7 +20,7 @@ import {
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import { shortenHomePath } from "../utils.js";
import { VERSION } from "../version.js";
import type {
ElevatedLevel,
ReasoningLevel,
@@ -29,23 +30,30 @@ import type {
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
type QueueStatus = {
mode?: string;
depth?: number;
debounceMs?: number;
cap?: number;
dropPolicy?: string;
showDetails?: boolean;
};
type StatusArgs = {
agent: AgentConfig;
workspaceDir?: string;
sessionEntry?: SessionEntry;
sessionKey?: string;
sessionScope?: SessionScope;
storePath?: string;
groupActivation?: "mention" | "always";
resolvedThink?: ThinkLevel;
resolvedVerbose?: VerboseLevel;
resolvedReasoning?: ReasoningLevel;
resolvedElevated?: ElevatedLevel;
modelAuth?: string;
usageLine?: string;
queue?: QueueStatus;
includeTranscriptUsage?: boolean;
now?: number;
webLinked?: boolean;
webAuthAgeMs?: number | null;
heartbeatSeconds?: number;
};
const formatAge = (ms?: number | null) => {
@@ -84,6 +92,97 @@ export const formatContextUsageShort = (
contextTokens: number | null | undefined,
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
const formatCommit = (value?: string | null) => {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > 7 ? trimmed.slice(0, 7) : trimmed;
};
const resolveGitHead = (startDir: string) => {
let current = startDir;
for (let i = 0; i < 12; i += 1) {
const gitPath = path.join(current, ".git");
try {
const stat = fs.statSync(gitPath);
if (stat.isDirectory()) {
return path.join(gitPath, "HEAD");
}
if (stat.isFile()) {
const raw = fs.readFileSync(gitPath, "utf-8");
const match = raw.match(/gitdir:\s*(.+)/i);
if (match?.[1]) {
const resolved = path.resolve(current, match[1].trim());
return path.join(resolved, "HEAD");
}
}
} catch {
// ignore missing .git at this level
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return null;
};
let cachedCommit: string | null | undefined;
const resolveCommitHash = () => {
if (cachedCommit !== undefined) return cachedCommit;
const envCommit =
process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim();
const normalized = formatCommit(envCommit);
if (normalized) {
cachedCommit = normalized;
return cachedCommit;
}
try {
const headPath = resolveGitHead(process.cwd());
if (!headPath) {
cachedCommit = null;
return cachedCommit;
}
const head = fs.readFileSync(headPath, "utf-8").trim();
if (!head) {
cachedCommit = null;
return cachedCommit;
}
if (head.startsWith("ref:")) {
const ref = head.replace(/^ref:\s*/i, "").trim();
const refPath = path.resolve(path.dirname(headPath), ref);
const refHash = fs.readFileSync(refPath, "utf-8").trim();
cachedCommit = formatCommit(refHash);
return cachedCommit;
}
cachedCommit = formatCommit(head);
return cachedCommit;
} catch {
cachedCommit = null;
return cachedCommit;
}
};
const formatQueueDetails = (queue?: QueueStatus) => {
if (!queue) return "";
const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null;
if (!queue.showDetails) {
return depth ? ` (${depth})` : "";
}
const detailParts: string[] = [];
if (depth) detailParts.push(depth);
if (typeof queue.debounceMs === "number") {
const ms = Math.max(0, Math.round(queue.debounceMs));
const label =
ms >= 1000
? `${ms % 1000 === 0 ? ms / 1000 : (ms / 1000).toFixed(1)}s`
: `${ms}ms`;
detailParts.push(`debounce ${label}`);
}
if (typeof queue.cap === "number") detailParts.push(`cap ${queue.cap}`);
if (queue.dropPolicy) detailParts.push(`drop ${queue.dropPolicy}`);
return detailParts.length ? ` (${detailParts.join(" · ")})` : "";
};
const readUsageFromSessionLog = (
sessionId?: string,
):
@@ -164,15 +263,17 @@ export function buildStatusMessage(args: StatusArgs): string {
// Prefer prompt-size tokens from the session transcript when it looks larger
// (cached prompt tokens are often missing from agent meta/store).
const logUsage = readUsageFromSessionLog(entry?.sessionId);
if (logUsage) {
const candidate = logUsage.promptTokens || logUsage.total;
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
totalTokens = candidate;
}
if (!model) model = logUsage.model ?? model;
if (!contextTokens && logUsage.model) {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
if (args.includeTranscriptUsage) {
const logUsage = readUsageFromSessionLog(entry?.sessionId);
if (logUsage) {
const candidate = logUsage.promptTokens || logUsage.total;
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
totalTokens = candidate;
}
if (!model) model = logUsage.model ?? model;
if (!contextTokens && logUsage.model) {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
}
}
}
@@ -188,8 +289,7 @@ export function buildStatusMessage(args: StatusArgs): string {
const runtime = (() => {
const sandboxMode = args.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off")
return { line: "Runtime: direct", sandboxed: false };
if (sandboxMode === "off") return { label: "direct" };
const sessionScope = args.sessionScope ?? "per-sender";
const mainKey = resolveMainSessionKey({
session: { scope: sessionScope },
@@ -199,35 +299,17 @@ export function buildStatusMessage(args: StatusArgs): string {
? sandboxMode === "all" || sessionKey !== mainKey.trim()
: false;
const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
const suffix = sandboxed ? ` • elevated ${elevatedLevel}` : "";
return {
line: `Runtime: ${runtime} (sandbox ${sandboxMode})${suffix}`,
sandboxed,
label: `${runtime}/${sandboxMode}`,
};
})();
const webLine = (() => {
if (args.webLinked === false) {
return "Web: not linked — run `clawdbot login` to scan the QR.";
}
const authAge = formatAge(args.webAuthAgeMs);
const heartbeat =
typeof args.heartbeatSeconds === "number"
? ` • heartbeat ${args.heartbeatSeconds}s`
: "";
return `Web: linked • auth refreshed ${authAge}${heartbeat}`;
})();
const updatedAt = entry?.updatedAt;
const sessionLine = [
`Session: ${args.sessionKey ?? "unknown"}`,
`scope ${args.sessionScope ?? "per-sender"}`,
entry?.updatedAt
? `updated ${formatAge(now - entry.updatedAt)}`
typeof updatedAt === "number"
? `updated ${formatAge(now - updatedAt)}`
: "no activity",
typeof entry?.compactionCount === "number"
? `compactions ${entry.compactionCount}`
: undefined,
args.storePath ? `store ${shortenHomePath(args.storePath)}` : undefined,
]
.filter(Boolean)
.join(" • ");
@@ -238,39 +320,47 @@ export function buildStatusMessage(args: StatusArgs): string {
Boolean(args.sessionKey?.includes(":group:")) ||
Boolean(args.sessionKey?.includes(":channel:")) ||
Boolean(args.sessionKey?.startsWith("group:"));
const groupActivationLine = isGroupSession
? `Group activation: ${args.groupActivation ?? entry?.groupActivation ?? "mention"}`
const groupActivationValue = isGroupSession
? (args.groupActivation ?? entry?.groupActivation ?? "mention")
: undefined;
const contextLine = `Context: ${formatTokens(
totalTokens,
contextTokens ?? null,
)}${entry?.abortedLastRun ? " • last run aborted" : ""}`;
const contextLine = [
`Context: ${formatTokens(totalTokens, contextTokens ?? null)}`,
`🧹 Compactions: ${entry?.compactionCount ?? 0}`,
]
.filter(Boolean)
.join(" · ");
const optionsLine = runtime.sandboxed
? `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | reasoning=${reasoningLevel} | elevated=${elevatedLevel}`
: `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | reasoning=${reasoningLevel}`;
const queueMode = args.queue?.mode ?? "unknown";
const queueDetails = formatQueueDetails(args.queue);
const optionParts = [
`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";
const agentLine = `Agent: embedded pi • ${modelLabel}`;
const authLine = args.modelAuth ? `Model auth: ${args.modelAuth}` : undefined;
const workspaceLine = args.workspaceDir
? `Workspace: ${shortenHomePath(args.workspaceDir)}`
: undefined;
const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : "";
const modelLine = `🧠 Model: ${modelLabel}${authLabel}`;
const commit = resolveCommitHash();
const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`;
return [
"⚙️ Status",
webLine,
agentLine,
authLine,
runtime.line,
workspaceLine,
contextLine,
sessionLine,
groupActivationLine,
optionsLine,
versionLine,
modelLine,
`📚 ${contextLine}`,
args.usageLine,
`🧵 ${sessionLine}`,
`⚙️ ${optionsLine}`,
activationLine,
]
.filter(Boolean)
.join("\n");
+1
View File
@@ -31,6 +31,7 @@ export type MsgContext = {
GroupRoom?: string;
GroupSpace?: string;
GroupMembers?: string;
GroupSystemPrompt?: string;
SenderName?: string;
SenderId?: string;
SenderUsername?: string;
+5
View File
@@ -17,4 +17,9 @@ describe("normalizeReasoningLevel", () => {
expect(normalizeReasoningLevel("show")).toBe("on");
expect(normalizeReasoningLevel("hide")).toBe("off");
});
it("accepts stream", () => {
expect(normalizeReasoningLevel("stream")).toBe("stream");
expect(normalizeReasoningLevel("streaming")).toBe("stream");
});
});
+2 -1
View File
@@ -1,7 +1,7 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
export type VerboseLevel = "off" | "on";
export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream";
// Normalize user-provided thinking level strings to the canonical enum.
export function normalizeThinkLevel(
@@ -82,5 +82,6 @@ export function normalizeReasoningLevel(
)
)
return "on";
if (["stream", "streaming", "draft", "live"].includes(key)) return "stream";
return undefined;
}
+4
View File
@@ -5,8 +5,12 @@ export type GetReplyOptions = {
onTypingController?: (typing: TypingController) => void;
isHeartbeat?: boolean;
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
onReasoningStream?: (payload: ReplyPayload) => Promise<void> | void;
onBlockReply?: (payload: ReplyPayload) => Promise<void> | void;
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
disableBlockStreaming?: boolean;
/** If provided, only load these skills for this session (empty = no skills). */
skillFilter?: string[];
};
export type ReplyPayload = {
+82 -1
View File
@@ -1,6 +1,11 @@
import chalk from "chalk";
import { Command } from "commander";
import { agentCliCommand } from "../commands/agent-via-gateway.js";
import {
agentsAddCommand,
agentsDeleteCommand,
agentsListCommand,
} from "../commands/agents.js";
import { configureCommand } from "../commands/configure.js";
import { doctorCommand } from "../commands/doctor.js";
import { healthCommand } from "../commands/health.js";
@@ -217,7 +222,10 @@ export function buildProgram() {
.option("--workspace <dir>", "Agent workspace directory (default: ~/clawd)")
.option("--non-interactive", "Run without prompts", false)
.option("--mode <mode>", "Wizard mode: local|remote")
.option("--auth-choice <choice>", "Auth: oauth|apiKey|minimax|skip")
.option(
"--auth-choice <choice>",
"Auth: oauth|openai-codex|antigravity|apiKey|minimax|skip",
)
.option("--anthropic-api-key <key>", "Anthropic API key")
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
@@ -243,6 +251,8 @@ export function buildProgram() {
mode: opts.mode as "local" | "remote" | undefined,
authChoice: opts.authChoice as
| "oauth"
| "openai-codex"
| "antigravity"
| "apiKey"
| "minimax"
| "skip"
@@ -545,6 +555,74 @@ Examples:
}
});
const agents = program
.command("agents")
.description("Manage isolated agents (workspaces + auth + routing)");
agents
.command("list")
.description("List configured agents")
.option("--json", "Output JSON instead of text", false)
.action(async (opts) => {
try {
await agentsListCommand({ json: Boolean(opts.json) }, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
agents
.command("add [name]")
.description("Add a new isolated agent")
.option("--workspace <dir>", "Workspace directory for the new agent")
.option("--json", "Output JSON summary", false)
.action(async (name, opts) => {
try {
await agentsAddCommand(
{
name: typeof name === "string" ? name : undefined,
workspace: opts.workspace as string | undefined,
json: Boolean(opts.json),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
agents
.command("delete <id>")
.description("Delete an agent and prune workspace/state")
.option("--force", "Skip confirmation", false)
.option("--json", "Output JSON summary", false)
.action(async (id, opts) => {
try {
await agentsDeleteCommand(
{
id: String(id),
force: Boolean(opts.force),
json: Boolean(opts.json),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
agents.action(async () => {
try {
await agentsListCommand({}, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
registerCanvasCli(program);
registerGatewayCli(program);
registerModelsCli(program);
@@ -561,6 +639,7 @@ Examples:
.command("status")
.description("Show web session health and recent session recipients")
.option("--json", "Output JSON instead of text", false)
.option("--usage", "Show provider usage/quota snapshots", false)
.option(
"--deep",
"Probe providers (WhatsApp Web + Telegram + Discord + Slack + Signal)",
@@ -574,6 +653,7 @@ Examples:
Examples:
clawdbot status # show linked account + session store summary
clawdbot status --json # machine-readable output
clawdbot status --usage # show provider usage/quota snapshots
clawdbot status --deep # run provider probes (WA + Telegram + Discord + Slack + Signal)
clawdbot status --deep --timeout 5000 # tighten probe timeout`,
)
@@ -594,6 +674,7 @@ Examples:
{
json: Boolean(opts.json),
deep: Boolean(opts.deep),
usage: Boolean(opts.usage),
timeoutMs: timeout,
},
defaultRuntime,
+140
View File
@@ -0,0 +1,140 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import {
applyAgentBindings,
applyAgentConfig,
buildAgentSummaries,
pruneAgentConfig,
} from "./agents.js";
describe("agents helpers", () => {
it("buildAgentSummaries includes default + routing agents", () => {
const cfg: ClawdbotConfig = {
agent: { workspace: "/main-ws", model: { primary: "anthropic/claude" } },
routing: {
defaultAgentId: "work",
agents: {
work: {
name: "Work",
workspace: "/work-ws",
agentDir: "/state/agents/work/agent",
model: "openai/gpt-4.1",
},
},
bindings: [
{
agentId: "work",
match: { provider: "whatsapp", accountId: "biz" },
},
{ agentId: "main", match: { provider: "telegram" } },
],
},
};
const summaries = buildAgentSummaries(cfg);
const main = summaries.find((summary) => summary.id === "main");
const work = summaries.find((summary) => summary.id === "work");
expect(main).toBeTruthy();
expect(main?.workspace).toBe("/main-ws");
expect(main?.bindings).toBe(1);
expect(main?.model).toBe("anthropic/claude");
expect(main?.agentDir.endsWith(path.join("agents", "main", "agent"))).toBe(
true,
);
expect(work).toBeTruthy();
expect(work?.name).toBe("Work");
expect(work?.workspace).toBe("/work-ws");
expect(work?.agentDir).toBe("/state/agents/work/agent");
expect(work?.bindings).toBe(1);
expect(work?.isDefault).toBe(true);
});
it("applyAgentConfig merges updates", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
work: { workspace: "/old-ws", model: "anthropic/claude" },
},
},
};
const next = applyAgentConfig(cfg, {
agentId: "work",
name: "Work",
workspace: "/new-ws",
agentDir: "/state/work/agent",
});
const work = next.routing?.agents?.work;
expect(work?.name).toBe("Work");
expect(work?.workspace).toBe("/new-ws");
expect(work?.agentDir).toBe("/state/work/agent");
expect(work?.model).toBe("anthropic/claude");
});
it("applyAgentBindings skips duplicates and reports conflicts", () => {
const cfg: ClawdbotConfig = {
routing: {
bindings: [
{
agentId: "main",
match: { provider: "whatsapp", accountId: "default" },
},
],
},
};
const result = applyAgentBindings(cfg, [
{
agentId: "main",
match: { provider: "whatsapp", accountId: "default" },
},
{
agentId: "work",
match: { provider: "whatsapp", accountId: "default" },
},
{
agentId: "work",
match: { provider: "telegram" },
},
]);
expect(result.added).toHaveLength(1);
expect(result.skipped).toHaveLength(1);
expect(result.conflicts).toHaveLength(1);
expect(result.config.routing?.bindings).toHaveLength(2);
});
it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => {
const cfg: ClawdbotConfig = {
routing: {
defaultAgentId: "work",
agents: {
work: { workspace: "/work-ws" },
home: { workspace: "/home-ws" },
},
bindings: [
{ agentId: "work", match: { provider: "whatsapp" } },
{ agentId: "home", match: { provider: "telegram" } },
],
agentToAgent: { enabled: true, allow: ["work", "home"] },
},
};
const result = pruneAgentConfig(cfg, "work");
expect(result.config.routing?.agents?.work).toBeUndefined();
expect(result.config.routing?.agents?.home).toBeTruthy();
expect(result.config.routing?.bindings).toHaveLength(1);
expect(result.config.routing?.bindings?.[0]?.agentId).toBe("home");
expect(result.config.routing?.agentToAgent?.allow).toEqual(["home"]);
expect(result.config.routing?.defaultAgentId).toBe(DEFAULT_AGENT_ID);
expect(result.removedBindings).toBe(1);
expect(result.removedAllow).toBe(1);
});
});
+649
View File
@@ -0,0 +1,649 @@
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDBOT,
readConfigFileSnapshot,
writeConfigFile,
} from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import {
DEFAULT_ACCOUNT_ID,
DEFAULT_AGENT_ID,
normalizeAgentId,
} from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveDefaultWhatsAppAccountId } from "../web/accounts.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import { WizardCancelledError } from "../wizard/prompts.js";
import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
import { ensureWorkspaceAndSessions, moveToTrash } from "./onboard-helpers.js";
import { setupProviders } from "./onboard-providers.js";
import type { AuthChoice, ProviderChoice } from "./onboard-types.js";
type AgentsListOptions = {
json?: boolean;
};
type AgentsAddOptions = {
name?: string;
workspace?: string;
json?: boolean;
};
type AgentsDeleteOptions = {
id: string;
force?: boolean;
json?: boolean;
};
export type AgentSummary = {
id: string;
name?: string;
workspace: string;
agentDir: string;
model?: string;
bindings: number;
isDefault: boolean;
};
type AgentBinding = {
agentId: string;
match: {
provider: string;
accountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string;
teamId?: string;
};
};
function resolveAgentName(cfg: ClawdbotConfig, agentId: string) {
return cfg.routing?.agents?.[agentId]?.name?.trim() || undefined;
}
function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
if (agentId !== DEFAULT_AGENT_ID) {
return cfg.routing?.agents?.[agentId]?.model?.trim() || undefined;
}
const raw = cfg.agent?.model;
if (typeof raw === "string") return raw;
return raw?.primary?.trim() || undefined;
}
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {
const defaultAgentId = normalizeAgentId(
cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const agentIds = new Set<string>([
DEFAULT_AGENT_ID,
defaultAgentId,
...Object.keys(cfg.routing?.agents ?? {}),
]);
const bindingCounts = new Map<string, number>();
for (const binding of cfg.routing?.bindings ?? []) {
const agentId = normalizeAgentId(binding.agentId);
bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1);
}
const ordered = [
DEFAULT_AGENT_ID,
...[...agentIds]
.filter((id) => id !== DEFAULT_AGENT_ID)
.sort((a, b) => a.localeCompare(b)),
];
return ordered.map((id) => ({
id,
name: resolveAgentName(cfg, id),
workspace: resolveAgentWorkspaceDir(cfg, id),
agentDir: resolveAgentDir(cfg, id),
model: resolveAgentModel(cfg, id),
bindings: bindingCounts.get(id) ?? 0,
isDefault: id === defaultAgentId,
}));
}
export function applyAgentConfig(
cfg: ClawdbotConfig,
params: {
agentId: string;
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
},
): ClawdbotConfig {
const agentId = normalizeAgentId(params.agentId);
const existing = cfg.routing?.agents?.[agentId] ?? {};
const name = params.name?.trim();
return {
...cfg,
routing: {
...cfg.routing,
agents: {
...cfg.routing?.agents,
[agentId]: {
...existing,
...(name ? { name } : {}),
...(params.workspace ? { workspace: params.workspace } : {}),
...(params.agentDir ? { agentDir: params.agentDir } : {}),
...(params.model ? { model: params.model } : {}),
},
},
},
};
}
function bindingMatchKey(match: AgentBinding["match"]) {
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
return [
match.provider,
accountId,
match.peer?.kind ?? "",
match.peer?.id ?? "",
match.guildId ?? "",
match.teamId ?? "",
].join("|");
}
export function applyAgentBindings(
cfg: ClawdbotConfig,
bindings: AgentBinding[],
): {
config: ClawdbotConfig;
added: AgentBinding[];
skipped: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
} {
const existing = cfg.routing?.bindings ?? [];
const existingMatchMap = new Map<string, string>();
for (const binding of existing) {
const key = bindingMatchKey(binding.match);
if (!existingMatchMap.has(key)) {
existingMatchMap.set(key, normalizeAgentId(binding.agentId));
}
}
const added: AgentBinding[] = [];
const skipped: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> =
[];
for (const binding of bindings) {
const agentId = normalizeAgentId(binding.agentId);
const key = bindingMatchKey(binding.match);
const existingAgentId = existingMatchMap.get(key);
if (existingAgentId) {
if (existingAgentId === agentId) {
skipped.push(binding);
} else {
conflicts.push({ binding, existingAgentId });
}
continue;
}
existingMatchMap.set(key, agentId);
added.push({ ...binding, agentId });
}
if (added.length === 0) {
return { config: cfg, added, skipped, conflicts };
}
return {
config: {
...cfg,
routing: {
...cfg.routing,
bindings: [...existing, ...added],
},
},
added,
skipped,
conflicts,
};
}
export function pruneAgentConfig(
cfg: ClawdbotConfig,
agentId: string,
): {
config: ClawdbotConfig;
removedBindings: number;
removedAllow: number;
} {
const id = normalizeAgentId(agentId);
const agents = { ...cfg.routing?.agents };
delete agents[id];
const nextAgents = Object.keys(agents).length > 0 ? agents : undefined;
const bindings = cfg.routing?.bindings ?? [];
const filteredBindings = bindings.filter(
(binding) => normalizeAgentId(binding.agentId) !== id,
);
const allow = cfg.routing?.agentToAgent?.allow ?? [];
const filteredAllow = allow.filter((entry) => entry !== id);
const nextRouting = {
...cfg.routing,
...(nextAgents ? { agents: nextAgents } : {}),
...(nextAgents ? {} : { agents: undefined }),
bindings: filteredBindings.length > 0 ? filteredBindings : undefined,
agentToAgent: cfg.routing?.agentToAgent
? {
...cfg.routing.agentToAgent,
allow: filteredAllow.length > 0 ? filteredAllow : undefined,
}
: undefined,
defaultAgentId:
normalizeAgentId(cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID) === id
? DEFAULT_AGENT_ID
: cfg.routing?.defaultAgentId,
};
return {
config: {
...cfg,
routing: nextRouting,
},
removedBindings: bindings.length - filteredBindings.length,
removedAllow: allow.length - filteredAllow.length,
};
}
function formatSummary(summary: AgentSummary) {
const name =
summary.name && summary.name !== summary.id ? ` "${summary.name}"` : "";
const defaultTag = summary.isDefault ? " (default)" : "";
const parts = [
`${summary.id}${name}${defaultTag}`,
`workspace: ${summary.workspace}`,
`agentDir: ${summary.agentDir}`,
summary.model ? `model: ${summary.model}` : null,
`bindings: ${summary.bindings}`,
].filter(Boolean);
return `- ${parts.join(" | ")}`;
}
async function requireValidConfig(
runtime: RuntimeEnv,
): Promise<ClawdbotConfig | null> {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const issues =
snapshot.issues.length > 0
? snapshot.issues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n")
: "Unknown validation issue.";
runtime.error(`Config invalid:\n${issues}`);
runtime.error("Fix the config or run clawdbot doctor.");
runtime.exit(1);
return null;
}
return snapshot.config;
}
export async function agentsListCommand(
opts: AgentsListOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const summaries = buildAgentSummaries(cfg);
if (opts.json) {
runtime.log(JSON.stringify(summaries, null, 2));
return;
}
runtime.log(["Agents:", ...summaries.map(formatSummary)].join("\n"));
}
function describeBinding(binding: AgentBinding) {
const match = binding.match;
const parts = [match.provider];
if (match.accountId) parts.push(`accountId=${match.accountId}`);
if (match.peer) parts.push(`peer=${match.peer.kind}:${match.peer.id}`);
if (match.guildId) parts.push(`guild=${match.guildId}`);
if (match.teamId) parts.push(`team=${match.teamId}`);
return parts.join(" ");
}
function buildProviderBindings(params: {
agentId: string;
selection: ProviderChoice[];
config: ClawdbotConfig;
whatsappAccountId?: string;
}): AgentBinding[] {
const bindings: AgentBinding[] = [];
const agentId = normalizeAgentId(params.agentId);
for (const provider of params.selection) {
const match: AgentBinding["match"] = { provider };
if (provider === "whatsapp") {
const accountId =
params.whatsappAccountId?.trim() ||
resolveDefaultWhatsAppAccountId(params.config);
match.accountId = accountId || DEFAULT_ACCOUNT_ID;
}
bindings.push({ agentId, match });
}
return bindings;
}
export async function agentsAddCommand(
opts: AgentsAddOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const workspaceFlag = opts.workspace?.trim();
const nameInput = opts.name?.trim();
if (workspaceFlag) {
if (!nameInput) {
runtime.error("Agent name is required when --workspace is provided.");
runtime.exit(1);
return;
}
const agentId = normalizeAgentId(nameInput);
if (agentId === DEFAULT_AGENT_ID) {
runtime.error(`"${DEFAULT_AGENT_ID}" is reserved. Choose another name.`);
runtime.exit(1);
return;
}
if (agentId !== nameInput) {
runtime.log(`Normalized agent id to "${agentId}".`);
}
if (cfg.routing?.agents?.[agentId]) {
runtime.error(`Agent "${agentId}" already exists.`);
runtime.exit(1);
return;
}
const workspaceDir = resolveUserPath(workspaceFlag);
const agentDir = resolveAgentDir(cfg, agentId);
const nextConfig = applyAgentConfig(cfg, {
agentId,
name: nameInput,
workspace: workspaceDir,
agentDir,
});
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap),
agentId,
});
const payload = {
agentId,
name: nameInput,
workspace: workspaceDir,
agentDir,
};
if (opts.json) {
runtime.log(JSON.stringify(payload, null, 2));
} else {
runtime.log(`Agent: ${agentId}`);
runtime.log(`Workspace: ${workspaceDir}`);
runtime.log(`Agent dir: ${agentDir}`);
}
return;
}
const prompter = createClackPrompter();
try {
await prompter.intro("Add Clawdbot agent");
const name =
nameInput ??
(await prompter.text({
message: "Agent name",
validate: (value) => {
if (!value?.trim()) return "Required";
const normalized = normalizeAgentId(value);
if (normalized === DEFAULT_AGENT_ID) {
return `"${DEFAULT_AGENT_ID}" is reserved. Choose another name.`;
}
return undefined;
},
}));
const agentName = String(name).trim();
const agentId = normalizeAgentId(agentName);
if (agentName !== agentId) {
await prompter.note(`Normalized id to "${agentId}".`, "Agent id");
}
const existingAgent = cfg.routing?.agents?.[agentId];
if (existingAgent) {
const shouldUpdate = await prompter.confirm({
message: `Agent "${agentId}" already exists. Update it?`,
initialValue: false,
});
if (!shouldUpdate) {
await prompter.outro("No changes made.");
return;
}
}
const workspaceDefault = resolveAgentWorkspaceDir(cfg, agentId);
const workspaceInput = await prompter.text({
message: "Workspace directory",
initialValue: workspaceDefault,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const workspaceDir = resolveUserPath(
String(workspaceInput).trim() || workspaceDefault,
);
const agentDir = resolveAgentDir(cfg, agentId);
let nextConfig = applyAgentConfig(cfg, {
agentId,
name: agentName,
workspace: workspaceDir,
agentDir,
});
const wantsAuth = await prompter.confirm({
message: "Configure model/auth for this agent now?",
initialValue: false,
});
if (wantsAuth) {
const authStore = ensureAuthProfileStore(agentDir);
const authChoice = (await prompter.select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
store: authStore,
includeSkip: true,
}),
})) as AuthChoice;
const authResult = await applyAuthChoice({
authChoice,
config: nextConfig,
prompter,
runtime,
agentDir,
setDefaultModel: false,
agentId,
});
nextConfig = authResult.config;
if (authResult.agentModelOverride) {
nextConfig = applyAgentConfig(nextConfig, {
agentId,
model: authResult.agentModelOverride,
});
}
}
await warnIfModelConfigLooksOff(nextConfig, prompter, {
agentId,
agentDir,
});
let selection: ProviderChoice[] = [];
let whatsappAccountId: string | undefined;
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
allowSignalInstall: true,
onSelection: (value) => {
selection = value;
},
promptWhatsAppAccountId: true,
onWhatsAppAccountId: (value) => {
whatsappAccountId = value;
},
});
if (selection.length > 0) {
const wantsBindings = await prompter.confirm({
message:
"Route selected providers to this agent now? (routing.bindings)",
initialValue: false,
});
if (wantsBindings) {
const desiredBindings = buildProviderBindings({
agentId,
selection,
config: nextConfig,
whatsappAccountId,
});
const result = applyAgentBindings(nextConfig, desiredBindings);
nextConfig = result.config;
if (result.conflicts.length > 0) {
await prompter.note(
[
"Skipped bindings already claimed by another agent:",
...result.conflicts.map(
(conflict) =>
`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
),
].join("\n"),
"Routing bindings",
);
}
} else {
await prompter.note(
[
"Routing unchanged. Add routing.bindings when you're ready.",
"Docs: https://docs.clawd.bot/concepts/multi-agent",
].join("\n"),
"Routing",
);
}
}
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap),
agentId,
});
const payload = {
agentId,
name: agentName,
workspace: workspaceDir,
agentDir,
};
if (opts.json) {
runtime.log(JSON.stringify(payload, null, 2));
}
await prompter.outro(`Agent "${agentId}" ready.`);
} catch (err) {
if (err instanceof WizardCancelledError) {
runtime.exit(0);
return;
}
throw err;
}
}
export async function agentsDeleteCommand(
opts: AgentsDeleteOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const input = opts.id?.trim();
if (!input) {
runtime.error("Agent id is required.");
runtime.exit(1);
return;
}
const agentId = normalizeAgentId(input);
if (agentId !== input) {
runtime.log(`Normalized agent id to "${agentId}".`);
}
if (agentId === DEFAULT_AGENT_ID) {
runtime.error(`"${DEFAULT_AGENT_ID}" cannot be deleted.`);
runtime.exit(1);
return;
}
if (!cfg.routing?.agents?.[agentId]) {
runtime.error(`Agent "${agentId}" not found.`);
runtime.exit(1);
return;
}
if (!opts.force) {
if (!process.stdin.isTTY) {
runtime.error("Non-interactive session. Re-run with --force.");
runtime.exit(1);
return;
}
const prompter = createClackPrompter();
const confirmed = await prompter.confirm({
message: `Delete agent "${agentId}" and prune workspace/state?`,
initialValue: false,
});
if (!confirmed) {
runtime.log("Cancelled.");
return;
}
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const agentDir = resolveAgentDir(cfg, agentId);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
const result = pruneAgentConfig(cfg, agentId);
await writeConfigFile(result.config);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await moveToTrash(workspaceDir, runtime);
await moveToTrash(agentDir, runtime);
await moveToTrash(sessionsDir, runtime);
if (opts.json) {
runtime.log(
JSON.stringify(
{
agentId,
workspace: workspaceDir,
agentDir,
sessionsDir,
removedBindings: result.removedBindings,
removedAllow: result.removedAllow,
},
null,
2,
),
);
} else {
runtime.log(`Deleted agent: ${agentId}`);
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
} from "../agents/auth-profiles.js";
import type { AuthChoice } from "./onboard-types.js";
export type AuthChoiceOption = { value: AuthChoice; label: string };
export function buildAuthChoiceOptions(params: {
store: AuthProfileStore;
includeSkip: boolean;
}): AuthChoiceOption[] {
const options: AuthChoiceOption[] = [];
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
if (claudeCli?.type === "oauth") {
options.push({
value: "claude-cli",
label: "Anthropic OAuth (Claude CLI)",
});
}
options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
if (codexCli?.type === "oauth") {
options.push({
value: "codex-cli",
label: "OpenAI Codex OAuth (Codex CLI)",
});
}
options.push({
value: "openai-codex",
label: "OpenAI Codex (ChatGPT OAuth)",
});
options.push({
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
});
options.push({ value: "apiKey", label: "Anthropic API key" });
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
if (params.includeSkip) {
options.push({ value: "skip", label: "Skip for now" });
}
return options;
}
+411
View File
@@ -0,0 +1,411 @@
import {
loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
listProfilesForProvider,
} from "../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../agents/model-auth.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
applyMinimaxProviderConfig,
setAnthropicApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
import type { AuthChoice } from "./onboard-types.js";
import {
applyOpenAICodexModelDefault,
OPENAI_CODEX_DEFAULT_MODEL,
} from "./openai-codex-model-default.js";
export async function warnIfModelConfigLooksOff(
config: ClawdbotConfig,
prompter: WizardPrompter,
options?: { agentId?: string; agentDir?: string },
) {
const agentModelOverride = options?.agentId
? resolveAgentConfig(config, options.agentId)?.model?.trim()
: undefined;
const configWithModel =
agentModelOverride && agentModelOverride.length > 0
? {
...config,
agent: {
...config.agent,
model: {
...(typeof config.agent?.model === "object"
? config.agent.model
: undefined),
primary: agentModelOverride,
},
},
}
: config;
const ref = resolveConfiguredModelRef({
cfg: configWithModel,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const warnings: string[] = [];
const catalog = await loadModelCatalog({
config: configWithModel,
useCache: false,
});
if (catalog.length > 0) {
const known = catalog.some(
(entry) => entry.provider === ref.provider && entry.id === ref.model,
);
if (!known) {
warnings.push(
`Model not found: ${ref.provider}/${ref.model}. Update agent.model or run /models list.`,
);
}
}
const store = ensureAuthProfileStore(options?.agentDir);
const hasProfile = listProfilesForProvider(store, ref.provider).length > 0;
const envKey = resolveEnvApiKey(ref.provider);
const customKey = getCustomProviderApiKey(config, ref.provider);
if (!hasProfile && !envKey && !customKey) {
warnings.push(
`No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`,
);
}
if (ref.provider === "openai") {
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
if (hasCodex) {
warnings.push(
`Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`,
);
}
}
if (warnings.length > 0) {
await prompter.note(warnings.join("\n"), "Model check");
}
}
export async function applyAuthChoice(params: {
authChoice: AuthChoice;
config: ClawdbotConfig;
prompter: WizardPrompter;
runtime: RuntimeEnv;
agentDir?: string;
setDefaultModel: boolean;
agentId?: string;
}): Promise<{ config: ClawdbotConfig; agentModelOverride?: string }> {
let nextConfig = params.config;
let agentModelOverride: string | undefined;
const noteAgentModel = async (model: string) => {
if (!params.agentId) return;
await params.prompter.note(
`Default model set to ${model} for agent "${params.agentId}".`,
"Model configured",
);
};
if (params.authChoice === "oauth") {
await params.prompter.note(
"Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth",
);
const spin = params.prompter.progress("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAnthropic(
async (url) => {
await openUrl(url);
params.runtime.log(`Open: ${url}`);
},
async () => {
const code = await params.prompter.text({
message: "Paste authorization code (code#state)",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return String(code);
},
);
spin.stop("OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds, params.agentDir);
const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider: "anthropic",
mode: "oauth",
email: oauthCreds.email ?? undefined,
});
}
} catch (err) {
spin.stop("OAuth failed");
params.runtime.error(String(err));
await params.prompter.note(
"Trouble with OAuth? See https://docs.clawd.bot/start/faq",
"OAuth help",
);
}
} else if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir);
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
"No Claude CLI credentials found at ~/.claude/.credentials.json.",
"Claude CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
});
} else if (params.authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
await params.prompter.note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
].join("\n")
: [
"Browser will open for OpenAI authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"OpenAI OAuth uses localhost:1455 for the callback.",
].join("\n"),
"OpenAI Codex OAuth",
);
const spin = params.prompter.progress("Starting OAuth flow…");
let manualCodePromise: Promise<string> | undefined;
try {
const creds = await loginOpenAICodex({
onAuth: async ({ url }) => {
if (isRemote) {
spin.stop("OAuth URL ready");
params.runtime.log(
`\nOpen this URL in your LOCAL browser:\n\n${url}\n`,
);
manualCodePromise = params.prompter
.text({
message: "Paste the redirect URL (or authorization code)",
validate: (value) => (value?.trim() ? undefined : "Required"),
})
.then((value) => String(value));
} else {
spin.update("Complete sign-in in browser…");
await openUrl(url);
params.runtime.log(`Open: ${url}`);
}
},
onPrompt: async (prompt) => {
if (manualCodePromise) {
return manualCodePromise;
}
const code = await params.prompter.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return String(code);
},
onProgress: (msg) => spin.update(msg),
});
spin.stop("OpenAI OAuth complete");
if (creds) {
await writeOAuthCredentials(
"openai-codex" as unknown as OAuthProvider,
creds,
params.agentDir,
);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "openai-codex:default",
provider: "openai-codex",
mode: "oauth",
});
if (params.setDefaultModel) {
const applied = applyOpenAICodexModelDefault(nextConfig);
nextConfig = applied.next;
if (applied.changed) {
await params.prompter.note(
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
"Model configured",
);
}
} else {
agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL;
await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL);
}
}
} catch (err) {
spin.stop("OpenAI OAuth failed");
params.runtime.error(String(err));
await params.prompter.note(
"Trouble with OAuth? See https://docs.clawd.bot/start/faq",
"OAuth help",
);
}
} else if (params.authChoice === "codex-cli") {
const store = ensureAuthProfileStore(params.agentDir);
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
await params.prompter.note(
"No Codex CLI credentials found at ~/.codex/auth.json.",
"Codex CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CODEX_CLI_PROFILE_ID,
provider: "openai-codex",
mode: "oauth",
});
if (params.setDefaultModel) {
const applied = applyOpenAICodexModelDefault(nextConfig);
nextConfig = applied.next;
if (applied.changed) {
await params.prompter.note(
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
"Model configured",
);
}
} else {
agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL;
await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL);
}
} else if (params.authChoice === "antigravity") {
const isRemote = isRemoteEnvironment();
await params.prompter.note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account that has Antigravity access.",
"The callback will be captured automatically on localhost:51121.",
].join("\n"),
"Google Antigravity OAuth",
);
const spin = params.prompter.progress("Starting OAuth flow…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAntigravityVpsAware(
async (url) => {
if (isRemote) {
spin.stop("OAuth URL ready");
params.runtime.log(
`\nOpen this URL in your LOCAL browser:\n\n${url}\n`,
);
} else {
spin.update("Complete sign-in in browser…");
await openUrl(url);
params.runtime.log(`Open: ${url}`);
}
},
(msg) => spin.update(msg),
);
spin.stop("Antigravity OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials(
"google-antigravity",
oauthCreds,
params.agentDir,
);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: `google-antigravity:${oauthCreds.email ?? "default"}`,
provider: "google-antigravity",
mode: "oauth",
});
const modelKey = "google-antigravity/claude-opus-4-5-thinking";
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
models: {
...nextConfig.agent?.models,
[modelKey]: nextConfig.agent?.models?.[modelKey] ?? {},
},
},
};
if (params.setDefaultModel) {
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
model: {
...(nextConfig.agent?.model &&
"fallbacks" in
(nextConfig.agent.model as Record<string, unknown>)
? {
fallbacks: (
nextConfig.agent.model as { fallbacks?: string[] }
).fallbacks,
}
: undefined),
primary: modelKey,
},
},
};
await params.prompter.note(
`Default model set to ${modelKey}`,
"Model configured",
);
} else {
agentModelOverride = modelKey;
await noteAgentModel(modelKey);
}
}
} catch (err) {
spin.stop("Antigravity OAuth failed");
params.runtime.error(String(err));
await params.prompter.note(
"Trouble with OAuth? See https://docs.clawd.bot/start/faq",
"OAuth help",
);
}
} else if (params.authChoice === "apiKey") {
const key = await params.prompter.text({
message: "Enter Anthropic API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setAnthropicApiKey(String(key).trim(), params.agentDir);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "anthropic:default",
provider: "anthropic",
mode: "api_key",
});
} else if (params.authChoice === "minimax") {
if (params.setDefaultModel) {
nextConfig = applyMinimaxConfig(nextConfig);
} else {
nextConfig = applyMinimaxProviderConfig(nextConfig);
agentModelOverride = "lmstudio/minimax-m2.1-gs32";
await noteAgentModel("lmstudio/minimax-m2.1-gs32");
}
}
return { config: nextConfig, agentModelOverride };
}
+39 -12
View File
@@ -16,6 +16,11 @@ import {
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDBOT,
@@ -35,6 +40,7 @@ import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
@@ -254,20 +260,21 @@ async function promptAuthConfig(
const authChoice = guardCancel(
await select({
message: "Model/auth choice",
options: [
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
{ value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" },
{
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
},
{ value: "apiKey", label: "Anthropic API key" },
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
{ value: "skip", label: "Skip for now" },
],
options: buildAuthChoiceOptions({
store: ensureAuthProfileStore(),
includeSkip: true,
}),
}),
runtime,
) as "oauth" | "openai-codex" | "antigravity" | "apiKey" | "minimax" | "skip";
) as
| "oauth"
| "claude-cli"
| "openai-codex"
| "codex-cli"
| "antigravity"
| "apiKey"
| "minimax"
| "skip";
let next = cfg;
@@ -312,6 +319,12 @@ async function promptAuthConfig(
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "claude-cli") {
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
});
} else if (authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
note(
@@ -386,6 +399,20 @@ async function promptAuthConfig(
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "codex-cli") {
next = applyAuthProfileConfig(next, {
profileId: CODEX_CLI_PROFILE_ID,
provider: "openai-codex",
mode: "oauth",
});
const applied = applyOpenAICodexModelDefault(next);
next = applied.next;
if (applied.changed) {
note(
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
"Model configured",
);
}
} else if (authChoice === "antigravity") {
const isRemote = isRemoteEnvironment();
note(
+27 -16
View File
@@ -6,9 +6,9 @@ import type { ClawdbotConfig } from "../config/config.js";
export async function writeOAuthCredentials(
provider: OAuthProvider,
creds: OAuthCredentials,
agentDir?: string,
): Promise<void> {
// Write to the multi-agent path so gateway finds credentials on startup
const agentDir = resolveDefaultAgentDir();
upsertAuthProfile({
profileId: `${provider}:${creds.email ?? "default"}`,
credential: {
@@ -16,13 +16,12 @@ export async function writeOAuthCredentials(
provider,
...creds,
},
agentDir,
agentDir: agentDir ?? resolveDefaultAgentDir(),
});
}
export async function setAnthropicApiKey(key: string) {
export async function setAnthropicApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
const agentDir = resolveDefaultAgentDir();
upsertAuthProfile({
profileId: "anthropic:default",
credential: {
@@ -30,7 +29,7 @@ export async function setAnthropicApiKey(key: string) {
provider: "anthropic",
key,
},
agentDir,
agentDir: agentDir ?? resolveDefaultAgentDir(),
});
}
@@ -74,7 +73,9 @@ export function applyAuthProfileConfig(
};
}
export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
export function applyMinimaxProviderConfig(
cfg: ClawdbotConfig,
): ClawdbotConfig {
const models = { ...cfg.agent?.models };
models["anthropic/claude-opus-4-5"] = {
...models["anthropic/claude-opus-4-5"],
@@ -109,16 +110,6 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
...cfg,
agent: {
...cfg.agent,
model: {
...(cfg.agent?.model &&
"fallbacks" in (cfg.agent.model as Record<string, unknown>)
? {
fallbacks: (cfg.agent.model as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: "lmstudio/minimax-m2.1-gs32",
},
models,
},
models: {
@@ -127,3 +118,23 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
},
};
}
export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const next = applyMinimaxProviderConfig(cfg);
return {
...next,
agent: {
...next.agent,
model: {
...(next.agent?.model &&
"fallbacks" in (next.agent.model as Record<string, unknown>)
? {
fallbacks: (next.agent.model as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: "lmstudio/minimax-m2.1-gs32",
},
},
};
}
+10 -6
View File
@@ -11,7 +11,7 @@ import {
} from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
@@ -19,7 +19,11 @@ import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { VERSION } from "../version.js";
import type { NodeManagerChoice, ResetScope } from "./onboard-types.js";
import type {
NodeManagerChoice,
OnboardMode,
ResetScope,
} from "./onboard-types.js";
export function guardCancel<T>(value: T, runtime: RuntimeEnv): T {
if (isCancel(value)) {
@@ -72,7 +76,7 @@ export function printWizardHeader(runtime: RuntimeEnv) {
export function applyWizardMetadata(
cfg: ClawdbotConfig,
params: { command: string; mode: "local" | "remote" },
params: { command: string; mode: OnboardMode },
): ClawdbotConfig {
const commit =
process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim() || undefined;
@@ -226,14 +230,14 @@ export async function openUrl(url: string): Promise<boolean> {
export async function ensureWorkspaceAndSessions(
workspaceDir: string,
runtime: RuntimeEnv,
options?: { skipBootstrap?: boolean },
options?: { skipBootstrap?: boolean; agentId?: string },
) {
const ws = await ensureAgentWorkspace({
dir: workspaceDir,
ensureBootstrapFiles: !options?.skipBootstrap,
});
runtime.log(`Workspace OK: ${ws.dir}`);
const sessionsDir = resolveSessionTranscriptsDir();
const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId);
await fs.mkdir(sessionsDir, { recursive: true });
runtime.log(`Sessions OK: ${sessionsDir}`);
}
@@ -275,7 +279,7 @@ export async function handleReset(
await moveToTrash(CONFIG_PATH_CLAWDBOT, runtime);
if (scope === "config") return;
await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime);
await moveToTrash(resolveSessionTranscriptsDir(), runtime);
await moveToTrash(resolveSessionTranscriptsDirForAgent(), runtime);
if (scope === "full") {
await moveToTrash(workspaceDir, runtime);
}
+40 -7
View File
@@ -1,5 +1,9 @@
import path from "node:path";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "../agents/auth-profiles.js";
import {
type ClawdbotConfig,
CONFIG_PATH_CLAWDBOT,
@@ -29,11 +33,8 @@ import {
ensureWorkspaceAndSessions,
randomToken,
} from "./onboard-helpers.js";
import type {
AuthChoice,
OnboardMode,
OnboardOptions,
} from "./onboard-types.js";
import type { AuthChoice, OnboardOptions } from "./onboard-types.js";
import { applyOpenAICodexModelDefault } from "./openai-codex-model-default.js";
import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js";
export async function runNonInteractiveOnboarding(
@@ -42,7 +43,12 @@ export async function runNonInteractiveOnboarding(
) {
const snapshot = await readConfigFileSnapshot();
const baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
const mode: OnboardMode = opts.mode ?? "local";
const mode = opts.mode ?? "local";
if (mode !== "local" && mode !== "remote") {
runtime.error(`Invalid --mode "${String(mode)}" (use local|remote).`);
runtime.exit(1);
return;
}
if (mode === "remote") {
const remoteUrl = opts.remoteUrl?.trim();
@@ -111,6 +117,33 @@ export async function runNonInteractiveOnboarding(
provider: "anthropic",
mode: "api_key",
});
} else if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore();
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
runtime.error(
"No Claude CLI credentials found at ~/.claude/.credentials.json",
);
runtime.exit(1);
return;
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
});
} else if (authChoice === "codex-cli") {
const store = ensureAuthProfileStore();
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
runtime.error("No Codex CLI credentials found at ~/.codex/auth.json");
runtime.exit(1);
return;
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CODEX_CLI_PROFILE_ID,
provider: "openai-codex",
mode: "oauth",
});
nextConfig = applyOpenAICodexModelDefault(nextConfig).next;
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
} else if (
+90 -8
View File
@@ -3,9 +3,17 @@ import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import type { DmPolicy } from "../config/types.js";
import { loginWeb } from "../provider-web.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeE164 } from "../utils.js";
import { WA_WEB_AUTH_DIR } from "../web/session.js";
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAuthDir,
} from "../web/accounts.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { detectBinary } from "./onboard-helpers.js";
import type { ProviderChoice } from "./onboard-types.js";
@@ -28,8 +36,12 @@ async function pathExists(filePath: string): Promise<boolean> {
}
}
async function detectWhatsAppLinked(): Promise<boolean> {
const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
async function detectWhatsAppLinked(
cfg: ClawdbotConfig,
accountId: string,
): Promise<boolean> {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const credsPath = path.join(authDir, "creds.json");
return await pathExists(credsPath);
}
@@ -461,13 +473,24 @@ async function promptWhatsAppAllowFrom(
return setWhatsAppAllowFrom(next, unique);
}
type SetupProvidersOptions = {
allowDisable?: boolean;
allowSignalInstall?: boolean;
onSelection?: (selection: ProviderChoice[]) => void;
whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void;
};
export async function setupProviders(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
prompter: WizardPrompter,
options?: { allowDisable?: boolean; allowSignalInstall?: boolean },
options?: SetupProvidersOptions,
): Promise<ClawdbotConfig> {
const whatsappLinked = await detectWhatsAppLinked();
let whatsappAccountId =
options?.whatsappAccountId?.trim() || resolveDefaultWhatsAppAccountId(cfg);
let whatsappLinked = await detectWhatsAppLinked(cfg, whatsappAccountId);
const telegramEnv = Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim());
const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim());
@@ -491,9 +514,11 @@ export async function setupProviders(
const imessageCliPath = cfg.imessage?.cliPath ?? "imsg";
const imessageCliDetected = await detectBinary(imessageCliPath);
const waAccountLabel =
whatsappAccountId === DEFAULT_ACCOUNT_ID ? "default" : whatsappAccountId;
await prompter.note(
[
`WhatsApp: ${whatsappLinked ? "linked" : "not linked"}`,
`WhatsApp (${waAccountLabel}): ${whatsappLinked ? "linked" : "not linked"}`,
`Telegram: ${telegramConfigured ? "configured" : "needs token"}`,
`Discord: ${discordConfigured ? "configured" : "needs token"}`,
`Slack: ${slackConfigured ? "configured" : "needs tokens"}`,
@@ -549,14 +574,71 @@ export async function setupProviders(
],
})) as ProviderChoice[];
options?.onSelection?.(selection);
let next = cfg;
if (selection.includes("whatsapp")) {
if (options?.promptWhatsAppAccountId && !options.whatsappAccountId) {
const existingIds = listWhatsAppAccountIds(next);
const choice = (await prompter.select({
message: "WhatsApp account",
options: [
...existingIds.map((id) => ({
value: id,
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
})),
{ value: "__new__", label: "Add a new account" },
],
})) as string;
if (choice === "__new__") {
const entered = await prompter.text({
message: "New WhatsApp account id",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const normalized = normalizeAccountId(String(entered));
if (String(entered).trim() !== normalized) {
await prompter.note(
`Normalized account id to "${normalized}".`,
"WhatsApp account",
);
}
whatsappAccountId = normalized;
} else {
whatsappAccountId = choice;
}
}
if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) {
next = {
...next,
whatsapp: {
...next.whatsapp,
accounts: {
...next.whatsapp?.accounts,
[whatsappAccountId]: {
...next.whatsapp?.accounts?.[whatsappAccountId],
enabled:
next.whatsapp?.accounts?.[whatsappAccountId]?.enabled ?? true,
},
},
},
};
}
options?.onWhatsAppAccountId?.(whatsappAccountId);
whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId);
const { authDir } = resolveWhatsAppAuthDir({
cfg: next,
accountId: whatsappAccountId,
});
if (!whatsappLinked) {
await prompter.note(
[
"Scan the QR with WhatsApp on your phone.",
`Credentials are stored under ${WA_WEB_AUTH_DIR}/ for future runs.`,
`Credentials are stored under ${authDir}/ for future runs.`,
"Docs: https://docs.clawd.bot/whatsapp",
].join("\n"),
"WhatsApp linking",
@@ -570,7 +652,7 @@ export async function setupProviders(
});
if (wantsLink) {
try {
await loginWeb(false, "web");
await loginWeb(false, "web", undefined, runtime, whatsappAccountId);
} catch (err) {
runtime.error(`WhatsApp login failed: ${String(err)}`);
await prompter.note(
+2
View File
@@ -3,7 +3,9 @@ import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type OnboardMode = "local" | "remote";
export type AuthChoice =
| "oauth"
| "claude-cli"
| "openai-codex"
| "codex-cli"
| "antigravity"
| "apiKey"
| "minimax"
+23 -3
View File
@@ -14,6 +14,10 @@ import {
import { callGateway } from "../gateway/call.js";
import { info } from "../globals.js";
import { buildProviderSummary } from "../infra/provider-summary.js";
import {
formatUsageReportLines,
loadProviderUsageSummary,
} from "../infra/provider-usage.js";
import { peekSystemEvents } from "../infra/system-events.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
@@ -66,7 +70,10 @@ export async function getStatusSummary(): Promise<StatusSummary> {
const linked = await webAuthExists(account.authDir);
const authAgeMs = getWebAuthAgeMs(account.authDir);
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
const providerSummary = await buildProviderSummary(cfg);
const providerSummary = await buildProviderSummary(cfg, {
colorize: true,
includeAllowFrom: true,
});
const queuedSystemEvents = peekSystemEvents();
const resolved = resolveConfiguredModelRef({
@@ -215,10 +222,13 @@ const buildFlags = (entry: SessionEntry): string[] => {
};
export async function statusCommand(
opts: { json?: boolean; deep?: boolean; timeoutMs?: number },
opts: { json?: boolean; deep?: boolean; usage?: boolean; timeoutMs?: number },
runtime: RuntimeEnv,
) {
const summary = await getStatusSummary();
const usage = opts.usage
? await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs })
: undefined;
const health: HealthSummary | undefined = opts.deep
? await callGateway<HealthSummary>({
method: "health",
@@ -228,7 +238,11 @@ export async function statusCommand(
if (opts.json) {
runtime.log(
JSON.stringify(health ? { ...summary, health } : summary, null, 2),
JSON.stringify(
health || usage ? { ...summary, health, usage } : summary,
null,
2,
),
);
return;
}
@@ -299,4 +313,10 @@ export async function statusCommand(
} else {
runtime.log("No session activity yet.");
}
if (usage) {
for (const line of formatUsageReportLines(usage)) {
runtime.log(line);
}
}
}
+10
View File
@@ -713,6 +713,16 @@ describe("legacy config detection", () => {
}
});
it("defaults telegram.streamMode to partial when telegram section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ telegram: {} });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.telegram?.streamMode).toBe("partial");
}
});
it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
+3
View File
@@ -107,6 +107,7 @@ const FIELD_LABELS: Record<string, string> = {
"talk.apiKey": "Talk API Key",
"telegram.botToken": "Telegram Bot Token",
"telegram.dmPolicy": "Telegram DM Policy",
"telegram.streamMode": "Telegram Stream Mode",
"whatsapp.dmPolicy": "WhatsApp DM Policy",
"signal.dmPolicy": "Signal DM Policy",
"imessage.dmPolicy": "iMessage DM Policy",
@@ -155,6 +156,8 @@ const FIELD_HELP: Record<string, string> = {
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
"telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].',
"telegram.streamMode":
"Draft streaming mode for Telegram replies (off | partial | block). Requires private topics + sendMessageDraft.",
"whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].',
"signal.dmPolicy":
+8
View File
@@ -115,6 +115,14 @@ export function resolveSessionTranscriptsDir(
return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
}
export function resolveSessionTranscriptsDirForAgent(
agentId?: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
return resolveAgentSessionsDir(agentId, env, homedir);
}
export function resolveDefaultSessionStorePath(agentId?: string): string {
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
}
+55 -6
View File
@@ -236,6 +236,32 @@ export type TelegramActionConfig = {
reactions?: boolean;
};
export type TelegramTopicConfig = {
requireMention?: boolean;
/** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this topic. */
enabled?: boolean;
/** Optional allowlist for topic senders (ids or usernames). */
allowFrom?: Array<string | number>;
/** Optional system prompt snippet for this topic. */
systemPrompt?: string;
};
export type TelegramGroupConfig = {
requireMention?: boolean;
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
skills?: string[];
/** Per-topic configuration (key is message_thread_id as string) */
topics?: Record<string, TelegramTopicConfig>;
/** If false, disable the bot for this group (and its topics). */
enabled?: boolean;
/** Optional allowlist for group senders (ids or usernames). */
allowFrom?: Array<string | number>;
/** Optional system prompt snippet for this group. */
systemPrompt?: string;
};
export type TelegramConfig = {
/**
* Controls how Telegram direct chats (DMs) are handled:
@@ -252,12 +278,7 @@ export type TelegramConfig = {
tokenFile?: string;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
groups?: Record<
string,
{
requireMention?: boolean;
}
>;
groups?: Record<string, TelegramGroupConfig>;
allowFrom?: Array<string | number>;
/** Optional allowlist for Telegram group senders (user ids or usernames). */
groupAllowFrom?: Array<string | number>;
@@ -270,6 +291,8 @@ export type TelegramConfig = {
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
streamMode?: "off" | "partial" | "block";
mediaMaxMb?: number;
proxy?: string;
webhookUrl?: string;
@@ -295,6 +318,14 @@ export type DiscordDmConfig = {
export type DiscordGuildChannelConfig = {
allow?: boolean;
requireMention?: boolean;
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this channel. */
enabled?: boolean;
/** Optional allowlist for channel senders (ids or names). */
users?: Array<string | number>;
/** Optional system prompt snippet for this channel. */
systemPrompt?: string;
};
export type DiscordReactionNotificationMode =
@@ -370,8 +401,18 @@ export type SlackDmConfig = {
};
export type SlackChannelConfig = {
/** If false, disable the bot in this channel. (Alias for allow: false.) */
enabled?: boolean;
/** Legacy channel allow toggle; prefer enabled. */
allow?: boolean;
/** Require mentioning the bot to trigger replies. */
requireMention?: boolean;
/** Allowlist of users that can invoke the bot in this channel. */
users?: Array<string | number>;
/** Optional skill filter for this channel. */
skills?: string[];
/** Optional system prompt for this channel. */
systemPrompt?: string;
};
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
@@ -539,6 +580,7 @@ export type RoutingConfig = {
agents?: Record<
string,
{
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
@@ -923,6 +965,13 @@ export type ClawdbotConfig = {
sandbox?: {
/** Enable sandboxing for sessions. */
mode?: "off" | "non-main" | "all";
/**
* Agent workspace access inside the sandbox.
* - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot
* - "ro": mount the agent workspace read-only; disables write/edit tools
* - "rw": mount the agent workspace read/write; enables write/edit tools
*/
workspaceAccess?: "none" | "ro" | "rw";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
+38
View File
@@ -223,6 +223,7 @@ const RoutingSchema = z
z.string(),
z
.object({
name: z.string().optional(),
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: z.string().optional(),
@@ -578,6 +579,9 @@ export const ClawdbotSchema = z.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
@@ -781,6 +785,26 @@ export const ClawdbotSchema = z.object({
z
.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topics: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z
.array(z.union([z.string(), z.number()]))
.optional(),
systemPrompt: z.string().optional(),
})
.optional(),
)
.optional(),
})
.optional(),
)
@@ -789,6 +813,10 @@ export const ClawdbotSchema = z.object({
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
streamMode: z
.enum(["off", "partial", "block"])
.optional()
.default("partial"),
mediaMaxMb: z.number().positive().optional(),
proxy: z.string().optional(),
webhookUrl: z.string().optional(),
@@ -882,6 +910,12 @@ export const ClawdbotSchema = z.object({
.object({
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
users: z
.array(z.union([z.string(), z.number()]))
.optional(),
systemPrompt: z.string().optional(),
})
.optional(),
)
@@ -951,8 +985,12 @@ export const ClawdbotSchema = z.object({
z.string(),
z
.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.optional(),
)
+36 -1
View File
@@ -2,6 +2,7 @@ import type { Guild } from "@buape/carbon";
import { describe, expect, it } from "vitest";
import {
allowListMatches,
buildDiscordMediaPayload,
type DiscordGuildEntryResolved,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
@@ -95,7 +96,14 @@ describe("discord guild/channel resolution", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
help: { allow: true, requireMention: true },
help: {
allow: true,
requireMention: true,
skills: ["search"],
enabled: false,
users: ["123"],
systemPrompt: "Use short answers.",
},
},
};
const channel = resolveDiscordChannelConfig({
@@ -115,6 +123,10 @@ describe("discord guild/channel resolution", () => {
});
expect(help?.allowed).toBe(true);
expect(help?.requireMention).toBe(true);
expect(help?.skills).toEqual(["search"]);
expect(help?.enabled).toBe(false);
expect(help?.users).toEqual(["123"]);
expect(help?.systemPrompt).toBe("Use short answers.");
});
it("denies channel when config present but no match", () => {
@@ -346,3 +358,26 @@ describe("discord reaction notification gating", () => {
).toBe(true);
});
});
describe("discord media payload", () => {
it("preserves attachment order for MediaPaths/MediaUrls", () => {
const payload = buildDiscordMediaPayload([
{ path: "/tmp/a.png", contentType: "image/png" },
{ path: "/tmp/b.png", contentType: "image/png" },
{ path: "/tmp/c.png", contentType: "image/png" },
]);
expect(payload.MediaPath).toBe("/tmp/a.png");
expect(payload.MediaUrl).toBe("/tmp/a.png");
expect(payload.MediaType).toBe("image/png");
expect(payload.MediaPaths).toEqual([
"/tmp/a.png",
"/tmp/b.png",
"/tmp/c.png",
]);
expect(payload.MediaUrls).toEqual([
"/tmp/a.png",
"/tmp/b.png",
"/tmp/c.png",
]);
});
});
+217 -77
View File
@@ -94,12 +94,26 @@ export type DiscordGuildEntryResolved = {
requireMention?: boolean;
reactionNotifications?: "off" | "own" | "all" | "allowlist";
users?: Array<string | number>;
channels?: Record<string, { allow?: boolean; requireMention?: boolean }>;
channels?: Record<
string,
{
allow?: boolean;
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
}
>;
};
export type DiscordChannelConfigResolved = {
allowed: boolean;
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
};
export type DiscordMessageEvent = Parameters<
@@ -518,6 +532,12 @@ export function createDiscordMessageHandler(params: {
channelSlug,
})
: null;
if (isGuildMessage && channelConfig?.enabled === false) {
logVerbose(
`Blocked discord channel ${message.channelId} (channel disabled)`,
);
return;
}
const groupDmAllowed =
isGroupDm &&
@@ -579,7 +599,7 @@ export function createDiscordMessageHandler(params: {
guildHistories.set(message.channelId, history);
}
const resolvedRequireMention =
const shouldRequireMention =
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
const hasAnyMention = Boolean(
!isDirectMessage &&
@@ -602,13 +622,13 @@ export function createDiscordMessageHandler(params: {
const shouldBypassMention =
allowTextCommands &&
isGuildMessage &&
resolvedRequireMention &&
shouldRequireMention &&
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(baseText);
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
if (isGuildMessage && resolvedRequireMention) {
if (isGuildMessage && shouldRequireMention) {
if (botId && !wasMentioned && !shouldBypassMention) {
logVerbose(
`discord: drop guild message (mention required, botId=${botId})`,
@@ -625,22 +645,17 @@ export function createDiscordMessageHandler(params: {
}
if (isGuildMessage) {
const userAllow = guildInfo?.users;
if (Array.isArray(userAllow) && userAllow.length > 0) {
const users = normalizeDiscordAllowList(userAllow, [
"discord:",
"user:",
]);
const userOk =
!users ||
allowListMatches(users, {
id: author.id,
name: author.username,
tag: formatDiscordUserTag(author),
});
const channelUsers = channelConfig?.users ?? guildInfo?.users;
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
const userOk = resolveDiscordUserAllowed({
allowList: channelUsers,
userId: author.id,
userName: author.username,
userTag: formatDiscordUserTag(author),
});
if (!userOk) {
logVerbose(
`Blocked discord guild sender ${author.id} (not in guild users allowlist)`,
`Blocked discord guild sender ${author.id} (not in channel users allowlist)`,
);
return;
}
@@ -662,12 +677,8 @@ export function createDiscordMessageHandler(params: {
return;
}
const media = await resolveMedia(message, mediaMaxBytes);
const text =
message.content?.trim() ||
media?.placeholder ||
message.embeds?.[0]?.description ||
"";
const mediaList = await resolveMediaList(message, mediaMaxBytes);
const text = baseText;
if (!text) {
logVerbose(`discord: drop message ${message.id} (empty content)`);
return;
@@ -680,7 +691,7 @@ export function createDiscordMessageHandler(params: {
if (ackReactionScope === "group-all") return isGroupChat;
if (ackReactionScope === "group-mentions") {
if (!isGuildMessage) return false;
if (!resolvedRequireMention) return false;
if (!shouldRequireMention) return false;
if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention;
}
@@ -706,6 +717,15 @@ export function createDiscordMessageHandler(params: {
const groupRoom =
isGuildMessage && channelSlug ? `#${channelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupRoom;
const channelDescription = channelInfo?.topic?.trim();
const systemPromptParts = [
channelDescription ? `Channel topic: ${channelDescription}` : null,
channelConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0
? systemPromptParts.join("\n\n")
: undefined;
let combinedBody = formatAgentEnvelope({
provider: "Discord",
from: fromLabel,
@@ -741,6 +761,7 @@ export function createDiscordMessageHandler(params: {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
}
const mediaPayload = buildDiscordMediaPayload(mediaList);
const discordTo = `channel:${message.channelId}`;
const ctxPayload = {
Body: combinedBody,
@@ -758,6 +779,7 @@ export function createDiscordMessageHandler(params: {
SenderTag: formatDiscordUserTag(author),
GroupSubject: groupSubject,
GroupRoom: groupRoom,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage
? (guildInfo?.id ?? guildSlug) || undefined
: undefined,
@@ -766,9 +788,7 @@ export function createDiscordMessageHandler(params: {
WasMentioned: wasMentioned,
MessageSid: message.id,
Timestamp: resolveTimestampMs(message.timestamp),
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
...mediaPayload,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
// Originating channel for reply routing.
@@ -830,7 +850,7 @@ export function createDiscordMessageHandler(params: {
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
replyOptions: { ...replyOptions, skillFilter: channelConfig?.skills },
});
markDispatchIdle();
if (!queuedFinal) {
@@ -1058,13 +1078,27 @@ function createDiscordNativeCommand(params: {
guild: interaction.guild ?? undefined,
guildEntries: cfg.discord?.guilds,
});
if (useAccessGroups && interaction.guild) {
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: channel?.id ?? "",
channelName,
channelSlug,
const channelConfig = interaction.guild
? resolveDiscordChannelConfig({
guildInfo,
channelId: channel?.id ?? "",
channelName,
channelSlug,
})
: null;
if (channelConfig?.enabled === false) {
await interaction.reply({
content: "This channel is disabled.",
});
return;
}
if (interaction.guild && channelConfig?.allowed === false) {
await interaction.reply({
content: "This channel is not allowed.",
});
return;
}
if (useAccessGroups && interaction.guild) {
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) &&
Object.keys(guildInfo?.channels ?? {}).length > 0;
@@ -1143,23 +1177,21 @@ function createDiscordNativeCommand(params: {
commandAuthorized = true;
}
}
if (guildInfo?.users && !isDirectMessage) {
const allowList = normalizeDiscordAllowList(guildInfo.users, [
"discord:",
"user:",
]);
if (
allowList &&
!allowListMatches(allowList, {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
})
) {
await interaction.reply({
content: "You are not authorized to use this command.",
if (!isDirectMessage) {
const channelUsers = channelConfig?.users ?? guildInfo?.users;
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
const userOk = resolveDiscordUserAllowed({
allowList: channelUsers,
userId: user.id,
userName: user.username,
userTag: formatDiscordUserTag(user),
});
return;
if (!userOk) {
await interaction.reply({
content: "You are not authorized to use this command.",
});
return;
}
}
}
if (isGroupDm && cfg.discord?.dm?.groupEnabled === false) {
@@ -1188,6 +1220,24 @@ function createDiscordNativeCommand(params: {
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
GroupSubject: isGuild ? interaction.guild?.name : undefined,
GroupSystemPrompt: isGuild
? (() => {
const channelTopic =
channel && "topic" in channel
? (channel.topic ?? undefined)
: undefined;
const channelDescription = channelTopic?.trim();
const systemPromptParts = [
channelDescription
? `Channel topic: ${channelDescription}`
: null,
channelConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
return systemPromptParts.length > 0
? systemPromptParts.join("\n\n")
: undefined;
})()
: undefined,
SenderName: user.globalName ?? user.username,
SenderId: user.id,
SenderUsername: user.username,
@@ -1218,7 +1268,11 @@ function createDiscordNativeCommand(params: {
},
});
const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg);
const replyResult = await getReplyFromConfig(
ctxPayload,
{ skillFilter: channelConfig?.skills },
cfg,
);
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
@@ -1344,42 +1398,54 @@ async function deliverDiscordReply(params: {
async function resolveDiscordChannelInfo(
client: Client,
channelId: string,
): Promise<{ type: ChannelType; name?: string } | null> {
): Promise<{ type: ChannelType; name?: string; topic?: string } | null> {
try {
const channel = await client.fetchChannel(channelId);
if (!channel) return null;
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
return { type: channel.type, name };
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
return { type: channel.type, name, topic };
} catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
return null;
}
}
async function resolveMedia(
async function resolveMediaList(
message: Message,
maxBytes: number,
): Promise<DiscordMediaInfo | null> {
const attachment = message.attachments?.[0];
if (!attachment) return null;
const res = await fetch(attachment.url);
if (!res.ok) {
throw new Error(
`Failed to download discord attachment: HTTP ${res.status}`,
);
): Promise<DiscordMediaInfo[]> {
const attachments = message.attachments ?? [];
if (attachments.length === 0) return [];
const out: DiscordMediaInfo[] = [];
for (const attachment of attachments) {
try {
const res = await fetch(attachment.url);
if (!res.ok) {
throw new Error(
`Failed to download discord attachment: HTTP ${res.status}`,
);
}
const buffer = Buffer.from(await res.arrayBuffer());
const mime = await detectMime({
buffer,
headerMime: attachment.content_type ?? res.headers.get("content-type"),
filePath: attachment.filename ?? attachment.url,
});
const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(attachment),
});
} catch (err) {
const id = attachment.id ?? attachment.url;
logVerbose(
`discord: failed to download attachment ${id}: ${String(err)}`,
);
}
}
const buffer = Buffer.from(await res.arrayBuffer());
const mime = await detectMime({
buffer,
headerMime: attachment.content_type ?? res.headers.get("content-type"),
filePath: attachment.filename ?? attachment.url,
});
const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(attachment),
};
return out;
}
function inferPlaceholder(attachment: APIAttachment): string {
@@ -1390,20 +1456,64 @@ function inferPlaceholder(attachment: APIAttachment): string {
return "<media:document>";
}
function isImageAttachment(attachment: APIAttachment): boolean {
const mime = attachment.content_type ?? "";
if (mime.startsWith("image/")) return true;
const name = attachment.filename?.toLowerCase() ?? "";
if (!name) return false;
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/.test(name);
}
function buildDiscordAttachmentPlaceholder(
attachments?: APIAttachment[],
): string {
if (!attachments || attachments.length === 0) return "";
const count = attachments.length;
const allImages = attachments.every(isImageAttachment);
const label = allImages ? "image" : "file";
const suffix = count === 1 ? label : `${label}s`;
const tag = allImages ? "<media:image>" : "<media:document>";
return `${tag} (${count} ${suffix})`;
}
function resolveDiscordMessageText(
message: Message,
fallbackText?: string,
): string {
const attachment = message.attachments?.[0];
return (
message.content?.trim() ||
(attachment ? inferPlaceholder(attachment) : "") ||
buildDiscordAttachmentPlaceholder(message.attachments) ||
message.embeds?.[0]?.description ||
fallbackText?.trim() ||
""
);
}
export function buildDiscordMediaPayload(
mediaList: Array<{ path: string; contentType?: string }>,
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList
.map((media) => media.contentType)
.filter(Boolean) as string[];
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
};
}
function resolveReplyContext(message: Message): string | null {
const referenced = message.referencedMessage;
if (!referenced?.author) return null;
@@ -1621,6 +1731,24 @@ export function allowListMatches(
return false;
}
function resolveDiscordUserAllowed(params: {
allowList?: Array<string | number>;
userId: string;
userName?: string;
userTag?: string;
}) {
const allowList = normalizeDiscordAllowList(params.allowList, [
"discord:",
"user:",
]);
if (!allowList) return true;
return allowListMatches(allowList, {
id: params.userId,
name: params.userName,
tag: params.userTag,
});
}
export function resolveDiscordCommandAuthorized(params: {
isDirectMessage: boolean;
allowFrom?: Array<string | number>;
@@ -1672,12 +1800,20 @@ export function resolveDiscordChannelConfig(params: {
return {
allowed: byId.allow !== false,
requireMention: byId.requireMention,
skills: byId.skills,
enabled: byId.enabled,
users: byId.users,
systemPrompt: byId.systemPrompt,
};
if (channelSlug && channels[channelSlug]) {
const entry = channels[channelSlug];
return {
allowed: entry.allow !== false,
requireMention: entry.requireMention,
skills: entry.skills,
enabled: entry.enabled,
users: entry.users,
systemPrompt: entry.systemPrompt,
};
}
if (channelName && channels[channelName]) {
@@ -1685,6 +1821,10 @@ export function resolveDiscordChannelConfig(params: {
return {
allowed: entry.allow !== false,
requireMention: entry.requireMention,
skills: entry.skills,
enabled: entry.enabled,
users: entry.users,
systemPrompt: entry.systemPrompt,
};
}
return { allowed: false };
+1 -1
View File
@@ -446,7 +446,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid reasoningLevel: ${String(raw)}`,
message: `invalid reasoningLevel: ${String(raw)} (use on|off|stream)`,
},
};
}
+2
View File
@@ -17,6 +17,7 @@ import type {
GatewayRequestHandlers,
GatewayRequestOptions,
} from "./server-methods/types.js";
import { usageHandlers } from "./server-methods/usage.js";
import { voicewakeHandlers } from "./server-methods/voicewake.js";
import { webHandlers } from "./server-methods/web.js";
import { wizardHandlers } from "./server-methods/wizard.js";
@@ -38,6 +39,7 @@ const handlers: GatewayRequestHandlers = {
...systemHandlers,
...nodeHandlers,
...sendHandlers,
...usageHandlers,
...agentHandlers,
};
+1 -1
View File
@@ -224,7 +224,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
'invalid reasoningLevel (use "on"|"off")',
'invalid reasoningLevel (use "on"|"off"|"stream")',
),
);
return;
+9
View File
@@ -0,0 +1,9 @@
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
import type { GatewayRequestHandlers } from "./types.js";
export const usageHandlers: GatewayRequestHandlers = {
"usage.status": async ({ respond }) => {
const summary = await loadProviderUsageSummary();
respond(true, summary, undefined);
},
};
+9 -2
View File
@@ -32,7 +32,11 @@ import {
deriveDefaultBridgePort,
deriveDefaultCanvasHostPort,
} from "../config/port-defaults.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import {
loadSessionStore,
resolveMainSessionKey,
resolveStorePath,
} from "../config/sessions.js";
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js";
import { CronService } from "../cron/service.js";
@@ -201,6 +205,7 @@ const METHODS = [
"health",
"providers.status",
"status",
"usage.status",
"config.get",
"config.set",
"config.schema",
@@ -689,7 +694,9 @@ export async function startGatewayServer(
const cron = new CronService({
storePath,
cronEnabled,
enqueueSystemEvent,
enqueueSystemEvent: (text) => {
enqueueSystemEvent(text, { sessionKey: resolveMainSessionKey(cfg) });
},
requestHeartbeatNow,
runIsolatedAgentJob: async ({ job, message }) => {
const runtimeConfig = loadConfig();
+64
View File
@@ -184,6 +184,70 @@ describe("runHeartbeatOnce", () => {
}
});
it("loads the default agent session from templated stores", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storeTemplate = path.join(
tmpDir,
"agents",
"{agentId}",
"sessions.json",
);
const storePath = path.join(tmpDir, "agents", "work", "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:work:main": {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
const cfg: ClawdbotConfig = {
routing: { defaultAgentId: "work" },
agent: { heartbeat: { every: "5m" } },
whatsapp: { allowFrom: ["*"] },
session: { store: storeTemplate },
};
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
await runHeartbeatOnce({
cfg,
deps: {
sendWhatsApp,
getQueueSize: () => 0,
nowMs: () => 0,
webAuthExists: async () => true,
hasActiveWebListener: () => true,
},
});
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
expect(sendWhatsApp).toHaveBeenCalledWith(
"+1555",
"Hello from heartbeat",
expect.any(Object),
);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("respects ackMaxChars for heartbeat acks", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
+5 -3
View File
@@ -11,6 +11,8 @@ import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveMainSessionKey,
resolveStorePath,
type SessionEntry,
saveSessionStore,
@@ -79,9 +81,9 @@ function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) {
function resolveHeartbeatSession(cfg: ClawdbotConfig) {
const sessionCfg = cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
const sessionKey = scope === "global" ? "global" : mainKey;
const storePath = resolveStorePath(sessionCfg?.store);
const sessionKey = scope === "global" ? "global" : resolveMainSessionKey(cfg);
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
return { sessionKey, storePath, store, entry };
+36 -19
View File
@@ -8,45 +8,60 @@ import {
webAuthExists,
} from "../web/session.js";
export type ProviderSummaryOptions = {
colorize?: boolean;
includeAllowFrom?: boolean;
};
const DEFAULT_OPTIONS: Required<ProviderSummaryOptions> = {
colorize: false,
includeAllowFrom: false,
};
export async function buildProviderSummary(
cfg?: ClawdbotConfig,
options?: ProviderSummaryOptions,
): Promise<string[]> {
const effective = cfg ?? loadConfig();
const lines: string[] = [];
const resolved = { ...DEFAULT_OPTIONS, ...options };
const tint = (value: string, color?: (input: string) => string) =>
resolved.colorize && color ? color(value) : value;
const webEnabled = effective.web?.enabled !== false;
if (!webEnabled) {
lines.push(chalk.cyan("WhatsApp: disabled"));
lines.push(tint("WhatsApp: disabled", chalk.cyan));
} else {
const webLinked = await webAuthExists();
const authAgeMs = getWebAuthAgeMs();
const authAge = authAgeMs === null ? "unknown" : formatAge(authAgeMs);
const authAge = authAgeMs === null ? "" : ` auth ${formatAge(authAgeMs)}`;
const { e164 } = readWebSelfId();
lines.push(
webLinked
? chalk.green(
`WhatsApp: linked${e164 ? ` as ${e164}` : ""} (auth ${authAge})`,
? tint(
`WhatsApp: linked${e164 ? ` ${e164}` : ""}${authAge}`,
chalk.green,
)
: chalk.red("WhatsApp: not linked"),
: tint("WhatsApp: not linked", chalk.red),
);
}
const telegramEnabled = effective.telegram?.enabled !== false;
if (!telegramEnabled) {
lines.push(chalk.cyan("Telegram: disabled"));
lines.push(tint("Telegram: disabled", chalk.cyan));
} else {
const { token: telegramToken } = resolveTelegramToken(effective);
const telegramConfigured = Boolean(telegramToken?.trim());
lines.push(
telegramConfigured
? chalk.green("Telegram: configured")
: chalk.cyan("Telegram: not configured"),
? tint("Telegram: configured", chalk.green)
: tint("Telegram: not configured", chalk.cyan),
);
}
const signalEnabled = effective.signal?.enabled !== false;
if (!signalEnabled) {
lines.push(chalk.cyan("Signal: disabled"));
lines.push(tint("Signal: disabled", chalk.cyan));
} else {
const signalConfigured =
Boolean(effective.signal) &&
@@ -60,28 +75,30 @@ export async function buildProviderSummary(
);
lines.push(
signalConfigured
? chalk.green("Signal: configured")
: chalk.cyan("Signal: not configured"),
? tint("Signal: configured", chalk.green)
: tint("Signal: not configured", chalk.cyan),
);
}
const imessageEnabled = effective.imessage?.enabled !== false;
if (!imessageEnabled) {
lines.push(chalk.cyan("iMessage: disabled"));
lines.push(tint("iMessage: disabled", chalk.cyan));
} else {
const imessageConfigured = Boolean(effective.imessage);
lines.push(
imessageConfigured
? chalk.green("iMessage: configured")
: chalk.cyan("iMessage: not configured"),
? tint("iMessage: configured", chalk.green)
: tint("iMessage: not configured", chalk.cyan),
);
}
const allowFrom = effective.whatsapp?.allowFrom?.length
? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean)
: [];
if (allowFrom.length) {
lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`));
if (resolved.includeAllowFrom) {
const allowFrom = effective.whatsapp?.allowFrom?.length
? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean)
: [];
if (allowFrom.length) {
lines.push(tint(`AllowFrom: ${allowFrom.join(", ")}`, chalk.cyan));
}
}
return lines;
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more