Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
491a6e02fb | ||
|
|
2c4c5907bb | ||
|
|
8804a80111 | ||
|
|
7f6b98929f | ||
|
|
d3ae92aaa8 | ||
|
|
53c037a197 | ||
|
|
4bd7ca305a | ||
|
|
3cbced01fa | ||
|
|
45dc4ef3cf | ||
|
|
1601be5480 | ||
|
|
1011640a13 | ||
|
|
25edac96cf | ||
|
|
85e536f3ff | ||
|
|
a5b29623b8 | ||
|
|
eef90b47a3 | ||
|
|
c74f89c871 | ||
|
|
87e08fc7d5 | ||
|
|
43c6bb7595 | ||
|
|
61f720b945 | ||
|
|
9bf6684366 | ||
|
|
4e14123edd | ||
|
|
a700f9896d | ||
|
|
e8420bd047 | ||
|
|
412990a139 | ||
|
|
e58e13708d | ||
|
|
7a917602c5 | ||
|
|
0914517ee3 | ||
|
|
94d3a9742b | ||
|
|
7973fd4caf | ||
|
|
9df8af855b | ||
|
|
a50ffa69b0 | ||
|
|
7d2dde6ea6 | ||
|
|
e5dbe1db9d | ||
|
|
34cac1beb0 | ||
|
|
1b81805d63 | ||
|
|
7176b114da | ||
|
|
0707b1e487 | ||
|
|
2937c4861f | ||
|
|
ff79db0a99 | ||
|
|
8b1263ce11 | ||
|
|
dc941b7e57 | ||
|
|
4f10279ac3 | ||
|
|
f1643a5b8d | ||
|
|
eb8d7a19af |
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+75
-55
@@ -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: {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] } } },
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`
|
||||
: "",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -31,6 +31,7 @@ export type MsgContext = {
|
||||
GroupRoom?: string;
|
||||
GroupSpace?: string;
|
||||
GroupMembers?: string;
|
||||
GroupSystemPrompt?: string;
|
||||
SenderName?: string;
|
||||
SenderId?: string;
|
||||
SenderUsername?: string;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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)`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
Reference in new issue
Block a user