Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c51724f39b | ||
|
|
a70fcc8ae0 | ||
|
|
fa521154ff | ||
|
|
dffc1a4dcd | ||
|
|
61e385b331 | ||
|
|
f8f319713f | ||
|
|
bb665bf22c | ||
|
|
f17dcb6213 | ||
|
|
d54c101100 | ||
|
|
96daa51d45 | ||
|
|
6084421ec6 | ||
|
|
bca5c0d569 | ||
|
|
8c3cdba21c | ||
|
|
e274b5a040 | ||
|
|
a139d35fa2 | ||
|
|
8312a19f02 | ||
|
|
fe8b28cdd9 | ||
|
|
6146acbb69 |
No files matched your search
+13
-2
@@ -2,7 +2,14 @@
|
||||
|
||||
## 2026.1.15 (unreleased)
|
||||
|
||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||
- TUI: show provider/model labels for the active session and default model.
|
||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
@@ -10,14 +17,17 @@
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.1.14-1
|
||||
@@ -32,8 +42,9 @@
|
||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
|
||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
|
||||
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
|
||||
- Security: add `SECURITY.md` reporting policy.
|
||||
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: 95ad7532c1...c1243a7978.
+88
-26
@@ -27,17 +27,17 @@ Minimal config:
|
||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
4) **Event Subscriptions** → enable events and subscribe to:
|
||||
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
|
||||
5) **Event Subscriptions** → enable events and subscribe to:
|
||||
- `message.*` (includes edits/deletes/thread broadcasts)
|
||||
- `app_mention`
|
||||
- `reaction_added`, `reaction_removed`
|
||||
- `member_joined_channel`, `member_left_channel`
|
||||
- `channel_id_changed`
|
||||
- `channel_rename`
|
||||
- `pin_added`, `pin_removed`
|
||||
5) Invite the bot to channels you want it to read.
|
||||
6) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
|
||||
7) App Home → enable the **Messages Tab** so users can DM the bot.
|
||||
6) Invite the bot to channels you want it to read.
|
||||
7) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
|
||||
8) App Home → enable the **Messages Tab** so users can DM the bot.
|
||||
|
||||
Use the manifest below so scopes and events stay in sync.
|
||||
|
||||
@@ -63,27 +63,59 @@ Or via config:
|
||||
}
|
||||
```
|
||||
|
||||
## History context
|
||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
|
||||
## User token (optional)
|
||||
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
||||
pins, reactions, emoji, member info). By default this stays read-only: reads
|
||||
prefer the user token when present, and writes still use the bot token unless
|
||||
you explicitly opt in. Even with `userTokenReadOnly: false`, the bot token stays
|
||||
preferred for writes when it is available.
|
||||
|
||||
## Config writes
|
||||
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
|
||||
User tokens are configured in the config file (no env var support). For
|
||||
multi-account, set `channels.slack.accounts.<id>.userToken`.
|
||||
|
||||
This happens when:
|
||||
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
|
||||
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
Example with bot + app + user tokens:
|
||||
```json5
|
||||
{
|
||||
channels: { slack: { configWrites: false } }
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
appToken: "xapp-...",
|
||||
botToken: "xoxb-...",
|
||||
userToken: "xoxp-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example with userTokenReadOnly explicitly set (allow user token writes):
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
appToken: "xapp-...",
|
||||
botToken: "xoxb-...",
|
||||
userToken: "xoxp-...",
|
||||
userTokenReadOnly: false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Token usage
|
||||
- Read operations (history, reactions list, pins list, emoji list, member info,
|
||||
search) prefer the user token when configured, otherwise the bot token.
|
||||
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||
no bot token is available, Clawdbot falls back to the user token.
|
||||
|
||||
## History context
|
||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
|
||||
## Manifest (optional)
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||
user scopes if you plan to configure a user token.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -133,6 +165,21 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
|
||||
"commands",
|
||||
"files:read",
|
||||
"files:write"
|
||||
],
|
||||
"user": [
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
"im:read",
|
||||
"mpim:history",
|
||||
"mpim:read",
|
||||
"users:read",
|
||||
"reactions:read",
|
||||
"pins:read",
|
||||
"emoji:read",
|
||||
"search:read"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -149,7 +196,6 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
|
||||
"reaction_removed",
|
||||
"member_joined_channel",
|
||||
"member_left_channel",
|
||||
"channel_id_changed",
|
||||
"channel_rename",
|
||||
"pin_added",
|
||||
"pin_removed"
|
||||
@@ -166,7 +212,7 @@ Slack's Conversations API is type-scoped: you only need the scopes for the
|
||||
conversation types you actually touch (channels, groups, im, mpim). See
|
||||
https://api.channels.slack.com/docs/conversations-api for the overview.
|
||||
|
||||
### Required scopes
|
||||
### Bot token scopes (required)
|
||||
- `chat:write` (send/update/delete messages via `chat.postMessage`)
|
||||
https://api.channels.slack.com/methods/chat.postMessage
|
||||
- `im:write` (open DMs via `conversations.open` for user DMs)
|
||||
@@ -188,6 +234,17 @@ https://api.channels.slack.com/docs/conversations-api for the overview.
|
||||
- `files:write` (uploads via `files.uploadV2`)
|
||||
https://api.channels.slack.com/messaging/files/uploading
|
||||
|
||||
### User token scopes (optional, read-only by default)
|
||||
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
||||
|
||||
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
||||
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
|
||||
- `users:read`
|
||||
- `reactions:read`
|
||||
- `pins:read`
|
||||
- `emoji:read`
|
||||
- `search:read`
|
||||
|
||||
### Not needed today (but likely future)
|
||||
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
|
||||
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
|
||||
@@ -269,11 +326,6 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
|
||||
|
||||
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||
|
||||
### Thread session isolation
|
||||
Slack thread sessions are isolated by default. Configure with:
|
||||
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
|
||||
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
|
||||
|
||||
### Manual threading tags
|
||||
For fine-grained control, use these tags in agent responses:
|
||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||
@@ -320,6 +372,17 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
|
||||
| memberInfo | enabled | Member info |
|
||||
| emojiList | enabled | Custom emoji list |
|
||||
|
||||
## Security notes
|
||||
- Writes default to the bot token so state-changing actions stay scoped to the
|
||||
app's bot permissions and identity.
|
||||
- Setting `userTokenReadOnly: false` allows the user token to be used for write
|
||||
operations when a bot token is unavailable, which means actions run with the
|
||||
installing user's access. Treat the user token as highly privileged and keep
|
||||
action gates and allowlists tight.
|
||||
- If you enable user-token writes, make sure the user token includes the write
|
||||
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
|
||||
`files:write`) or those operations will fail.
|
||||
|
||||
## Notes
|
||||
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
|
||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||
@@ -327,5 +390,4 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
|
||||
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
||||
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
|
||||
- Read/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Slack `ts`.
|
||||
- Attachments are downloaded to the media store when permitted and under the size limit.
|
||||
@@ -188,6 +188,7 @@ Disable with:
|
||||
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.
|
||||
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
|
||||
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
||||
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
||||
|
||||
|
||||
@@ -296,8 +296,9 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
|
||||
## Heartbeats
|
||||
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
||||
- **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session.
|
||||
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior.
|
||||
- **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally
|
||||
via `agents.defaults.heartbeat` (fallback when no per-agent entries are set).
|
||||
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`) + `HEARTBEAT_OK` skip behavior.
|
||||
- Delivery defaults to the last used channel (or configured target).
|
||||
|
||||
## Reconnect behavior
|
||||
@@ -330,6 +331,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
- `agents.defaults.heartbeat.model` (optional override)
|
||||
- `agents.defaults.heartbeat.target`
|
||||
- `agents.defaults.heartbeat.to`
|
||||
- `agents.list[].heartbeat.*` (per-agent overrides)
|
||||
- `session.*` (scope, idle, store, mainKey)
|
||||
- `web.enabled` (disable channel startup when false)
|
||||
- `web.heartbeatSeconds`
|
||||
|
||||
@@ -11,10 +11,12 @@ Interactive prompt to set up credentials, devices, and agent defaults.
|
||||
Related:
|
||||
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
||||
|
||||
Notes:
|
||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot configure
|
||||
clawdbot configure --section models --section channels
|
||||
```
|
||||
|
||||
+7
-1
@@ -22,6 +22,10 @@ clawdbot models set <model-or-alias>
|
||||
clawdbot models scan
|
||||
```
|
||||
|
||||
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
|
||||
When provider usage snapshots are available, the OAuth/token status section includes
|
||||
provider usage headers.
|
||||
|
||||
## Aliases + fallbacks
|
||||
|
||||
```bash
|
||||
@@ -33,7 +37,9 @@ clawdbot models fallbacks list
|
||||
|
||||
```bash
|
||||
clawdbot models auth add
|
||||
clawdbot models auth login --provider <id>
|
||||
clawdbot models auth setup-token
|
||||
clawdbot models auth paste-token
|
||||
```
|
||||
|
||||
`models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use
|
||||
`clawdbot plugins list` to see which providers are installed.
|
||||
@@ -48,6 +48,17 @@ If no explicit order is configured, Clawdbot uses a round‑robin order:
|
||||
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
|
||||
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
|
||||
|
||||
### Session stickiness (cache-friendly)
|
||||
|
||||
Clawdbot **pins the chosen auth profile per session** to keep provider caches warm.
|
||||
It does **not** rotate on every request. The pinned profile is reused until:
|
||||
- the session is reset (`/new` / `/reset`)
|
||||
- a compaction completes (compaction count increments)
|
||||
- the profile is in cooldown/disabled
|
||||
|
||||
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
||||
and is not auto‑rotated until a new session starts.
|
||||
|
||||
### Why OAuth can “look lost”
|
||||
|
||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||
|
||||
@@ -15,6 +15,13 @@ Clawdbot supports “subscription auth” via OAuth for providers that offer it
|
||||
- how we **reuse external CLI tokens** (Claude Code / Codex CLI)
|
||||
- how to handle **multiple accounts** (profiles + per-session overrides)
|
||||
|
||||
Clawdbot also supports **provider plugins** that ship their own OAuth or API‑key
|
||||
flows. Run them via:
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider <id>
|
||||
```
|
||||
|
||||
## The token sink (why it exists)
|
||||
|
||||
OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app.
|
||||
|
||||
@@ -26,6 +26,22 @@ The prompt is intentionally compact and uses fixed sections:
|
||||
- **Runtime**: host, OS, node, model, thinking level (one line).
|
||||
- **Reasoning**: current visibility level + /reasoning toggle hint.
|
||||
|
||||
## Prompt modes
|
||||
|
||||
Clawdbot can render smaller system prompts for sub-agents. The runtime sets a
|
||||
`promptMode` for each run (not a user-facing config):
|
||||
|
||||
- `full` (default): includes all sections above.
|
||||
- `minimal`: used for sub-agents; omits **Skills**, **Memory Recall**, **Clawdbot
|
||||
Self-Update**, **Model Aliases**, **User Identity**, **Reply Tags**,
|
||||
**Messaging**, **Silent Replies**, and **Heartbeats**. Tooling, Workspace,
|
||||
Sandbox, Current Date & Time (when known), Runtime, and injected context stay
|
||||
available.
|
||||
- `none`: returns only the base identity line.
|
||||
|
||||
When `promptMode=minimal`, extra injected prompts are labeled **Subagent
|
||||
Context** instead of **Group Chat Context**.
|
||||
|
||||
## Workspace bootstrap injection
|
||||
|
||||
Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads:
|
||||
|
||||
@@ -44,7 +44,7 @@ Common methods + events:
|
||||
| --- | --- | --- |
|
||||
| Core | `connect`, `health`, `status` | `connect` must be first |
|
||||
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
|
||||
| Chat | `chat.history`, `chat.send`, `chat.abort` | WebChat uses these |
|
||||
| Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these |
|
||||
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
|
||||
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
|
||||
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
|
||||
|
||||
@@ -11,7 +11,7 @@ read_when:
|
||||
- No estimated costs; only the provider-reported windows.
|
||||
|
||||
## Where it shows up
|
||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available.
|
||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
|
||||
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
|
||||
@@ -455,6 +455,44 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
}
|
||||
```
|
||||
|
||||
### Anthropic subscription + API key, MiniMax fallback
|
||||
```json5
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:subscription": {
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
email: "user@example.com"
|
||||
},
|
||||
"anthropic:api": {
|
||||
provider: "anthropic",
|
||||
mode: "api_key"
|
||||
}
|
||||
},
|
||||
order: {
|
||||
anthropic: ["anthropic:subscription", "anthropic:api"]
|
||||
}
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
apiKey: "${MINIMAX_API_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
agent: {
|
||||
workspace: "~/clawd",
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-5",
|
||||
fallbacks: ["minimax/MiniMax-M2.1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Work bot (restricted access)
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -1675,9 +1675,14 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
|
||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
||||
|
||||
Per-agent heartbeats:
|
||||
- Set `agents.list[].heartbeat` to enable or override heartbeat settings for a specific agent.
|
||||
- If any agent entry defines `heartbeat`, **only those agents** run heartbeats; defaults
|
||||
become the shared baseline for those agents.
|
||||
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
|
||||
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ Example config:
|
||||
|
||||
## Defaults
|
||||
|
||||
- Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable).
|
||||
- Interval: `30m` (set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable).
|
||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
|
||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
||||
prompt includes a “Heartbeat” section and the run is flagged internally.
|
||||
|
||||
@@ -49,8 +49,8 @@ The default prompt is intentionally broad:
|
||||
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
|
||||
|
||||
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
|
||||
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` to a
|
||||
custom body (sent verbatim).
|
||||
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` (or
|
||||
`agents.list[].heartbeat.prompt`) to a custom body (sent verbatim).
|
||||
|
||||
## Response contract
|
||||
|
||||
@@ -77,7 +77,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
||||
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
||||
to: "+15551234567", // optional channel-specific override
|
||||
prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.",
|
||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,39 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
}
|
||||
```
|
||||
|
||||
### Per-agent heartbeats
|
||||
|
||||
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
|
||||
run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat`
|
||||
(so you can set shared defaults once and override per agent).
|
||||
|
||||
Example: two agents, only the second agent runs heartbeats.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "30m",
|
||||
target: "last"
|
||||
}
|
||||
},
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: "ops",
|
||||
heartbeat: {
|
||||
every: "1h",
|
||||
target: "whatsapp",
|
||||
to: "+15551234567",
|
||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field notes
|
||||
|
||||
- `every`: heartbeat interval (duration string; default unit = minutes).
|
||||
@@ -100,7 +133,8 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
|
||||
## Delivery behavior
|
||||
|
||||
- Heartbeats run in the **main session** (`main`, or `global` when scope is global).
|
||||
- Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global`
|
||||
when `session.scope = "global"`.
|
||||
- If the main queue is busy, the heartbeat is skipped and retried later.
|
||||
- If `target` resolves to no external destination, the run still happens but no
|
||||
outbound message is sent.
|
||||
@@ -149,6 +183,9 @@ You can enqueue a system event and trigger an immediate heartbeat with:
|
||||
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
||||
```
|
||||
|
||||
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
||||
agent heartbeats immediately.
|
||||
|
||||
Use `--mode next-heartbeat` to wait for the next scheduled tick.
|
||||
|
||||
## Reasoning delivery (optional)
|
||||
|
||||
@@ -24,8 +24,8 @@ agent (with a session switcher for other sessions).
|
||||
|
||||
## How it’s wired
|
||||
|
||||
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort` and
|
||||
events `chat`, `agent`, `presence`, `tick`, `health`.
|
||||
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`,
|
||||
`chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`.
|
||||
- Session: defaults to the primary session (`main`, or `global` when scope is
|
||||
global). The UI can switch between sessions.
|
||||
- Onboarding uses a dedicated session to keep first‑run setup separate.
|
||||
|
||||
@@ -172,6 +172,56 @@ Plugins export either:
|
||||
- A function: `(api) => { ... }`
|
||||
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
||||
|
||||
## Provider plugins (model auth)
|
||||
|
||||
Plugins can register **model provider auth** flows so users can run OAuth or
|
||||
API-key setup inside Clawdbot (no external scripts needed).
|
||||
|
||||
Register a provider via `api.registerProvider(...)`. Each provider exposes one
|
||||
or more auth methods (OAuth, API key, device code, etc.). These methods power:
|
||||
|
||||
- `clawdbot models auth login --provider <id> [--method <id>]`
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
api.registerProvider({
|
||||
id: "acme",
|
||||
label: "AcmeAI",
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "OAuth",
|
||||
kind: "oauth",
|
||||
run: async (ctx) => {
|
||||
// Run OAuth flow and return auth profiles.
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: "acme:default",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "acme",
|
||||
access: "...",
|
||||
refresh: "...",
|
||||
expires: Date.now() + 3600 * 1000,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: "acme/opus-1",
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `run` receives a `ProviderAuthContext` with `prompter`, `runtime`,
|
||||
`openUrl`, and `oauth.createVpsAwareHandlers` helpers.
|
||||
- Return `configPatch` when you need to add default models or provider config.
|
||||
- Return `defaultModel` so `--set-default` can update agent defaults.
|
||||
|
||||
### Register a messaging channel
|
||||
|
||||
Plugins can register **channel plugins** that behave like built‑in channels
|
||||
|
||||
@@ -63,7 +63,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
|
||||
7) **GitHub release + appcast**
|
||||
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**.
|
||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
|
||||
- [ ] Attach artifacts: `npm pack` tarball (optional), `Clawdbot-X.Y.Z.zip`, and `Clawdbot-X.Y.Z.dSYM.zip` (if generated).
|
||||
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
|
||||
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
||||
|
||||
@@ -108,7 +108,7 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
|
||||
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
||||
|
||||
Default heartbeat prompt:
|
||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
|
||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ Example:
|
||||
## Heartbeats (proactive mode)
|
||||
|
||||
By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
||||
|
||||
@@ -54,7 +54,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
||||
Text + native (when enabled):
|
||||
- `/help`
|
||||
- `/commands`
|
||||
- `/status` (show current status; includes a short provider usage/quota line when available)
|
||||
- `/status` (show current status; includes provider usage/quota when available, plus OAuth/token status block when OAuth profiles exist)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/usage` (alias: `/status`)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
|
||||
@@ -50,8 +50,8 @@ read_when:
|
||||
- Elevated mode docs live in [Elevated mode](/tools/elevated).
|
||||
|
||||
## Heartbeats
|
||||
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true`.
|
||||
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.list[].heartbeat.includeReasoning: true`.
|
||||
|
||||
## Web chat UI
|
||||
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
|
||||
|
||||
@@ -28,7 +28,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
|
||||
The onboarding wizard generates a gateway token by default, so paste it here on first connect.
|
||||
|
||||
## What it can do (today)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
@@ -60,6 +60,7 @@ Notes:
|
||||
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
|
||||
- Stop:
|
||||
- Click **Stop** (calls `chat.abort`)
|
||||
- Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
3) Ensure gateway auth is configured if you are not on loopback.
|
||||
|
||||
## How it works (behavior)
|
||||
- The UI connects to the Gateway WebSocket and uses `chat.history` + `chat.send`.
|
||||
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
||||
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
|
||||
- History is always fetched from the gateway (no local file watching).
|
||||
- If the gateway is unreachable, WebChat is read-only.
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type ResolvedAgentConfig = {
|
||||
model?: AgentEntry["model"];
|
||||
memorySearch?: AgentEntry["memorySearch"];
|
||||
humanDelay?: AgentEntry["humanDelay"];
|
||||
heartbeat?: AgentEntry["heartbeat"];
|
||||
identity?: AgentEntry["identity"];
|
||||
groupChat?: AgentEntry["groupChat"];
|
||||
subagents?: AgentEntry["subagents"];
|
||||
@@ -89,6 +90,7 @@ export function resolveAgentConfig(
|
||||
: undefined,
|
||||
memorySearch: entry.memorySearch,
|
||||
humanDelay: entry.humanDelay,
|
||||
heartbeat: entry.heartbeat,
|
||||
identity: entry.identity,
|
||||
groupChat: entry.groupChat,
|
||||
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CliBackendConfig } from "../config/types.js";
|
||||
import { runCliAgent } from "./cli-runner.js";
|
||||
import { cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js";
|
||||
|
||||
const runCommandWithTimeoutMock = vi.fn();
|
||||
const runExecMock = vi.fn();
|
||||
@@ -17,7 +19,12 @@ describe("runCliAgent resume cleanup", () => {
|
||||
});
|
||||
|
||||
it("kills stale resume processes for codex sessions", async () => {
|
||||
runExecMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
runExecMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: " 1 S /bin/launchd\n",
|
||||
stderr: "",
|
||||
}) // cleanupSuspendedCliProcesses (ps)
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" }); // cleanupResumeProcesses (pkill)
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
@@ -43,13 +50,103 @@ describe("runCliAgent resume cleanup", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).toHaveBeenCalledTimes(1);
|
||||
const args = runExecMock.mock.calls[0] ?? [];
|
||||
expect(args[0]).toBe("pkill");
|
||||
const pkillArgs = args[1] as string[];
|
||||
expect(runExecMock).toHaveBeenCalledTimes(2);
|
||||
const pkillCall = runExecMock.mock.calls[1] ?? [];
|
||||
expect(pkillCall[0]).toBe("pkill");
|
||||
const pkillArgs = pkillCall[1] as string[];
|
||||
expect(pkillArgs[0]).toBe("-f");
|
||||
expect(pkillArgs[1]).toContain("codex");
|
||||
expect(pkillArgs[1]).toContain("resume");
|
||||
expect(pkillArgs[1]).toContain("thread-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupSuspendedCliProcesses", () => {
|
||||
beforeEach(() => {
|
||||
runExecMock.mockReset();
|
||||
});
|
||||
|
||||
it("skips when no session tokens are configured", async () => {
|
||||
await cleanupSuspendedCliProcesses(
|
||||
{
|
||||
command: "tool",
|
||||
} as CliBackendConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches sessionArg-based commands", async () => {
|
||||
runExecMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: [
|
||||
" 40 T+ claude --session-id thread-1 -p",
|
||||
" 41 S claude --session-id thread-2 -p",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
|
||||
await cleanupSuspendedCliProcesses(
|
||||
{
|
||||
command: "claude",
|
||||
sessionArg: "--session-id",
|
||||
} as CliBackendConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).toHaveBeenCalledTimes(2);
|
||||
const killCall = runExecMock.mock.calls[1] ?? [];
|
||||
expect(killCall[0]).toBe("kill");
|
||||
expect(killCall[1]).toEqual(["-9", "40"]);
|
||||
});
|
||||
|
||||
it("matches resumeArgs with positional session id", async () => {
|
||||
runExecMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: [
|
||||
" 50 T codex exec resume thread-99 --color never --sandbox read-only",
|
||||
" 51 T codex exec resume other --color never --sandbox read-only",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
|
||||
await cleanupSuspendedCliProcesses(
|
||||
{
|
||||
command: "codex",
|
||||
resumeArgs: [
|
||||
"exec",
|
||||
"resume",
|
||||
"{sessionId}",
|
||||
"--color",
|
||||
"never",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
],
|
||||
} as CliBackendConfig,
|
||||
1,
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).toHaveBeenCalledTimes(2);
|
||||
const killCall = runExecMock.mock.calls[1] ?? [];
|
||||
expect(killCall[0]).toBe("kill");
|
||||
expect(killCall[1]).toEqual(["-9", "50", "51"]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
buildCliArgs,
|
||||
buildSystemPrompt,
|
||||
cleanupResumeProcesses,
|
||||
cleanupSuspendedCliProcesses,
|
||||
enqueueCliRun,
|
||||
normalizeCliModel,
|
||||
parseCliJson,
|
||||
@@ -206,6 +207,7 @@ export async function runCliAgent(params: {
|
||||
return next;
|
||||
})();
|
||||
|
||||
await cleanupSuspendedCliProcesses(backend);
|
||||
if (useResume && cliSessionIdToSend) {
|
||||
await cleanupResumeProcesses(backend, cliSessionIdToSend);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,82 @@ export async function cleanupResumeProcesses(
|
||||
}
|
||||
}
|
||||
|
||||
function buildSessionMatchers(backend: CliBackendConfig): RegExp[] {
|
||||
const commandToken = path.basename(backend.command ?? "").trim();
|
||||
if (!commandToken) return [];
|
||||
const matchers: RegExp[] = [];
|
||||
const sessionArg = backend.sessionArg?.trim();
|
||||
const sessionArgs = backend.sessionArgs ?? [];
|
||||
const resumeArgs = backend.resumeArgs ?? [];
|
||||
|
||||
const addMatcher = (args: string[]) => {
|
||||
if (args.length === 0) return;
|
||||
const tokens = [commandToken, ...args];
|
||||
const pattern = tokens
|
||||
.map((token, index) => {
|
||||
const tokenPattern = tokenToRegex(token);
|
||||
return index === 0 ? `(?:^|\\s)${tokenPattern}` : `\\s+${tokenPattern}`;
|
||||
})
|
||||
.join("");
|
||||
matchers.push(new RegExp(pattern));
|
||||
};
|
||||
|
||||
if (sessionArgs.some((arg) => arg.includes("{sessionId}"))) {
|
||||
addMatcher(sessionArgs);
|
||||
} else if (sessionArg) {
|
||||
addMatcher([sessionArg, "{sessionId}"]);
|
||||
}
|
||||
|
||||
if (resumeArgs.some((arg) => arg.includes("{sessionId}"))) {
|
||||
addMatcher(resumeArgs);
|
||||
}
|
||||
|
||||
return matchers;
|
||||
}
|
||||
|
||||
function tokenToRegex(token: string): string {
|
||||
if (!token.includes("{sessionId}")) return escapeRegex(token);
|
||||
const parts = token.split("{sessionId}").map((part) => escapeRegex(part));
|
||||
return parts.join("\\S+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup suspended Clawdbot CLI processes that have accumulated.
|
||||
* Only cleans up if there are more than the threshold (default: 10).
|
||||
*/
|
||||
export async function cleanupSuspendedCliProcesses(
|
||||
backend: CliBackendConfig,
|
||||
threshold = 10,
|
||||
): Promise<void> {
|
||||
if (process.platform === "win32") return;
|
||||
const matchers = buildSessionMatchers(backend);
|
||||
if (matchers.length === 0) return;
|
||||
|
||||
try {
|
||||
const { stdout } = await runExec("ps", ["-ax", "-o", "pid=,stat=,command="]);
|
||||
const suspended: number[] = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = /^(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed);
|
||||
if (!match) continue;
|
||||
const pid = Number(match[1]);
|
||||
const stat = match[2] ?? "";
|
||||
const command = match[3] ?? "";
|
||||
if (!Number.isFinite(pid)) continue;
|
||||
if (!stat.includes("T")) continue;
|
||||
if (!matchers.some((matcher) => matcher.test(command))) continue;
|
||||
suspended.push(pid);
|
||||
}
|
||||
|
||||
if (suspended.length > threshold) {
|
||||
// Verified locally: stopped (T) processes ignore SIGTERM, so use SIGKILL.
|
||||
await runExec("kill", ["-9", ...suspended.map((pid) => String(pid))]);
|
||||
}
|
||||
} catch {
|
||||
// ignore errors - best effort cleanup
|
||||
}
|
||||
}
|
||||
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
|
||||
const chained = prior.catch(() => undefined).then(task);
|
||||
|
||||
+8
@@ -45,6 +45,14 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../skills.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../skills.js")>();
|
||||
return {
|
||||
...actual,
|
||||
syncSkillsToWorkspace: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Agent-specific sandbox config", () => {
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
|
||||
+8
@@ -46,6 +46,14 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../skills.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../skills.js")>();
|
||||
return {
|
||||
...actual,
|
||||
syncSkillsToWorkspace: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Agent-specific sandbox config", () => {
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
|
||||
@@ -123,4 +123,54 @@ describe("subagent registry persistence", () => {
|
||||
);
|
||||
expect(match).toBeFalsy();
|
||||
});
|
||||
|
||||
it("retries cleanup announce after a failed announce", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
||||
|
||||
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
|
||||
const persisted = {
|
||||
version: 1,
|
||||
runs: {
|
||||
"run-3": {
|
||||
runId: "run-3",
|
||||
childSessionKey: "agent:main:subagent:three",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "retry announce",
|
||||
cleanup: "keep",
|
||||
createdAt: 1,
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.mkdir(path.dirname(registryPath), { recursive: true });
|
||||
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
|
||||
|
||||
announceSpy.mockResolvedValueOnce(false);
|
||||
vi.resetModules();
|
||||
const mod1 = await import("./subagent-registry.js");
|
||||
mod1.initSubagentRegistry();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(announceSpy).toHaveBeenCalledTimes(1);
|
||||
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
|
||||
runs: Record<string, { cleanupHandled?: boolean; cleanupCompletedAt?: number }>;
|
||||
};
|
||||
expect(afterFirst.runs["run-3"].cleanupHandled).toBe(false);
|
||||
expect(afterFirst.runs["run-3"].cleanupCompletedAt).toBeUndefined();
|
||||
|
||||
announceSpy.mockResolvedValueOnce(true);
|
||||
vi.resetModules();
|
||||
const mod2 = await import("./subagent-registry.js");
|
||||
mod2.initSubagentRegistry();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(announceSpy).toHaveBeenCalledTimes(2);
|
||||
const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
|
||||
runs: Record<string, { cleanupCompletedAt?: number }>;
|
||||
};
|
||||
expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,30 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).not.toContain("Owner numbers:");
|
||||
});
|
||||
|
||||
it("omits extended sections in minimal prompt mode", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
promptMode: "minimal",
|
||||
ownerNumbers: ["+123"],
|
||||
skillsPrompt:
|
||||
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
||||
heartbeatPrompt: "ping",
|
||||
toolNames: ["message", "memory_search"],
|
||||
extraSystemPrompt: "Subagent details",
|
||||
});
|
||||
|
||||
expect(prompt).not.toContain("## User Identity");
|
||||
expect(prompt).not.toContain("## Skills");
|
||||
expect(prompt).not.toContain("## Memory Recall");
|
||||
expect(prompt).not.toContain("## Reply Tags");
|
||||
expect(prompt).not.toContain("## Messaging");
|
||||
expect(prompt).not.toContain("## Silent Replies");
|
||||
expect(prompt).not.toContain("## Heartbeats");
|
||||
expect(prompt).toContain("## Subagent Context");
|
||||
expect(prompt).not.toContain("## Group Chat Context");
|
||||
expect(prompt).toContain("Subagent details");
|
||||
});
|
||||
|
||||
it("adds reasoning tag hint when enabled", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
|
||||
+120
-74
@@ -12,6 +12,105 @@ import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||
*/
|
||||
export type PromptMode = "full" | "minimal" | "none";
|
||||
|
||||
function buildSkillsSection(params: {
|
||||
skillsPrompt?: string;
|
||||
isMinimal: boolean;
|
||||
readToolName: string;
|
||||
}) {
|
||||
const trimmed = params.skillsPrompt?.trim();
|
||||
if (!trimmed || params.isMinimal) return [];
|
||||
return [
|
||||
"## Skills",
|
||||
`Skills provide task-specific instructions. Use \`${params.readToolName}\` to load the SKILL.md at the location listed for that skill.`,
|
||||
trimmed,
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function buildMemorySection(params: {
|
||||
isMinimal: boolean;
|
||||
availableTools: Set<string>;
|
||||
}) {
|
||||
if (params.isMinimal) return [];
|
||||
if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
"## Memory Recall",
|
||||
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
|
||||
if (!ownerLine || isMinimal) return [];
|
||||
return ["## User Identity", ownerLine, ""];
|
||||
}
|
||||
|
||||
function buildTimeSection(params: {
|
||||
userTimezone?: string;
|
||||
userTime?: string;
|
||||
userTimeFormat?: ResolvedTimeFormat;
|
||||
}) {
|
||||
if (!params.userTimezone && !params.userTime) return [];
|
||||
return [
|
||||
"## Current Date & Time",
|
||||
params.userTime
|
||||
? `${params.userTime} (${params.userTimezone ?? "unknown"})`
|
||||
: `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`,
|
||||
params.userTimeFormat
|
||||
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
|
||||
: "",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function buildReplyTagsSection(isMinimal: boolean) {
|
||||
if (isMinimal) return [];
|
||||
return [
|
||||
"## Reply Tags",
|
||||
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
|
||||
"- [[reply_to_current]] replies to the triggering message.",
|
||||
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
|
||||
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
|
||||
"Tags are stripped before sending; support depends on the current channel config.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function buildMessagingSection(params: {
|
||||
isMinimal: boolean;
|
||||
availableTools: Set<string>;
|
||||
messageChannelOptions: string;
|
||||
inlineButtonsEnabled: boolean;
|
||||
runtimeChannel?: string;
|
||||
}) {
|
||||
if (params.isMinimal) return [];
|
||||
return [
|
||||
"## Messaging",
|
||||
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
||||
"- Cross-session messaging → use sessions_send(sessionKey, message)",
|
||||
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
|
||||
params.availableTools.has("message")
|
||||
? [
|
||||
"",
|
||||
"### message tool",
|
||||
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||
"- For `action=send`, include `to` and `message`.",
|
||||
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
||||
params.inlineButtonsEnabled
|
||||
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||
: params.runtimeChannel
|
||||
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to add "inlineButtons" to ${params.runtimeChannel}.capabilities or ${params.runtimeChannel}.accounts.<id>.capabilities.`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: "",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAgentSystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
defaultThinkLevel?: ThinkLevel;
|
||||
@@ -118,6 +217,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
|
||||
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
|
||||
const canonicalToolNames = rawToolNames.filter(Boolean);
|
||||
// Preserve caller casing while deduping tool names by lowercase.
|
||||
const canonicalByNormalized = new Map<string, string>();
|
||||
for (const name of canonicalToolNames) {
|
||||
const normalized = name.toLowerCase();
|
||||
@@ -191,26 +291,12 @@ export function buildAgentSystemPrompt(params: {
|
||||
const messageChannelOptions = listDeliverableMessageChannels().join("|");
|
||||
const promptMode = params.promptMode ?? "full";
|
||||
const isMinimal = promptMode === "minimal" || promptMode === "none";
|
||||
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
|
||||
// Skip skills section for subagent/none modes
|
||||
const skillsSection =
|
||||
skillsPrompt && !isMinimal
|
||||
? [
|
||||
"## Skills",
|
||||
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
|
||||
...skillsLines,
|
||||
"",
|
||||
]
|
||||
: [];
|
||||
// Skip memory section for subagent/none modes
|
||||
const memorySection =
|
||||
!isMinimal && (availableTools.has("memory_search") || availableTools.has("memory_get"))
|
||||
? [
|
||||
"## Memory Recall",
|
||||
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
|
||||
"",
|
||||
]
|
||||
: [];
|
||||
const skillsSection = buildSkillsSection({
|
||||
skillsPrompt,
|
||||
isMinimal,
|
||||
readToolName,
|
||||
});
|
||||
const memorySection = buildMemorySection({ isMinimal, availableTools });
|
||||
|
||||
// For "none" mode, return just the basic identity line
|
||||
if (promptMode === "none") {
|
||||
@@ -335,63 +421,23 @@ export function buildAgentSystemPrompt(params: {
|
||||
.join("\n")
|
||||
: "",
|
||||
params.sandboxInfo?.enabled ? "" : "",
|
||||
// Skip user identity for subagent/none modes
|
||||
ownerLine && !isMinimal ? "## User Identity" : "",
|
||||
ownerLine && !isMinimal ? ownerLine : "",
|
||||
ownerLine && !isMinimal ? "" : "",
|
||||
...(userTimezone || userTime
|
||||
? [
|
||||
"## Current Date & Time",
|
||||
userTime
|
||||
? `${userTime} (${userTimezone ?? "unknown"})`
|
||||
: `Time zone: ${userTimezone}. Current time unknown; assume UTC for date/time references.`,
|
||||
params.userTimeFormat
|
||||
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
|
||||
: "",
|
||||
"",
|
||||
]
|
||||
: []),
|
||||
...buildUserIdentitySection(ownerLine, isMinimal),
|
||||
...buildTimeSection({
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat: params.userTimeFormat,
|
||||
}),
|
||||
"## Workspace Files (injected)",
|
||||
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
|
||||
"",
|
||||
// Skip reply tags for subagent/none modes
|
||||
...(isMinimal
|
||||
? []
|
||||
: [
|
||||
"## Reply Tags",
|
||||
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
|
||||
"- [[reply_to_current]] replies to the triggering message.",
|
||||
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
|
||||
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
|
||||
"Tags are stripped before sending; support depends on the current channel config.",
|
||||
"",
|
||||
]),
|
||||
// Skip messaging section for subagent/none modes
|
||||
...(isMinimal
|
||||
? []
|
||||
: [
|
||||
"## Messaging",
|
||||
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
||||
"- Cross-session messaging → use sessions_send(sessionKey, message)",
|
||||
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
|
||||
availableTools.has("message")
|
||||
? [
|
||||
"",
|
||||
"### message tool",
|
||||
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||
"- For `action=send`, include `to` and `message`.",
|
||||
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
|
||||
inlineButtonsEnabled
|
||||
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||
: runtimeChannel
|
||||
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: "",
|
||||
"",
|
||||
]),
|
||||
...buildReplyTagsSection(isMinimal),
|
||||
...buildMessagingSection({
|
||||
isMinimal,
|
||||
availableTools,
|
||||
messageChannelOptions,
|
||||
inlineButtonsEnabled,
|
||||
runtimeChannel,
|
||||
}),
|
||||
];
|
||||
|
||||
if (extraSystemPrompt) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
||||
import { buildAuthHealthSummary, formatRemainingShort } from "../../agents/auth-health.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
@@ -28,9 +29,10 @@ import {
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import {
|
||||
formatUsageSummaryLine,
|
||||
formatUsageWindowSummary,
|
||||
loadProviderUsageSummary,
|
||||
resolveUsageProviderId,
|
||||
type UsageProviderId,
|
||||
} from "../../infra/provider-usage.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
@@ -257,10 +259,14 @@ export function createSessionStatusTool(opts?: {
|
||||
delete nextEntry.providerOverride;
|
||||
delete nextEntry.modelOverride;
|
||||
delete nextEntry.authProfileOverride;
|
||||
delete nextEntry.authProfileOverrideSource;
|
||||
delete nextEntry.authProfileOverrideCompactionCount;
|
||||
} else {
|
||||
nextEntry.providerOverride = selection.provider;
|
||||
nextEntry.modelOverride = selection.model;
|
||||
delete nextEntry.authProfileOverride;
|
||||
delete nextEntry.authProfileOverrideSource;
|
||||
delete nextEntry.authProfileOverrideCompactionCount;
|
||||
}
|
||||
store[resolved.key] = nextEntry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
@@ -277,24 +283,48 @@ export function createSessionStatusTool(opts?: {
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
||||
const usageProvider = resolveUsageProviderId(providerForCard);
|
||||
let usageLine: string | undefined;
|
||||
if (usageProvider) {
|
||||
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const authHealth = buildAuthHealthSummary({
|
||||
store: authStore,
|
||||
cfg,
|
||||
});
|
||||
const oauthProfiles = authHealth.profiles.filter(
|
||||
(profile) => profile.type === "oauth" || profile.type === "token",
|
||||
);
|
||||
|
||||
const usageProviders = Array.from(
|
||||
new Set(
|
||||
oauthProfiles
|
||||
.map((profile) => resolveUsageProviderId(profile.provider))
|
||||
.filter((provider): provider is UsageProviderId => Boolean(provider)),
|
||||
),
|
||||
);
|
||||
const usageByProvider = new Map<string, string>();
|
||||
if (usageProviders.length > 0) {
|
||||
try {
|
||||
const usageSummary = await loadProviderUsageSummary({
|
||||
timeoutMs: 3500,
|
||||
providers: [usageProvider],
|
||||
providers: usageProviders,
|
||||
agentDir,
|
||||
});
|
||||
const formatted = formatUsageSummaryLine(usageSummary, {
|
||||
now: Date.now(),
|
||||
});
|
||||
if (formatted) usageLine = formatted;
|
||||
for (const snapshot of usageSummary.providers) {
|
||||
const formatted = formatUsageWindowSummary(snapshot, {
|
||||
now: Date.now(),
|
||||
maxWindows: 2,
|
||||
});
|
||||
if (formatted) usageByProvider.set(snapshot.provider, formatted);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const usageProvider = resolveUsageProviderId(providerForCard);
|
||||
const usageLine =
|
||||
oauthProfiles.length === 0 && usageProvider && usageByProvider.has(usageProvider)
|
||||
? `📊 Usage: ${usageByProvider.get(usageProvider)}`
|
||||
: undefined;
|
||||
|
||||
const isGroup =
|
||||
resolved.entry.chatType === "group" ||
|
||||
resolved.entry.chatType === "room" ||
|
||||
@@ -340,13 +370,53 @@ export function createSessionStatusTool(opts?: {
|
||||
includeTranscriptUsage: false,
|
||||
});
|
||||
|
||||
const authStatusLines = (() => {
|
||||
if (oauthProfiles.length === 0) return [];
|
||||
const formatStatus = (status: string) => {
|
||||
if (status === "ok") return "ok";
|
||||
if (status === "static") return "static";
|
||||
if (status === "expiring") return "expiring";
|
||||
if (status === "missing") return "unknown";
|
||||
return "expired";
|
||||
};
|
||||
const profilesByProvider = new Map<string, typeof oauthProfiles>();
|
||||
for (const profile of oauthProfiles) {
|
||||
const current = profilesByProvider.get(profile.provider);
|
||||
if (current) current.push(profile);
|
||||
else profilesByProvider.set(profile.provider, [profile]);
|
||||
}
|
||||
const lines: string[] = ["OAuth/token status"];
|
||||
for (const [provider, profiles] of profilesByProvider) {
|
||||
const usageKey = resolveUsageProviderId(provider);
|
||||
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
|
||||
const usageSuffix = usage ? ` — usage: ${usage}` : "";
|
||||
lines.push(`- ${provider}${usageSuffix}`);
|
||||
for (const profile of profiles) {
|
||||
const labelText = profile.label || profile.profileId;
|
||||
const status = formatStatus(profile.status);
|
||||
const expiry =
|
||||
profile.status === "static"
|
||||
? ""
|
||||
: profile.expiresAt
|
||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||
: " expires unknown";
|
||||
const source = profile.source !== "store" ? ` (${profile.source})` : "";
|
||||
lines.push(` - ${labelText} ${status}${expiry}${source}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
})();
|
||||
|
||||
const fullStatusText =
|
||||
authStatusLines.length > 0 ? `${statusText}\n\n${authStatusLines.join("\n")}` : statusText;
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: statusText }],
|
||||
content: [{ type: "text", text: fullStatusText }],
|
||||
details: {
|
||||
ok: true,
|
||||
sessionKey: resolved.key,
|
||||
changedModel,
|
||||
statusText,
|
||||
statusText: fullStatusText,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -361,4 +361,48 @@ describe("handleSlackAction", () => {
|
||||
expect(payload.pins[0].message?.timestampMs).toBe(expectedMs);
|
||||
expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("uses user token for reads when available", async () => {
|
||||
const cfg = {
|
||||
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
|
||||
} as ClawdbotConfig;
|
||||
readSlackMessages.mockClear();
|
||||
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
|
||||
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
|
||||
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
|
||||
expect(opts?.token).toBe("xoxp-1");
|
||||
});
|
||||
|
||||
it("falls back to bot token for reads when user token missing", async () => {
|
||||
const cfg = {
|
||||
channels: { slack: { botToken: "xoxb-1" } },
|
||||
} as ClawdbotConfig;
|
||||
readSlackMessages.mockClear();
|
||||
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
|
||||
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
|
||||
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
|
||||
expect(opts?.token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses bot token for writes when userTokenReadOnly is true", async () => {
|
||||
const cfg = {
|
||||
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
|
||||
} as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
|
||||
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
|
||||
expect(opts?.token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows user token writes when bot token is missing", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: { userToken: "xoxp-1", userTokenReadOnly: false },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
|
||||
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
|
||||
expect(opts?.token).toBe("xoxp-1");
|
||||
});
|
||||
});
|
||||
@@ -78,10 +78,32 @@ export async function handleSlackAction(
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
const accountOpts = accountId ? { accountId } : undefined;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
|
||||
const isActionEnabled = createActionGate(actionConfig);
|
||||
const userToken = account.config.userToken?.trim() || undefined;
|
||||
const botToken = account.botToken?.trim();
|
||||
const allowUserWrites = account.config.userTokenReadOnly === false;
|
||||
|
||||
// Choose the most appropriate token for Slack read/write operations.
|
||||
const getTokenForOperation = (operation: "read" | "write") => {
|
||||
if (operation === "read") return userToken ?? botToken;
|
||||
if (!allowUserWrites) return botToken;
|
||||
return botToken ?? userToken;
|
||||
};
|
||||
|
||||
const buildActionOpts = (operation: "read" | "write") => {
|
||||
const token = getTokenForOperation(operation);
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
if (!accountId && !tokenOverride) return undefined;
|
||||
return {
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const readOpts = buildActionOpts("read");
|
||||
const writeOpts = buildActionOpts("write");
|
||||
|
||||
if (reactionsActions.has(action)) {
|
||||
if (!isActionEnabled("reactions")) {
|
||||
@@ -94,28 +116,28 @@ export async function handleSlackAction(
|
||||
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
|
||||
});
|
||||
if (remove) {
|
||||
if (accountOpts) {
|
||||
await removeSlackReaction(channelId, messageId, emoji, accountOpts);
|
||||
if (writeOpts) {
|
||||
await removeSlackReaction(channelId, messageId, emoji, writeOpts);
|
||||
} else {
|
||||
await removeSlackReaction(channelId, messageId, emoji);
|
||||
}
|
||||
return jsonResult({ ok: true, removed: emoji });
|
||||
}
|
||||
if (isEmpty) {
|
||||
const removed = accountOpts
|
||||
? await removeOwnSlackReactions(channelId, messageId, accountOpts)
|
||||
const removed = writeOpts
|
||||
? await removeOwnSlackReactions(channelId, messageId, writeOpts)
|
||||
: await removeOwnSlackReactions(channelId, messageId);
|
||||
return jsonResult({ ok: true, removed });
|
||||
}
|
||||
if (accountOpts) {
|
||||
await reactSlackMessage(channelId, messageId, emoji, accountOpts);
|
||||
if (writeOpts) {
|
||||
await reactSlackMessage(channelId, messageId, emoji, writeOpts);
|
||||
} else {
|
||||
await reactSlackMessage(channelId, messageId, emoji);
|
||||
}
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
}
|
||||
const reactions = accountOpts
|
||||
? await listSlackReactions(channelId, messageId, accountOpts)
|
||||
const reactions = readOpts
|
||||
? await listSlackReactions(channelId, messageId, readOpts)
|
||||
: await listSlackReactions(channelId, messageId);
|
||||
return jsonResult({ ok: true, reactions });
|
||||
}
|
||||
@@ -135,7 +157,7 @@ export async function handleSlackAction(
|
||||
context,
|
||||
);
|
||||
const result = await sendSlackMessage(to, content, {
|
||||
accountId: accountId ?? undefined,
|
||||
...writeOpts,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
threadTs: threadTs ?? undefined,
|
||||
});
|
||||
@@ -162,8 +184,8 @@ export async function handleSlackAction(
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
if (accountOpts) {
|
||||
await editSlackMessage(channelId, messageId, content, accountOpts);
|
||||
if (writeOpts) {
|
||||
await editSlackMessage(channelId, messageId, content, writeOpts);
|
||||
} else {
|
||||
await editSlackMessage(channelId, messageId, content);
|
||||
}
|
||||
@@ -176,8 +198,8 @@ export async function handleSlackAction(
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
if (accountOpts) {
|
||||
await deleteSlackMessage(channelId, messageId, accountOpts);
|
||||
if (writeOpts) {
|
||||
await deleteSlackMessage(channelId, messageId, writeOpts);
|
||||
} else {
|
||||
await deleteSlackMessage(channelId, messageId);
|
||||
}
|
||||
@@ -193,7 +215,7 @@ export async function handleSlackAction(
|
||||
const before = readStringParam(params, "before");
|
||||
const after = readStringParam(params, "after");
|
||||
const result = await readSlackMessages(channelId, {
|
||||
accountId: accountId ?? undefined,
|
||||
...readOpts,
|
||||
limit,
|
||||
before: before ?? undefined,
|
||||
after: after ?? undefined,
|
||||
@@ -220,8 +242,8 @@ export async function handleSlackAction(
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
if (accountOpts) {
|
||||
await pinSlackMessage(channelId, messageId, accountOpts);
|
||||
if (writeOpts) {
|
||||
await pinSlackMessage(channelId, messageId, writeOpts);
|
||||
} else {
|
||||
await pinSlackMessage(channelId, messageId);
|
||||
}
|
||||
@@ -231,15 +253,15 @@ export async function handleSlackAction(
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
if (accountOpts) {
|
||||
await unpinSlackMessage(channelId, messageId, accountOpts);
|
||||
if (writeOpts) {
|
||||
await unpinSlackMessage(channelId, messageId, writeOpts);
|
||||
} else {
|
||||
await unpinSlackMessage(channelId, messageId);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
const pins = accountOpts
|
||||
? await listSlackPins(channelId, accountOpts)
|
||||
const pins = writeOpts
|
||||
? await listSlackPins(channelId, readOpts)
|
||||
: await listSlackPins(channelId);
|
||||
const normalizedPins = pins.map((pin) => {
|
||||
const message = pin.message
|
||||
@@ -258,8 +280,8 @@ export async function handleSlackAction(
|
||||
throw new Error("Slack member info is disabled.");
|
||||
}
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
const info = accountOpts
|
||||
? await getSlackMemberInfo(userId, accountOpts)
|
||||
const info = writeOpts
|
||||
? await getSlackMemberInfo(userId, readOpts)
|
||||
: await getSlackMemberInfo(userId);
|
||||
return jsonResult({ ok: true, info });
|
||||
}
|
||||
@@ -268,7 +290,7 @@ export async function handleSlackAction(
|
||||
if (!isActionEnabled("emojiList")) {
|
||||
throw new Error("Slack emoji list is disabled.");
|
||||
}
|
||||
const emojis = accountOpts ? await listSlackEmojis(accountOpts) : await listSlackEmojis();
|
||||
const emojis = readOpts ? await listSlackEmojis(readOpts) : await listSlackEmojis();
|
||||
return jsonResult({ ok: true, emojis });
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,12 @@ Add whatever else you want the assistant to know about your local toolchain.
|
||||
|
||||
const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md
|
||||
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
Optional: keep a tiny checklist for heartbeat runs.
|
||||
|
||||
Guidance (to avoid nagging):
|
||||
- Only report items that are truly new or changed.
|
||||
- Do not invent tasks from old chat context.
|
||||
- If nothing needs attention, reply HEARTBEAT_OK.
|
||||
`;
|
||||
|
||||
const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
// Default heartbeat prompt (used when config.agents.defaults.heartbeat.prompt is unset).
|
||||
// Keep it tight and avoid encouraging the model to invent/rehash "open loops" from prior chat context.
|
||||
export const HEARTBEAT_PROMPT =
|
||||
"Consider outstanding tasks and HEARTBEAT.md guidance from the workspace context (if present). Checkup sometimes on your human during (user local) day time.";
|
||||
"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.";
|
||||
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
||||
|
||||
|
||||
@@ -264,8 +264,12 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
if (profileOverride) {
|
||||
sessionEntry.authProfileOverride = profileOverride;
|
||||
sessionEntry.authProfileOverrideSource = "user";
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
} else if (directives.hasModelDirective) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
delete sessionEntry.authProfileOverrideSource;
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
}
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
|
||||
@@ -156,8 +156,12 @@ export async function persistInlineDirectives(params: {
|
||||
}
|
||||
if (profileOverride) {
|
||||
sessionEntry.authProfileOverride = profileOverride;
|
||||
sessionEntry.authProfileOverrideSource = "user";
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
} else if (directives.hasModelDirective) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
delete sessionEntry.authProfileOverrideSource;
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
}
|
||||
provider = resolved.ref.provider;
|
||||
model = resolved.ref.model;
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
isEmbeddedPiRunStreaming,
|
||||
resolveEmbeddedSessionLane,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
@@ -97,6 +102,86 @@ type RunPreparedReplyParams = {
|
||||
abortedLastRun: boolean;
|
||||
};
|
||||
|
||||
async function resolveSessionAuthProfileOverride(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
provider: string;
|
||||
agentDir: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
isNewSession: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
const {
|
||||
cfg,
|
||||
provider,
|
||||
agentDir,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
isNewSession,
|
||||
} = params;
|
||||
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||
if (order.length === 0) return sessionEntry.authProfileOverride;
|
||||
|
||||
const pickFirstAvailable = () =>
|
||||
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
|
||||
const pickNextAvailable = (current: string) => {
|
||||
const startIndex = order.indexOf(current);
|
||||
if (startIndex < 0) return pickFirstAvailable();
|
||||
for (let offset = 1; offset <= order.length; offset += 1) {
|
||||
const candidate = order[(startIndex + offset) % order.length];
|
||||
if (!isProfileInCooldown(store, candidate)) return candidate;
|
||||
}
|
||||
return order[startIndex] ?? order[0];
|
||||
};
|
||||
|
||||
const compactionCount = sessionEntry.compactionCount ?? 0;
|
||||
const storedCompaction =
|
||||
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||
? sessionEntry.authProfileOverrideCompactionCount
|
||||
: compactionCount;
|
||||
|
||||
let current = sessionEntry.authProfileOverride?.trim();
|
||||
if (current && !order.includes(current)) current = undefined;
|
||||
|
||||
const source = sessionEntry.authProfileOverrideSource ?? (current ? "user" : undefined);
|
||||
if (source === "user" && current && !isNewSession) {
|
||||
return current;
|
||||
}
|
||||
|
||||
let next = current;
|
||||
if (isNewSession) {
|
||||
next = current ? pickNextAvailable(current) : pickFirstAvailable();
|
||||
} else if (current && compactionCount > storedCompaction) {
|
||||
next = pickNextAvailable(current);
|
||||
} else if (!current || isProfileInCooldown(store, current)) {
|
||||
next = pickFirstAvailable();
|
||||
}
|
||||
|
||||
if (!next) return current;
|
||||
const shouldPersist =
|
||||
next !== sessionEntry.authProfileOverride ||
|
||||
sessionEntry.authProfileOverrideSource !== "auto" ||
|
||||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
|
||||
if (shouldPersist) {
|
||||
sessionEntry.authProfileOverride = next;
|
||||
sessionEntry.authProfileOverrideSource = "auto";
|
||||
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function runPreparedReply(
|
||||
params: RunPreparedReplyParams,
|
||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
@@ -314,7 +399,16 @@ export async function runPreparedReply(
|
||||
resolvedQueue.mode === "followup" ||
|
||||
resolvedQueue.mode === "collect" ||
|
||||
resolvedQueue.mode === "steer-backlog";
|
||||
const authProfileId = sessionEntry?.authProfileOverride;
|
||||
const authProfileId = await resolveSessionAuthProfileOverride({
|
||||
cfg,
|
||||
provider,
|
||||
agentDir,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
isNewSession,
|
||||
});
|
||||
const followupRun = {
|
||||
prompt: queuedBody,
|
||||
messageId: sessionCtx.MessageSid,
|
||||
|
||||
@@ -217,6 +217,8 @@ export async function createModelSelectionState(params: {
|
||||
const profile = store.profiles[sessionEntry.authProfileOverride];
|
||||
if (!profile || profile.provider !== provider) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
delete sessionEntry.authProfileOverrideSource;
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SetupChannelsOptions = {
|
||||
promptWhatsAppAccountId?: boolean;
|
||||
onWhatsAppAccountId?: (accountId: string) => void;
|
||||
forceAllowFromChannels?: ChannelId[];
|
||||
skipStatusNote?: boolean;
|
||||
skipDmPolicyPrompt?: boolean;
|
||||
skipConfirm?: boolean;
|
||||
quickstartDefaults?: boolean;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js";
|
||||
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
listEnabledSlackAccounts,
|
||||
listSlackAccountIds,
|
||||
type ResolvedSlackAccount,
|
||||
resolveDefaultSlackAccountId,
|
||||
@@ -21,11 +25,23 @@ import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import { createSlackActions } from "./slack.actions.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
|
||||
|
||||
const meta = getChatChannelMeta("slack");
|
||||
|
||||
// Select the appropriate Slack token for read/write operations.
|
||||
function getTokenForOperation(
|
||||
account: ResolvedSlackAccount,
|
||||
operation: "read" | "write",
|
||||
): string | undefined {
|
||||
const userToken = account.config.userToken?.trim() || undefined;
|
||||
const botToken = account.botToken?.trim();
|
||||
const allowUserWrites = account.config.userTokenReadOnly === false;
|
||||
if (operation === "read") return userToken ?? botToken;
|
||||
if (!allowUserWrites) return botToken;
|
||||
return botToken ?? userToken;
|
||||
}
|
||||
|
||||
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
id: "slack",
|
||||
meta: {
|
||||
@@ -36,7 +52,21 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
idLabel: "slackUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
const cfg = loadConfig();
|
||||
const account = resolveSlackAccount({
|
||||
cfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
if (tokenOverride) {
|
||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, {
|
||||
token: tokenOverride,
|
||||
});
|
||||
} else {
|
||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
}
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -139,7 +169,197 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
messaging: {
|
||||
normalizeTarget: normalizeSlackMessagingTarget,
|
||||
},
|
||||
actions: createSlackActions(meta.id),
|
||||
actions: {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listEnabledSlackAccounts(cfg).filter(
|
||||
(account) => account.botTokenSource !== "none",
|
||||
);
|
||||
if (accounts.length === 0) return [];
|
||||
const isActionEnabled = (key: string, defaultValue = true) => {
|
||||
for (const account of accounts) {
|
||||
const gate = createActionGate(
|
||||
(account.actions ?? cfg.channels?.slack?.actions) as Record<
|
||||
string,
|
||||
boolean | undefined
|
||||
>,
|
||||
);
|
||||
if (gate(key, defaultValue)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
if (isActionEnabled("reactions")) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
if (isActionEnabled("messages")) {
|
||||
actions.add("read");
|
||||
actions.add("edit");
|
||||
actions.add("delete");
|
||||
}
|
||||
if (isActionEnabled("pins")) {
|
||||
actions.add("pin");
|
||||
actions.add("unpin");
|
||||
actions.add("list-pins");
|
||||
}
|
||||
if (isActionEnabled("memberInfo")) actions.add("member-info");
|
||||
if (isActionEnabled("emojiList")) actions.add("emoji-list");
|
||||
return Array.from(actions);
|
||||
},
|
||||
extractToolSend: ({ args }) => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action !== "sendMessage") return null;
|
||||
const to = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!to) return null;
|
||||
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||
return { to, accountId };
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
const resolveChannelId = () =>
|
||||
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true });
|
||||
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "message", {
|
||||
required: true,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to,
|
||||
content,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
threadTs: threadId ?? replyTo ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
toolContext,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "react",
|
||||
channelId: resolveChannelId(),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "reactions",
|
||||
channelId: resolveChannelId(),
|
||||
messageId,
|
||||
limit,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "read") {
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "readMessages",
|
||||
channelId: resolveChannelId(),
|
||||
limit,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(params, "message", { required: true });
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
channelId: resolveChannelId(),
|
||||
messageId,
|
||||
content,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
channelId: resolveChannelId(),
|
||||
messageId,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "pin" || action === "unpin" || action === "list-pins") {
|
||||
const messageId =
|
||||
action === "list-pins"
|
||||
? undefined
|
||||
: readStringParam(params, "messageId", { required: true });
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action:
|
||||
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||
channelId: resolveChannelId(),
|
||||
messageId,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "member-info") {
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
return await handleSlackAction(
|
||||
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "emoji-list") {
|
||||
return await handleSlackAction(
|
||||
{ action: "emojiList", accountId: accountId ?? undefined },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${meta.id}.`);
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
@@ -225,20 +445,30 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
const result = await send(to, text, {
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
const result = await send(to, text, {
|
||||
mediaUrl,
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
|
||||
+30
-7
@@ -4,13 +4,14 @@ import {
|
||||
githubCopilotLoginCommand,
|
||||
modelsAliasesAddCommand,
|
||||
modelsAliasesListCommand,
|
||||
modelsAliasesRemoveCommand,
|
||||
modelsAuthAddCommand,
|
||||
modelsAuthOrderClearCommand,
|
||||
modelsAuthOrderGetCommand,
|
||||
modelsAuthOrderSetCommand,
|
||||
modelsAuthPasteTokenCommand,
|
||||
modelsAuthSetupTokenCommand,
|
||||
modelsAliasesRemoveCommand,
|
||||
modelsAuthAddCommand,
|
||||
modelsAuthLoginCommand,
|
||||
modelsAuthOrderClearCommand,
|
||||
modelsAuthOrderGetCommand,
|
||||
modelsAuthOrderSetCommand,
|
||||
modelsAuthPasteTokenCommand,
|
||||
modelsAuthSetupTokenCommand,
|
||||
modelsFallbacksAddCommand,
|
||||
modelsFallbacksClearCommand,
|
||||
modelsFallbacksListCommand,
|
||||
@@ -309,6 +310,28 @@ export function registerModelsCli(program: Command) {
|
||||
}
|
||||
});
|
||||
|
||||
auth
|
||||
.command("login")
|
||||
.description("Run a provider plugin auth flow (OAuth/API key)")
|
||||
.option("--provider <id>", "Provider id registered by a plugin")
|
||||
.option("--method <id>", "Provider auth method id")
|
||||
.option("--set-default", "Apply the provider's default model recommendation", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsAuthLoginCommand(
|
||||
{
|
||||
provider: opts.provider as string | undefined,
|
||||
method: opts.method as string | undefined,
|
||||
setDefault: Boolean(opts.setDefault),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
auth
|
||||
.command("setup-token")
|
||||
.description("Run a provider CLI to create/sync a token (TTY required)")
|
||||
|
||||
@@ -49,6 +49,9 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
||||
` origin: ${plugin.origin}`,
|
||||
];
|
||||
if (plugin.version) parts.push(` version: ${plugin.version}`);
|
||||
if (plugin.providerIds.length > 0) {
|
||||
parts.push(` providers: ${plugin.providerIds.join(", ")}`);
|
||||
}
|
||||
if (plugin.error) parts.push(chalk.red(` error: ${plugin.error}`));
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -138,6 +141,9 @@ export function registerPluginsCli(program: Command) {
|
||||
if (plugin.gatewayMethods.length > 0) {
|
||||
lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`);
|
||||
}
|
||||
if (plugin.providerIds.length > 0) {
|
||||
lines.push(`Providers: ${plugin.providerIds.join(", ")}`);
|
||||
}
|
||||
if (plugin.cliCommands.length > 0) {
|
||||
lines.push(`CLI commands: ${plugin.cliCommands.join(", ")}`);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,19 @@ import { autoMigrateLegacyState } from "../../infra/state-migrations.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { emitCliBanner } from "../banner.js";
|
||||
|
||||
function setProcessTitleForCommand(actionCommand: Command) {
|
||||
let current: Command = actionCommand;
|
||||
while (current.parent && current.parent.parent) {
|
||||
current = current.parent;
|
||||
}
|
||||
const name = current.name();
|
||||
if (!name || name === "clawdbot") return;
|
||||
process.title = `clawdbot-${name}`;
|
||||
}
|
||||
|
||||
export function registerPreActionHooks(program: Command, programVersion: string) {
|
||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||
setProcessTitleForCommand(actionCommand);
|
||||
emitCliBanner(programVersion);
|
||||
if (actionCommand.name() === "doctor") return;
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
|
||||
@@ -286,6 +286,8 @@ export async function agentCommand(
|
||||
const profile = store.profiles[authProfileId];
|
||||
if (!profile || profile.provider !== provider) {
|
||||
delete entry.authProfileOverride;
|
||||
delete entry.authProfileOverrideSource;
|
||||
delete entry.authProfileOverrideCompactionCount;
|
||||
entry.updatedAt = Date.now();
|
||||
if (sessionStore && sessionKey) {
|
||||
sessionStore[sessionKey] = entry;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
clackIntro: vi.fn(),
|
||||
clackOutro: vi.fn(),
|
||||
clackSelect: vi.fn(),
|
||||
clackText: vi.fn(),
|
||||
clackConfirm: vi.fn(),
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
writeConfigFile: vi.fn(),
|
||||
resolveGatewayPort: vi.fn(),
|
||||
ensureControlUiAssetsBuilt: vi.fn(),
|
||||
createClackPrompter: vi.fn(),
|
||||
note: vi.fn(),
|
||||
printWizardHeader: vi.fn(),
|
||||
probeGatewayReachable: vi.fn(),
|
||||
waitForGatewayReachable: vi.fn(),
|
||||
resolveControlUiLinks: vi.fn(),
|
||||
summarizeExistingConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
intro: mocks.clackIntro,
|
||||
outro: mocks.clackOutro,
|
||||
select: mocks.clackSelect,
|
||||
text: mocks.clackText,
|
||||
confirm: mocks.clackConfirm,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
CONFIG_PATH_CLAWDBOT: "~/.clawdbot/clawdbot.json",
|
||||
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
|
||||
writeConfigFile: mocks.writeConfigFile,
|
||||
resolveGatewayPort: mocks.resolveGatewayPort,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/control-ui-assets.js", () => ({
|
||||
ensureControlUiAssetsBuilt: mocks.ensureControlUiAssetsBuilt,
|
||||
}));
|
||||
|
||||
vi.mock("../wizard/clack-prompter.js", () => ({
|
||||
createClackPrompter: mocks.createClackPrompter,
|
||||
}));
|
||||
|
||||
vi.mock("../terminal/note.js", () => ({
|
||||
note: mocks.note,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", () => ({
|
||||
DEFAULT_WORKSPACE: "~/.clawdbot/workspace",
|
||||
applyWizardMetadata: (cfg: ClawdbotConfig) => cfg,
|
||||
ensureWorkspaceAndSessions: vi.fn(),
|
||||
guardCancel: <T>(value: T) => value,
|
||||
printWizardHeader: mocks.printWizardHeader,
|
||||
probeGatewayReachable: mocks.probeGatewayReachable,
|
||||
resolveControlUiLinks: mocks.resolveControlUiLinks,
|
||||
summarizeExistingConfig: mocks.summarizeExistingConfig,
|
||||
waitForGatewayReachable: mocks.waitForGatewayReachable,
|
||||
}));
|
||||
|
||||
vi.mock("./health.js", () => ({
|
||||
healthCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./health-format.js", () => ({
|
||||
formatHealthCheckFailure: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./configure.gateway.js", () => ({
|
||||
promptGatewayConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./configure.gateway-auth.js", () => ({
|
||||
promptAuthConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./configure.channels.js", () => ({
|
||||
removeChannelConfigWizard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./configure.daemon.js", () => ({
|
||||
maybeInstallDaemon: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-remote.js", () => ({
|
||||
promptRemoteGatewayConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-skills.js", () => ({
|
||||
setupSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-channels.js", () => ({
|
||||
setupChannels: vi.fn(),
|
||||
}));
|
||||
|
||||
import { runConfigureWizard } from "./configure.wizard.js";
|
||||
|
||||
describe("runConfigureWizard", () => {
|
||||
it("persists gateway.mode=local when only the run mode is selected", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
valid: true,
|
||||
config: {},
|
||||
issues: [],
|
||||
});
|
||||
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
|
||||
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
|
||||
mocks.summarizeExistingConfig.mockReturnValue("");
|
||||
mocks.createClackPrompter.mockReturnValue({});
|
||||
|
||||
const selectQueue = ["local", "__continue"];
|
||||
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
|
||||
mocks.clackIntro.mockResolvedValue(undefined);
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
mocks.clackText.mockResolvedValue("");
|
||||
mocks.clackConfirm.mockResolvedValue(false);
|
||||
|
||||
await runConfigureWizard({ command: "configure" }, {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gateway: expect.objectContaining({ mode: "local" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "./configure.shared.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
import { setupChannels } from "./onboard-channels.js";
|
||||
import { noteChannelStatus, setupChannels } from "./onboard-channels.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
DEFAULT_WORKSPACE,
|
||||
@@ -256,6 +256,17 @@ export async function runConfigureWizard(
|
||||
}
|
||||
|
||||
let nextConfig = { ...baseConfig };
|
||||
let didSetGatewayMode = false;
|
||||
if (nextConfig.gateway?.mode !== "local") {
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
gateway: {
|
||||
...nextConfig.gateway,
|
||||
mode: "local",
|
||||
},
|
||||
};
|
||||
didSetGatewayMode = true;
|
||||
}
|
||||
let workspaceDir =
|
||||
nextConfig.agents?.defaults?.workspace ??
|
||||
baseConfig.agents?.defaults?.workspace ??
|
||||
@@ -320,11 +331,14 @@ export async function runConfigureWizard(
|
||||
}
|
||||
|
||||
if (selected.includes("channels")) {
|
||||
await noteChannelStatus({ cfg: nextConfig, prompter });
|
||||
const channelMode = await promptChannelMode(runtime);
|
||||
if (channelMode === "configure") {
|
||||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||||
allowDisable: true,
|
||||
allowSignalInstall: true,
|
||||
skipConfirm: true,
|
||||
skipStatusNote: true,
|
||||
});
|
||||
} else {
|
||||
nextConfig = await removeChannelConfigWizard(nextConfig, runtime);
|
||||
@@ -439,11 +453,14 @@ export async function runConfigureWizard(
|
||||
}
|
||||
|
||||
if (choice === "channels") {
|
||||
await noteChannelStatus({ cfg: nextConfig, prompter });
|
||||
const channelMode = await promptChannelMode(runtime);
|
||||
if (channelMode === "configure") {
|
||||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||||
allowDisable: true,
|
||||
allowSignalInstall: true,
|
||||
skipConfirm: true,
|
||||
skipStatusNote: true,
|
||||
});
|
||||
} else {
|
||||
nextConfig = await removeChannelConfigWizard(nextConfig, runtime);
|
||||
@@ -512,6 +529,11 @@ export async function runConfigureWizard(
|
||||
}
|
||||
|
||||
if (!ranSection) {
|
||||
if (didSetGatewayMode) {
|
||||
await persistConfig();
|
||||
outro("Gateway mode set to local.");
|
||||
return;
|
||||
}
|
||||
outro("No changes selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
} from "./models/aliases.js";
|
||||
export {
|
||||
modelsAuthAddCommand,
|
||||
modelsAuthLoginCommand,
|
||||
modelsAuthPasteTokenCommand,
|
||||
modelsAuthSetupTokenCommand,
|
||||
} from "./models/auth.js";
|
||||
|
||||
+211
-1
@@ -8,12 +8,21 @@ import {
|
||||
upsertAuthProfile,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||
import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
||||
import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, readConfigFileSnapshot } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
|
||||
import { applyAuthProfileConfig } from "../onboard-auth.js";
|
||||
import { isRemoteEnvironment } from "../antigravity-oauth.js";
|
||||
import { openUrl } from "../onboard-helpers.js";
|
||||
import { createVpsAwareOAuthHandlers } from "../oauth-flow.js";
|
||||
import { updateConfig } from "./shared.js";
|
||||
import { resolvePluginProviders } from "../../plugins/providers.js";
|
||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||
import type { ProviderAuthMethod, ProviderAuthResult, ProviderPlugin } from "../../plugins/types.js";
|
||||
import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
|
||||
|
||||
const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
|
||||
clackConfirm({
|
||||
@@ -215,3 +224,204 @@ export async function modelsAuthAddCommand(_opts: Record<string, never>, runtime
|
||||
|
||||
await modelsAuthPasteTokenCommand({ provider: providerId, profileId, expiresIn }, runtime);
|
||||
}
|
||||
|
||||
type LoginOptions = {
|
||||
provider?: string;
|
||||
method?: string;
|
||||
setDefault?: boolean;
|
||||
};
|
||||
|
||||
function resolveProviderMatch(
|
||||
providers: ProviderPlugin[],
|
||||
rawProvider?: string,
|
||||
): ProviderPlugin | null {
|
||||
const raw = rawProvider?.trim();
|
||||
if (!raw) return null;
|
||||
const normalized = normalizeProviderId(raw);
|
||||
return (
|
||||
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
|
||||
providers.find(
|
||||
(provider) =>
|
||||
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
|
||||
) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
|
||||
const raw = rawMethod?.trim();
|
||||
if (!raw) return null;
|
||||
const normalized = raw.toLowerCase();
|
||||
return (
|
||||
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
|
||||
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function mergeConfigPatch<T>(base: T, patch: unknown): T {
|
||||
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
|
||||
return patch as T;
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = { ...base };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const existing = next[key];
|
||||
if (isPlainRecord(existing) && isPlainRecord(value)) {
|
||||
next[key] = mergeConfigPatch(existing, value);
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
return next as T;
|
||||
}
|
||||
|
||||
function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[model] = models[model] ?? {};
|
||||
|
||||
const existingModel = cfg.agents?.defaults?.model;
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
model: {
|
||||
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
|
||||
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
|
||||
: undefined),
|
||||
primary: model,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" | "token" {
|
||||
if (credential.type === "api_key") return "api_key";
|
||||
if (credential.type === "token") return "token";
|
||||
return "oauth";
|
||||
}
|
||||
|
||||
export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error("models auth login requires an interactive TTY.");
|
||||
}
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (!snapshot.valid) {
|
||||
const issues = snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n");
|
||||
throw new Error(`Invalid config at ${snapshot.path}\n${issues}`);
|
||||
}
|
||||
|
||||
const config = snapshot.config;
|
||||
const defaultAgentId = resolveDefaultAgentId(config);
|
||||
const agentDir = resolveAgentDir(config, defaultAgentId);
|
||||
const workspaceDir =
|
||||
resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir();
|
||||
|
||||
const providers = resolvePluginProviders({ config, workspaceDir });
|
||||
if (providers.length === 0) {
|
||||
throw new Error("No provider plugins found. Install one via `clawdbot plugins install`.");
|
||||
}
|
||||
|
||||
const prompter = createClackPrompter();
|
||||
const selectedProvider =
|
||||
resolveProviderMatch(providers, opts.provider) ??
|
||||
(await prompter.select({
|
||||
message: "Select a provider",
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.label,
|
||||
hint: provider.docsPath ? `Docs: ${provider.docsPath}` : undefined,
|
||||
})),
|
||||
}).then((id) => resolveProviderMatch(providers, String(id))));
|
||||
|
||||
if (!selectedProvider) {
|
||||
throw new Error("Unknown provider. Use --provider <id> to pick a provider plugin.");
|
||||
}
|
||||
|
||||
const chosenMethod =
|
||||
pickAuthMethod(selectedProvider, opts.method) ??
|
||||
(selectedProvider.auth.length === 1
|
||||
? selectedProvider.auth[0]
|
||||
: await prompter.select({
|
||||
message: `Auth method for ${selectedProvider.label}`,
|
||||
options: selectedProvider.auth.map((method) => ({
|
||||
value: method.id,
|
||||
label: method.label,
|
||||
hint: method.hint,
|
||||
})),
|
||||
}).then((id) =>
|
||||
selectedProvider.auth.find((method) => method.id === String(id)),
|
||||
));
|
||||
|
||||
if (!chosenMethod) {
|
||||
throw new Error("Unknown auth method. Use --method <id> to select one.");
|
||||
}
|
||||
|
||||
const isRemote = isRemoteEnvironment();
|
||||
const result: ProviderAuthResult = await chosenMethod.run({
|
||||
config,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
prompter,
|
||||
runtime,
|
||||
isRemote,
|
||||
openUrl: async (url) => {
|
||||
await openUrl(url);
|
||||
},
|
||||
oauth: {
|
||||
createVpsAwareHandlers: (params) => createVpsAwareOAuthHandlers(params),
|
||||
},
|
||||
});
|
||||
|
||||
for (const profile of result.profiles) {
|
||||
upsertAuthProfile({
|
||||
profileId: profile.profileId,
|
||||
credential: profile.credential,
|
||||
agentDir,
|
||||
});
|
||||
}
|
||||
|
||||
await updateConfig((cfg) => {
|
||||
let next = cfg;
|
||||
if (result.configPatch) {
|
||||
next = mergeConfigPatch(next, result.configPatch);
|
||||
}
|
||||
for (const profile of result.profiles) {
|
||||
next = applyAuthProfileConfig(next, {
|
||||
profileId: profile.profileId,
|
||||
provider: profile.credential.provider,
|
||||
mode: credentialMode(profile.credential),
|
||||
});
|
||||
}
|
||||
if (opts.setDefault && result.defaultModel) {
|
||||
next = applyDefaultModel(next, result.defaultModel);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
for (const profile of result.profiles) {
|
||||
runtime.log(
|
||||
`Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`,
|
||||
);
|
||||
}
|
||||
if (result.defaultModel) {
|
||||
runtime.log(
|
||||
opts.setDefault
|
||||
? `Default model set to ${result.defaultModel}`
|
||||
: `Default model available: ${result.defaultModel} (use --set-default to apply)`,
|
||||
);
|
||||
}
|
||||
if (result.notes && result.notes.length > 0) {
|
||||
await prompter.note(result.notes.join("\n"), "Provider notes");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ import { resolveEnvApiKey } from "../../agents/model-auth.js";
|
||||
import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||
import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js";
|
||||
import {
|
||||
formatUsageWindowSummary,
|
||||
loadProviderUsageSummary,
|
||||
resolveUsageProviderId,
|
||||
type UsageProviderId,
|
||||
} from "../../infra/provider-usage.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { colorize, theme } from "../../terminal/theme.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
@@ -402,6 +408,32 @@ export async function modelsStatusCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const usageByProvider = new Map<string, string>();
|
||||
const usageProviders = Array.from(
|
||||
new Set(
|
||||
oauthProfiles
|
||||
.map((profile) => resolveUsageProviderId(profile.provider))
|
||||
.filter((provider): provider is UsageProviderId => Boolean(provider)),
|
||||
),
|
||||
);
|
||||
if (usageProviders.length > 0) {
|
||||
try {
|
||||
const usageSummary = await loadProviderUsageSummary({
|
||||
providers: usageProviders,
|
||||
agentDir,
|
||||
timeoutMs: 3500,
|
||||
});
|
||||
for (const snapshot of usageSummary.providers) {
|
||||
const formatted = formatUsageWindowSummary(snapshot, { now: Date.now(), maxWindows: 2 });
|
||||
if (formatted) {
|
||||
usageByProvider.set(snapshot.provider, formatted);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore usage failures
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
if (status === "ok") return colorize(rich, theme.success, "ok");
|
||||
if (status === "static") return colorize(rich, theme.muted, "static");
|
||||
@@ -410,19 +442,32 @@ export async function modelsStatusCommand(
|
||||
return colorize(rich, theme.error, "expired");
|
||||
};
|
||||
|
||||
const profilesByProvider = new Map<string, typeof oauthProfiles>();
|
||||
for (const profile of oauthProfiles) {
|
||||
const labelText = profile.label || profile.profileId;
|
||||
const label = colorize(rich, theme.accent, labelText);
|
||||
const status = formatStatus(profile.status);
|
||||
const expiry =
|
||||
profile.status === "static"
|
||||
? ""
|
||||
: profile.expiresAt
|
||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||
: " expires unknown";
|
||||
const source =
|
||||
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
|
||||
runtime.log(`- ${label} ${status}${expiry}${source}`);
|
||||
const current = profilesByProvider.get(profile.provider);
|
||||
if (current) current.push(profile);
|
||||
else profilesByProvider.set(profile.provider, [profile]);
|
||||
}
|
||||
|
||||
for (const [provider, profiles] of profilesByProvider) {
|
||||
const usageKey = resolveUsageProviderId(provider);
|
||||
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
|
||||
const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : "";
|
||||
runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`);
|
||||
for (const profile of profiles) {
|
||||
const labelText = profile.label || profile.profileId;
|
||||
const label = colorize(rich, theme.accent, labelText);
|
||||
const status = formatStatus(profile.status);
|
||||
const expiry =
|
||||
profile.status === "static"
|
||||
? ""
|
||||
: profile.expiresAt
|
||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||
: " expires unknown";
|
||||
const source =
|
||||
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
|
||||
runtime.log(` - ${label} ${status}${expiry}${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.check) runtime.exit(checkStatus);
|
||||
|
||||
@@ -67,4 +67,119 @@ describe("setupChannels", () => {
|
||||
);
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts for configured channel action and skips configuration when told to skip", async () => {
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") return "telegram";
|
||||
if (message.includes("already configured")) return "skip";
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
});
|
||||
const multiselect = vi.fn(async () => {
|
||||
throw new Error("unexpected multiselect");
|
||||
});
|
||||
const text = vi.fn(async ({ message }: { message: string }) => {
|
||||
throw new Error(`unexpected text prompt: ${message}`);
|
||||
});
|
||||
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async () => {}),
|
||||
select,
|
||||
multiselect,
|
||||
text: text as unknown as WizardPrompter["text"],
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}),
|
||||
};
|
||||
|
||||
await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "token",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Select channel (QuickStart)" }),
|
||||
);
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("already configured") }),
|
||||
);
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds disabled hint to channel selection when a channel is disabled", async () => {
|
||||
let selectionCount = 0;
|
||||
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
|
||||
if (message === "Select a channel") {
|
||||
selectionCount += 1;
|
||||
const opts = options as Array<{ value: string; hint?: string }>;
|
||||
const telegram = opts.find((opt) => opt.value === "telegram");
|
||||
expect(telegram?.hint).toContain("disabled");
|
||||
return selectionCount === 1 ? "telegram" : "__done__";
|
||||
}
|
||||
if (message.includes("already configured")) return "skip";
|
||||
return "__done__";
|
||||
});
|
||||
const multiselect = vi.fn(async () => {
|
||||
throw new Error("unexpected multiselect");
|
||||
});
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async () => {}),
|
||||
select,
|
||||
multiselect,
|
||||
text: vi.fn(async () => ""),
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}),
|
||||
};
|
||||
|
||||
await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "token",
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Select a channel" }),
|
||||
);
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+350
-125
@@ -4,6 +4,8 @@ import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.
|
||||
import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { DmPolicy } from "../config/types.js";
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
@@ -16,7 +18,120 @@ import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
reloadOnboardingPluginRegistry,
|
||||
} from "./onboarding/plugin-install.js";
|
||||
import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js";
|
||||
import type {
|
||||
ChannelOnboardingDmPolicy,
|
||||
ChannelOnboardingStatus,
|
||||
SetupChannelsOptions,
|
||||
} from "./onboarding/types.js";
|
||||
|
||||
type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip";
|
||||
|
||||
type ChannelStatusSummary = {
|
||||
installedPlugins: ReturnType<typeof listChannelPlugins>;
|
||||
catalogEntries: ReturnType<typeof listChannelPluginCatalogEntries>;
|
||||
statusByChannel: Map<ChannelChoice, ChannelOnboardingStatus>;
|
||||
statusLines: string[];
|
||||
};
|
||||
|
||||
function formatAccountLabel(accountId: string): string {
|
||||
return accountId === DEFAULT_ACCOUNT_ID ? "default (primary)" : accountId;
|
||||
}
|
||||
|
||||
async function promptConfiguredAction(params: {
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
supportsDisable: boolean;
|
||||
supportsDelete: boolean;
|
||||
}): Promise<ConfiguredChannelAction> {
|
||||
const { prompter, label, supportsDisable, supportsDelete } = params;
|
||||
const options = [
|
||||
{ value: "update", label: "Modify settings" },
|
||||
...(supportsDisable ? [{ value: "disable", label: "Disable (keeps config)" }] : []),
|
||||
...(supportsDelete ? [{ value: "delete", label: "Delete config" }] : []),
|
||||
{ value: "skip", label: "Skip (leave as-is)" },
|
||||
] as const;
|
||||
return (await prompter.select({
|
||||
message: `${label} already configured. What do you want to do?`,
|
||||
options,
|
||||
initialValue: "update",
|
||||
})) as ConfiguredChannelAction;
|
||||
}
|
||||
|
||||
async function promptRemovalAccountId(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
channel: ChannelChoice;
|
||||
}): Promise<string> {
|
||||
const { cfg, prompter, label, channel } = params;
|
||||
const plugin = getChannelPlugin(channel);
|
||||
if (!plugin) return DEFAULT_ACCOUNT_ID;
|
||||
const accountIds = plugin.config.listAccountIds(cfg).filter(Boolean);
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds });
|
||||
if (accountIds.length <= 1) return defaultAccountId;
|
||||
const selected = (await prompter.select({
|
||||
message: `${label} account`,
|
||||
options: accountIds.map((accountId) => ({
|
||||
value: accountId,
|
||||
label: formatAccountLabel(accountId),
|
||||
})),
|
||||
initialValue: defaultAccountId,
|
||||
})) as string;
|
||||
return normalizeAccountId(selected) ?? defaultAccountId;
|
||||
}
|
||||
|
||||
async function collectChannelStatus(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
options?: SetupChannelsOptions;
|
||||
accountOverrides: Partial<Record<ChannelChoice, string>>;
|
||||
}): Promise<ChannelStatusSummary> {
|
||||
const installedPlugins = listChannelPlugins();
|
||||
const installedIds = new Set(installedPlugins.map((plugin) => plugin.id));
|
||||
const catalogEntries = listChannelPluginCatalogEntries().filter(
|
||||
(entry) => !installedIds.has(entry.id),
|
||||
);
|
||||
const statusEntries = await Promise.all(
|
||||
listChannelOnboardingAdapters().map((adapter) =>
|
||||
adapter.getStatus({
|
||||
cfg: params.cfg,
|
||||
options: params.options,
|
||||
accountOverrides: params.accountOverrides,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const catalogStatuses = catalogEntries.map((entry) => ({
|
||||
channel: entry.id,
|
||||
configured: false,
|
||||
statusLines: [`${entry.meta.label}: install plugin to enable`],
|
||||
selectionHint: "plugin · install",
|
||||
quickstartScore: 0,
|
||||
}));
|
||||
const combinedStatuses = [...statusEntries, ...catalogStatuses];
|
||||
const statusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry]));
|
||||
const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines);
|
||||
return {
|
||||
installedPlugins,
|
||||
catalogEntries,
|
||||
statusByChannel,
|
||||
statusLines,
|
||||
};
|
||||
}
|
||||
|
||||
export async function noteChannelStatus(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
options?: SetupChannelsOptions;
|
||||
accountOverrides?: Partial<Record<ChannelChoice, string>>;
|
||||
}): Promise<void> {
|
||||
const { statusLines } = await collectChannelStatus({
|
||||
cfg: params.cfg,
|
||||
options: params.options,
|
||||
accountOverrides: params.accountOverrides ?? {},
|
||||
});
|
||||
if (statusLines.length > 0) {
|
||||
await params.prompter.note(statusLines.join("\n"), "Channel status");
|
||||
}
|
||||
}
|
||||
|
||||
async function noteChannelPrimer(
|
||||
prompter: WizardPrompter,
|
||||
@@ -123,26 +238,9 @@ export async function setupChannels(
|
||||
accountOverrides.whatsapp = options.whatsappAccountId.trim();
|
||||
}
|
||||
|
||||
const installedPlugins = listChannelPlugins();
|
||||
const catalogEntries = listChannelPluginCatalogEntries().filter(
|
||||
(entry) => !installedPlugins.some((plugin) => plugin.id === entry.id),
|
||||
);
|
||||
const statusEntries = await Promise.all(
|
||||
listChannelOnboardingAdapters().map((adapter) =>
|
||||
adapter.getStatus({ cfg, options, accountOverrides }),
|
||||
),
|
||||
);
|
||||
const catalogStatuses = catalogEntries.map((entry) => ({
|
||||
channel: entry.id,
|
||||
configured: false,
|
||||
statusLines: [`${entry.meta.label}: install plugin to enable`],
|
||||
selectionHint: "plugin · install",
|
||||
quickstartScore: 0,
|
||||
}));
|
||||
const combinedStatuses = [...statusEntries, ...catalogStatuses];
|
||||
const statusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry]));
|
||||
const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines);
|
||||
if (statusLines.length > 0) {
|
||||
const { installedPlugins, catalogEntries, statusByChannel, statusLines } =
|
||||
await collectChannelStatus({ cfg: next, options, accountOverrides });
|
||||
if (!options?.skipStatusNote && statusLines.length > 0) {
|
||||
await prompter.note(statusLines.join("\n"), "Channel status");
|
||||
}
|
||||
|
||||
@@ -168,94 +266,9 @@ export async function setupChannels(
|
||||
];
|
||||
await noteChannelPrimer(prompter, primerChannels);
|
||||
|
||||
const selectionOptions = [
|
||||
...installedPlugins.map((plugin) => ({
|
||||
id: plugin.id as ChannelChoice,
|
||||
meta: plugin.meta,
|
||||
})),
|
||||
...catalogEntries.map((entry) => ({
|
||||
id: entry.id as ChannelChoice,
|
||||
meta: entry.meta,
|
||||
})),
|
||||
].map((entry) => {
|
||||
const meta = entry.meta;
|
||||
const status = statusByChannel.get(entry.id);
|
||||
return {
|
||||
value: meta.id,
|
||||
label: meta.selectionLabel ?? meta.label,
|
||||
...(status?.selectionHint ? { hint: status.selectionHint } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const quickstartDefault =
|
||||
options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel);
|
||||
|
||||
let selection: ChannelChoice[];
|
||||
if (options?.quickstartDefaults) {
|
||||
const choice = (await prompter.select({
|
||||
message: "Select channel (QuickStart)",
|
||||
options: [
|
||||
...selectionOptions,
|
||||
{
|
||||
value: "__skip__",
|
||||
label: "Skip for now",
|
||||
hint: "You can add channels later via `clawdbot channels add`",
|
||||
},
|
||||
],
|
||||
initialValue: quickstartDefault,
|
||||
})) as ChannelChoice | "__skip__";
|
||||
selection = choice === "__skip__" ? [] : [choice];
|
||||
} else {
|
||||
const initialSelection = options?.initialSelection ?? [];
|
||||
selection = (await prompter.multiselect({
|
||||
message: "Select channels (Space to toggle, Enter to continue)",
|
||||
options: selectionOptions,
|
||||
initialValues: initialSelection.length ? initialSelection : undefined,
|
||||
})) as ChannelChoice[];
|
||||
}
|
||||
|
||||
const catalogById = new Map(catalogEntries.map((entry) => [entry.id as ChannelChoice, entry]));
|
||||
if (selection.some((channel) => catalogById.has(channel))) {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
||||
for (const channel of selection) {
|
||||
const entry = catalogById.get(channel);
|
||||
if (!entry) continue;
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: next,
|
||||
entry,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
next = result.cfg;
|
||||
if (!result.installed) {
|
||||
selection = selection.filter((id) => id !== channel);
|
||||
continue;
|
||||
}
|
||||
reloadOnboardingPluginRegistry({
|
||||
cfg: next,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
options?.onSelection?.(selection);
|
||||
|
||||
const selectionNotes = new Map<string, string>();
|
||||
for (const plugin of installedPlugins) {
|
||||
selectionNotes.set(plugin.id, formatChannelSelectionLine(plugin.meta, formatDocsLink));
|
||||
}
|
||||
for (const entry of catalogEntries) {
|
||||
selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink));
|
||||
}
|
||||
const selectedLines = selection
|
||||
.map((channel) => selectionNotes.get(channel))
|
||||
.filter((line): line is string => Boolean(line));
|
||||
if (selectedLines.length > 0) {
|
||||
await prompter.note(selectedLines.join("\n"), "Selected channels");
|
||||
}
|
||||
|
||||
const shouldPromptAccountIds = options?.promptAccountIds === true;
|
||||
const recordAccount = (channel: ChannelChoice, accountId: string) => {
|
||||
options?.onAccountId?.(channel, accountId);
|
||||
@@ -263,9 +276,80 @@ export async function setupChannels(
|
||||
adapter?.onAccountRecorded?.(accountId, options);
|
||||
};
|
||||
|
||||
for (const channel of selection) {
|
||||
const selection: ChannelChoice[] = [];
|
||||
const addSelection = (channel: ChannelChoice) => {
|
||||
if (!selection.includes(channel)) selection.push(channel);
|
||||
};
|
||||
|
||||
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
if (!plugin) return undefined;
|
||||
const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next });
|
||||
const account = plugin.config.resolveAccount(next, accountId);
|
||||
let enabled: boolean | undefined;
|
||||
if (plugin.config.isEnabled) {
|
||||
enabled = plugin.config.isEnabled(account, next);
|
||||
} else if (typeof (account as { enabled?: boolean })?.enabled === "boolean") {
|
||||
enabled = (account as { enabled?: boolean }).enabled;
|
||||
} else if (
|
||||
typeof (next.channels as Record<string, { enabled?: boolean }> | undefined)?.[channel]
|
||||
?.enabled === "boolean"
|
||||
) {
|
||||
enabled = (next.channels as Record<string, { enabled?: boolean }>)[channel]?.enabled;
|
||||
}
|
||||
return enabled === false ? "disabled" : undefined;
|
||||
};
|
||||
|
||||
const buildSelectionOptions = (
|
||||
entries: Array<{ id: ChannelChoice; meta: { id: string; label: string; selectionLabel?: string } }>,
|
||||
) =>
|
||||
entries.map((entry) => {
|
||||
const status = statusByChannel.get(entry.id);
|
||||
const disabledHint = resolveDisabledHint(entry.id);
|
||||
const hint = [status?.selectionHint, disabledHint].filter(Boolean).join(" · ") || undefined;
|
||||
return {
|
||||
value: entry.meta.id,
|
||||
label: entry.meta.selectionLabel ?? entry.meta.label,
|
||||
...(hint ? { hint } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const getChannelEntries = () => {
|
||||
const installed = listChannelPlugins();
|
||||
const installedIds = new Set(installed.map((plugin) => plugin.id));
|
||||
const catalog = listChannelPluginCatalogEntries().filter(
|
||||
(entry) => !installedIds.has(entry.id),
|
||||
);
|
||||
const entries = [
|
||||
...installed.map((plugin) => ({
|
||||
id: plugin.id as ChannelChoice,
|
||||
meta: plugin.meta,
|
||||
})),
|
||||
...catalog.map((entry) => ({
|
||||
id: entry.id as ChannelChoice,
|
||||
meta: entry.meta,
|
||||
})),
|
||||
];
|
||||
return {
|
||||
entries,
|
||||
catalog,
|
||||
catalogById: new Map(catalog.map((entry) => [entry.id as ChannelChoice, entry])),
|
||||
};
|
||||
};
|
||||
|
||||
const refreshStatus = async (channel: ChannelChoice) => {
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
if (!adapter) continue;
|
||||
if (!adapter) return;
|
||||
const status = await adapter.getStatus({ cfg: next, options, accountOverrides });
|
||||
statusByChannel.set(channel, status);
|
||||
};
|
||||
|
||||
const configureChannel = async (channel: ChannelChoice) => {
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
if (!adapter) {
|
||||
await prompter.note(`${channel} does not support onboarding yet.`, "Channel setup");
|
||||
return;
|
||||
}
|
||||
const result = await adapter.configure({
|
||||
cfg: next,
|
||||
runtime,
|
||||
@@ -279,28 +363,169 @@ export async function setupChannels(
|
||||
if (result.accountId) {
|
||||
recordAccount(channel, result.accountId);
|
||||
}
|
||||
addSelection(channel);
|
||||
await refreshStatus(channel);
|
||||
};
|
||||
|
||||
const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
const supportsDisable = Boolean(
|
||||
options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable),
|
||||
);
|
||||
const supportsDelete = Boolean(options?.allowDisable && plugin?.config.deleteAccount);
|
||||
const action = await promptConfiguredAction({
|
||||
prompter,
|
||||
label,
|
||||
supportsDisable,
|
||||
supportsDelete,
|
||||
});
|
||||
|
||||
if (action === "skip") return;
|
||||
if (action === "update") {
|
||||
await configureChannel(channel);
|
||||
return;
|
||||
}
|
||||
if (!options?.allowDisable) return;
|
||||
|
||||
if (action === "delete" && !supportsDelete) {
|
||||
await prompter.note(`${label} does not support deleting config entries.`, "Remove channel");
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPromptAccount =
|
||||
action === "delete"
|
||||
? Boolean(plugin?.config.deleteAccount)
|
||||
: Boolean(plugin?.config.setAccountEnabled);
|
||||
const accountId = shouldPromptAccount
|
||||
? await promptRemovalAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label,
|
||||
channel,
|
||||
})
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
const resolvedAccountId =
|
||||
normalizeAccountId(accountId) ??
|
||||
(plugin ? resolveChannelDefaultAccountId({ plugin, cfg: next }) : DEFAULT_ACCOUNT_ID);
|
||||
const accountLabel = formatAccountLabel(resolvedAccountId);
|
||||
|
||||
if (action === "delete") {
|
||||
const confirmed = await prompter.confirm({
|
||||
message: `Delete ${label} account "${accountLabel}"?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
if (plugin?.config.deleteAccount) {
|
||||
next = plugin.config.deleteAccount({ cfg: next, accountId: resolvedAccountId });
|
||||
}
|
||||
await refreshStatus(channel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plugin?.config.setAccountEnabled) {
|
||||
next = plugin.config.setAccountEnabled({
|
||||
cfg: next,
|
||||
accountId: resolvedAccountId,
|
||||
enabled: false,
|
||||
});
|
||||
} else if (adapter?.disable) {
|
||||
next = adapter.disable(next);
|
||||
}
|
||||
await refreshStatus(channel);
|
||||
};
|
||||
|
||||
const handleChannelChoice = async (channel: ChannelChoice) => {
|
||||
const { catalogById } = getChannelEntries();
|
||||
const catalogEntry = catalogById.get(channel);
|
||||
if (catalogEntry) {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: next,
|
||||
entry: catalogEntry,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
next = result.cfg;
|
||||
if (!result.installed) return;
|
||||
reloadOnboardingPluginRegistry({
|
||||
cfg: next,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
await refreshStatus(channel);
|
||||
}
|
||||
|
||||
const plugin = getChannelPlugin(channel);
|
||||
const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel;
|
||||
const status = statusByChannel.get(channel);
|
||||
const configured = status?.configured ?? false;
|
||||
if (configured) {
|
||||
await handleConfiguredChannel(channel, label);
|
||||
return;
|
||||
}
|
||||
await configureChannel(channel);
|
||||
};
|
||||
|
||||
if (options?.quickstartDefaults) {
|
||||
const { entries } = getChannelEntries();
|
||||
const choice = (await prompter.select({
|
||||
message: "Select channel (QuickStart)",
|
||||
options: [
|
||||
...buildSelectionOptions(entries),
|
||||
{
|
||||
value: "__skip__",
|
||||
label: "Skip for now",
|
||||
hint: "You can add channels later via `clawdbot channels add`",
|
||||
},
|
||||
],
|
||||
initialValue: quickstartDefault,
|
||||
})) as ChannelChoice | "__skip__";
|
||||
if (choice !== "__skip__") {
|
||||
await handleChannelChoice(choice);
|
||||
}
|
||||
} else {
|
||||
const doneValue = "__done__" as const;
|
||||
const initialValue = options?.initialSelection?.[0] ?? quickstartDefault;
|
||||
while (true) {
|
||||
const { entries } = getChannelEntries();
|
||||
const choice = (await prompter.select({
|
||||
message: "Select a channel",
|
||||
options: [
|
||||
...buildSelectionOptions(entries),
|
||||
{
|
||||
value: doneValue,
|
||||
label: "Finished",
|
||||
hint: selection.length > 0 ? "Done" : "Skip for now",
|
||||
},
|
||||
],
|
||||
initialValue,
|
||||
})) as ChannelChoice | typeof doneValue;
|
||||
if (choice === doneValue) break;
|
||||
await handleChannelChoice(choice);
|
||||
}
|
||||
}
|
||||
|
||||
options?.onSelection?.(selection);
|
||||
|
||||
const selectionNotes = new Map<string, string>();
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
selectionNotes.set(plugin.id, formatChannelSelectionLine(plugin.meta, formatDocsLink));
|
||||
}
|
||||
for (const entry of listChannelPluginCatalogEntries()) {
|
||||
selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink));
|
||||
}
|
||||
const selectedLines = selection
|
||||
.map((channel) => selectionNotes.get(channel))
|
||||
.filter((line): line is string => Boolean(line));
|
||||
if (selectedLines.length > 0) {
|
||||
await prompter.note(selectedLines.join("\n"), "Selected channels");
|
||||
}
|
||||
|
||||
if (!options?.skipDmPolicyPrompt) {
|
||||
next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter });
|
||||
}
|
||||
|
||||
if (options?.allowDisable) {
|
||||
for (const [channelId, status] of statusByChannel) {
|
||||
if (selection.includes(channelId)) continue;
|
||||
if (!status.configured) continue;
|
||||
const adapter = getChannelOnboardingAdapter(channelId);
|
||||
if (!adapter?.disable) continue;
|
||||
const meta = getChannelPlugin(channelId)?.meta;
|
||||
const disable = await prompter.confirm({
|
||||
message: `Disable ${meta?.label ?? channelId} channel?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (disable) {
|
||||
next = adapter.disable(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -204,6 +204,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.discord.token": "Discord Bot Token",
|
||||
"channels.slack.botToken": "Slack Bot Token",
|
||||
"channels.slack.appToken": "Slack App Token",
|
||||
"channels.slack.userToken": "Slack User Token",
|
||||
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
|
||||
"channels.slack.thread.historyScope": "Slack Thread History Scope",
|
||||
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
|
||||
"channels.signal.account": "Signal Account",
|
||||
|
||||
@@ -10,6 +10,13 @@ export type SessionChannelId = ChannelId | "webchat";
|
||||
export type SessionChatType = "direct" | "group" | "room";
|
||||
|
||||
export type SessionEntry = {
|
||||
/**
|
||||
* Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications).
|
||||
* Stored on the main session entry.
|
||||
*/
|
||||
lastHeartbeatText?: string;
|
||||
/** Timestamp (ms) when lastHeartbeatText was delivered. */
|
||||
lastHeartbeatSentAt?: number;
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
sessionFile?: string;
|
||||
@@ -26,6 +33,8 @@ export type SessionEntry = {
|
||||
providerOverride?: string;
|
||||
modelOverride?: string;
|
||||
authProfileOverride?: string;
|
||||
authProfileOverrideSource?: "auto" | "user";
|
||||
authProfileOverrideCompactionCount?: number;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivationNeedsSystemIntro?: boolean;
|
||||
sendPolicy?: "allow" | "deny";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("Slack token config fields", () => {
|
||||
it("accepts user token config fields", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
userTokenReadOnly: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts account-level user token config", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
userTokenReadOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -163,7 +163,7 @@ export type AgentDefaultsConfig = {
|
||||
| "none";
|
||||
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||
to?: string;
|
||||
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */
|
||||
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */
|
||||
prompt?: string;
|
||||
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
|
||||
ackMaxChars?: number;
|
||||
|
||||
@@ -27,6 +27,8 @@ export type AgentConfig = {
|
||||
memorySearch?: MemorySearchConfig;
|
||||
/** Human-like delay between block replies for this agent. */
|
||||
humanDelay?: HumanDelayConfig;
|
||||
/** Optional per-agent heartbeat overrides. */
|
||||
heartbeat?: AgentDefaultsConfig["heartbeat"];
|
||||
identity?: IdentityConfig;
|
||||
groupChat?: GroupChatConfig;
|
||||
subagents?: {
|
||||
|
||||
@@ -80,6 +80,9 @@ export type SlackAccountConfig = {
|
||||
enabled?: boolean;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
userToken?: string;
|
||||
/** If true, restrict user token to read operations only. Default: true. */
|
||||
userTokenReadOnly?: boolean;
|
||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||
allowBots?: boolean;
|
||||
/** Default mention requirement for channel messages (default: true). */
|
||||
|
||||
@@ -255,6 +255,7 @@ export const AgentEntrySchema = z.object({
|
||||
model: AgentModelSchema.optional(),
|
||||
memorySearch: MemorySearchSchema,
|
||||
humanDelay: HumanDelaySchema.optional(),
|
||||
heartbeat: HeartbeatSchema,
|
||||
identity: IdentitySchema,
|
||||
groupChat: GroupChatSchema,
|
||||
subagents: z
|
||||
|
||||
@@ -220,6 +220,8 @@ export const SlackAccountSchema = z.object({
|
||||
configWrites: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
appToken: z.string().optional(),
|
||||
userToken: z.string().optional(),
|
||||
userTokenReadOnly: z.boolean().optional().default(true),
|
||||
allowBots: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
|
||||
@@ -5,6 +5,8 @@ import process from "node:process";
|
||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
|
||||
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
|
||||
|
||||
process.title = "clawdbot";
|
||||
|
||||
if (process.argv.includes("--no-color")) {
|
||||
process.env.NO_COLOR = "1";
|
||||
process.env.FORCE_COLOR = "0";
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
@@ -90,6 +91,7 @@ describe("gateway server sessions", () => {
|
||||
|
||||
const list1 = await rpcReq<{
|
||||
path: string;
|
||||
defaults?: { model?: string | null; modelProvider?: string | null };
|
||||
sessions: Array<{
|
||||
key: string;
|
||||
totalTokens?: number;
|
||||
@@ -102,6 +104,7 @@ describe("gateway server sessions", () => {
|
||||
expect(list1.ok).toBe(true);
|
||||
expect(list1.payload?.path).toBe(storePath);
|
||||
expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false);
|
||||
expect(list1.payload?.defaults?.modelProvider).toBe(DEFAULT_PROVIDER);
|
||||
const main = list1.payload?.sessions.find((s) => s.key === "agent:main:main");
|
||||
expect(main?.totalTokens).toBe(30);
|
||||
expect(main?.thinkingLevel).toBe("low");
|
||||
|
||||
@@ -311,6 +311,7 @@ export function getSessionDefaults(cfg: ClawdbotConfig): GatewaySessionsDefaults
|
||||
lookupContextTokens(resolved.model) ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
return {
|
||||
modelProvider: resolved.provider ?? null,
|
||||
model: resolved.model ?? null,
|
||||
contextTokens: contextTokens ?? null,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
|
||||
export type GatewaySessionsDefaults = {
|
||||
modelProvider: string | null;
|
||||
model: string | null;
|
||||
contextTokens: number | null;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||
|
||||
// Avoid pulling optional runtime deps during isolated runs.
|
||||
@@ -15,22 +16,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -45,6 +30,23 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
@@ -75,22 +77,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -104,6 +90,23 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue({ text: "<b>HEARTBEAT_OK</b>" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
@@ -136,22 +139,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
try {
|
||||
const originalUpdatedAt = 1000;
|
||||
const bumpedUpdatedAt = 2000;
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: originalUpdatedAt,
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -165,12 +152,32 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: originalUpdatedAt,
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockImplementationOnce(async () => {
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { main?: { updatedAt?: number } };
|
||||
if (parsed.main) {
|
||||
parsed.main.updatedAt = bumpedUpdatedAt;
|
||||
const parsed = JSON.parse(raw) as Record<string, { updatedAt?: number } | undefined>;
|
||||
if (parsed[sessionKey]) {
|
||||
parsed[sessionKey] = {
|
||||
...parsed[sessionKey],
|
||||
updatedAt: bumpedUpdatedAt,
|
||||
};
|
||||
}
|
||||
await fs.writeFile(storePath, JSON.stringify(parsed, null, 2));
|
||||
return { text: "" };
|
||||
@@ -186,10 +193,11 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const finalStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
|
||||
main?: { updatedAt?: number };
|
||||
};
|
||||
expect(finalStore.main?.updatedAt).toBe(bumpedUpdatedAt);
|
||||
const finalStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
|
||||
string,
|
||||
{ updatedAt?: number } | undefined
|
||||
>;
|
||||
expect(finalStore[sessionKey]?.updatedAt).toBe(bumpedUpdatedAt);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
@@ -201,11 +209,22 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
@@ -217,16 +236,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Heartbeat alert" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
@@ -260,11 +269,22 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||
},
|
||||
},
|
||||
channels: { telegram: { botToken: "test-bot-token-123" } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "telegram",
|
||||
@@ -276,16 +296,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||
},
|
||||
},
|
||||
channels: { telegram: { botToken: "test-bot-token-123" } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
@@ -325,22 +335,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "telegram",
|
||||
lastTo: "123456",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -356,6 +350,23 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
},
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "telegram",
|
||||
lastTo: "123456",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as replyModule from "../auto-reply/reply.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
@@ -55,6 +56,16 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
}),
|
||||
).toBe(2 * 60 * 60_000);
|
||||
});
|
||||
|
||||
it("uses explicit heartbeat overrides when provided", () => {
|
||||
expect(
|
||||
resolveHeartbeatIntervalMs(
|
||||
{ agents: { defaults: { heartbeat: { every: "30m" } } } },
|
||||
undefined,
|
||||
{ every: "5m" },
|
||||
),
|
||||
).toBe(5 * 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHeartbeatPrompt", () => {
|
||||
@@ -183,6 +194,23 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
to: "123",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers per-agent heartbeat overrides when provided", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
|
||||
};
|
||||
const heartbeat = { target: "whatsapp", to: "+1555" } as const;
|
||||
expect(
|
||||
resolveHeartbeatDeliveryTarget({
|
||||
cfg,
|
||||
entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1999" },
|
||||
heartbeat,
|
||||
}),
|
||||
).toEqual({
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHeartbeatOnce", () => {
|
||||
@@ -191,11 +219,22 @@ describe("runHeartbeatOnce", () => {
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
@@ -207,16 +246,6 @@ describe("runHeartbeatOnce", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]);
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
@@ -242,19 +271,37 @@ describe("runHeartbeatOnce", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("can include reasoning payloads when enabled", async () => {
|
||||
it("uses per-agent heartbeat overrides and session keys", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "30m", prompt: "Default prompt" },
|
||||
},
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: "ops",
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555", prompt: "Ops check" },
|
||||
},
|
||||
],
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" });
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
@@ -263,6 +310,97 @@ describe("runHeartbeatOnce", () => {
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([{ text: "Final alert" }]);
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: "ops",
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object));
|
||||
expect(replySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ Body: "Ops check", SessionKey: sessionKey }),
|
||||
{ isHeartbeat: true },
|
||||
cfg,
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses duplicate heartbeat payloads within 24h", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastHeartbeatText: "Final alert",
|
||||
lastHeartbeatSentAt: 0,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([{ text: "Final alert" }]);
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 60_000,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("can include reasoning payloads when enabled", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -277,6 +415,23 @@ describe("runHeartbeatOnce", () => {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([
|
||||
{ text: "Reasoning:\n_Because it helps_" },
|
||||
@@ -317,22 +472,6 @@ describe("runHeartbeatOnce", () => {
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -347,6 +486,23 @@ describe("runHeartbeatOnce", () => {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([
|
||||
{ text: "Reasoning:\n_Because it helps_" },
|
||||
|
||||
+169
-31
@@ -1,3 +1,4 @@
|
||||
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
@@ -14,19 +15,22 @@ import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveMainSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
updateSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { getQueueSize } from "../process/command-queue.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
|
||||
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
||||
import {
|
||||
type HeartbeatRunResult,
|
||||
type HeartbeatWakeHandler,
|
||||
requestHeartbeatNow,
|
||||
setHeartbeatWakeHandler,
|
||||
} from "./heartbeat-wake.js";
|
||||
@@ -48,8 +52,48 @@ export function setHeartbeatsEnabled(enabled: boolean) {
|
||||
heartbeatsEnabled = enabled;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatIntervalMs(cfg: ClawdbotConfig, overrideEvery?: string) {
|
||||
const raw = overrideEvery ?? cfg.agents?.defaults?.heartbeat?.every ?? DEFAULT_HEARTBEAT_EVERY;
|
||||
type HeartbeatConfig = AgentDefaultsConfig["heartbeat"];
|
||||
type HeartbeatAgent = {
|
||||
agentId: string;
|
||||
heartbeat?: HeartbeatConfig;
|
||||
};
|
||||
|
||||
function resolveHeartbeatConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId?: string,
|
||||
): HeartbeatConfig | undefined {
|
||||
const defaults = cfg.agents?.defaults?.heartbeat;
|
||||
if (!agentId) return defaults;
|
||||
const overrides = resolveAgentConfig(cfg, agentId)?.heartbeat;
|
||||
if (!defaults && !overrides) return overrides;
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
|
||||
function resolveHeartbeatAgents(cfg: ClawdbotConfig): HeartbeatAgent[] {
|
||||
const list = cfg.agents?.list ?? [];
|
||||
const explicit = list.filter((entry) => entry?.heartbeat);
|
||||
if (explicit.length > 0) {
|
||||
return explicit
|
||||
.map((entry) => {
|
||||
const id = normalizeAgentId(entry.id);
|
||||
return { agentId: id, heartbeat: resolveHeartbeatConfig(cfg, id) };
|
||||
})
|
||||
.filter((entry) => entry.agentId);
|
||||
}
|
||||
const fallbackId = resolveDefaultAgentId(cfg);
|
||||
return [{ agentId: fallbackId, heartbeat: resolveHeartbeatConfig(cfg, fallbackId) }];
|
||||
}
|
||||
|
||||
export function resolveHeartbeatIntervalMs(
|
||||
cfg: ClawdbotConfig,
|
||||
overrideEvery?: string,
|
||||
heartbeat?: HeartbeatConfig,
|
||||
) {
|
||||
const raw =
|
||||
overrideEvery ??
|
||||
heartbeat?.every ??
|
||||
cfg.agents?.defaults?.heartbeat?.every ??
|
||||
DEFAULT_HEARTBEAT_EVERY;
|
||||
if (!raw) return null;
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -63,23 +107,31 @@ export function resolveHeartbeatIntervalMs(cfg: ClawdbotConfig, overrideEvery?:
|
||||
return ms;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) {
|
||||
return resolveHeartbeatPromptText(cfg.agents?.defaults?.heartbeat?.prompt);
|
||||
}
|
||||
|
||||
function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) {
|
||||
return Math.max(
|
||||
0,
|
||||
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
export function resolveHeartbeatPrompt(cfg: ClawdbotConfig, heartbeat?: HeartbeatConfig) {
|
||||
return resolveHeartbeatPromptText(
|
||||
heartbeat?.prompt ?? cfg.agents?.defaults?.heartbeat?.prompt,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveHeartbeatSession(cfg: ClawdbotConfig) {
|
||||
function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig, heartbeat?: HeartbeatConfig) {
|
||||
return Math.max(
|
||||
0,
|
||||
heartbeat?.ackMaxChars ??
|
||||
cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveHeartbeatSession(cfg: ClawdbotConfig, agentId?: string) {
|
||||
const sessionCfg = cfg.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
const sessionKey = scope === "global" ? "global" : resolveMainSessionKey(cfg);
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
|
||||
const sessionKey =
|
||||
scope === "global"
|
||||
? "global"
|
||||
: resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId });
|
||||
const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId;
|
||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
return { sessionKey, storePath, store, entry };
|
||||
@@ -185,14 +237,18 @@ function normalizeHeartbeatReply(
|
||||
|
||||
export async function runHeartbeatOnce(opts: {
|
||||
cfg?: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
heartbeat?: HeartbeatConfig;
|
||||
reason?: string;
|
||||
deps?: HeartbeatDeps;
|
||||
}): Promise<HeartbeatRunResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const agentId = normalizeAgentId(opts.agentId ?? resolveDefaultAgentId(cfg));
|
||||
const heartbeat = opts.heartbeat ?? resolveHeartbeatConfig(cfg, agentId);
|
||||
if (!heartbeatsEnabled) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
if (!resolveHeartbeatIntervalMs(cfg)) {
|
||||
if (!resolveHeartbeatIntervalMs(cfg, undefined, heartbeat)) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
|
||||
@@ -202,9 +258,9 @@ export async function runHeartbeatOnce(opts: {
|
||||
}
|
||||
|
||||
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
|
||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId);
|
||||
const previousUpdatedAt = entry?.updatedAt;
|
||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry });
|
||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||
const lastChannel =
|
||||
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
|
||||
? normalizeChannelId(entry.lastChannel)
|
||||
@@ -221,18 +277,19 @@ export async function runHeartbeatOnce(opts: {
|
||||
lastTo: entry?.lastTo,
|
||||
provider: senderProvider,
|
||||
});
|
||||
const prompt = resolveHeartbeatPrompt(cfg);
|
||||
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
|
||||
const ctx = {
|
||||
Body: prompt,
|
||||
From: sender,
|
||||
To: sender,
|
||||
Provider: "heartbeat",
|
||||
SessionKey: sessionKey,
|
||||
};
|
||||
|
||||
try {
|
||||
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
|
||||
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
||||
const includeReasoning = cfg.agents?.defaults?.heartbeat?.includeReasoning === true;
|
||||
const includeReasoning = heartbeat?.includeReasoning === true;
|
||||
const reasoningPayloads = includeReasoning
|
||||
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
|
||||
: [];
|
||||
@@ -254,10 +311,10 @@ export async function runHeartbeatOnce(opts: {
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg);
|
||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
|
||||
const normalized = normalizeHeartbeatReply(
|
||||
replyPayload,
|
||||
resolveEffectiveMessagesConfig(cfg, resolveAgentIdFromSessionKey(sessionKey)).responsePrefix,
|
||||
resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
|
||||
ackMaxChars,
|
||||
);
|
||||
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
|
||||
@@ -277,6 +334,35 @@ export async function runHeartbeatOnce(opts: {
|
||||
|
||||
const mediaUrls =
|
||||
replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
|
||||
|
||||
// Suppress duplicate heartbeats (same payload) within a short window.
|
||||
// This prevents "nagging" when nothing changed but the model repeats the same items.
|
||||
const prevHeartbeatText = typeof entry?.lastHeartbeatText === "string" ? entry.lastHeartbeatText : "";
|
||||
const prevHeartbeatAt = typeof entry?.lastHeartbeatSentAt === "number" ? entry.lastHeartbeatSentAt : undefined;
|
||||
const isDuplicateMain =
|
||||
!shouldSkipMain &&
|
||||
!mediaUrls.length &&
|
||||
Boolean(prevHeartbeatText.trim()) &&
|
||||
normalized.text.trim() === prevHeartbeatText.trim() &&
|
||||
typeof prevHeartbeatAt === "number" &&
|
||||
startedAt - prevHeartbeatAt < 24 * 60 * 60 * 1000;
|
||||
|
||||
if (isDuplicateMain) {
|
||||
await restoreHeartbeatUpdatedAt({
|
||||
storePath,
|
||||
sessionKey,
|
||||
updatedAt: previousUpdatedAt,
|
||||
});
|
||||
emitHeartbeatEvent({
|
||||
status: "skipped",
|
||||
reason: "duplicate",
|
||||
preview: normalized.text.slice(0, 200),
|
||||
durationMs: Date.now() - startedAt,
|
||||
hasMedia: false,
|
||||
});
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
// Reasoning payloads are text-only; any attachments stay on the main reply.
|
||||
const previewText = shouldSkipMain
|
||||
? reasoningPayloads
|
||||
@@ -339,6 +425,20 @@ export async function runHeartbeatOnce(opts: {
|
||||
deps: opts.deps,
|
||||
});
|
||||
|
||||
// Record last delivered heartbeat payload for dedupe.
|
||||
if (!shouldSkipMain && normalized.text.trim()) {
|
||||
const store = loadSessionStore(storePath);
|
||||
const current = store[sessionKey];
|
||||
if (current) {
|
||||
store[sessionKey] = {
|
||||
...current,
|
||||
lastHeartbeatText: normalized.text,
|
||||
lastHeartbeatSentAt: startedAt,
|
||||
};
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
}
|
||||
|
||||
emitHeartbeatEvent({
|
||||
status: "sent",
|
||||
to: delivery.to,
|
||||
@@ -365,19 +465,57 @@ export function startHeartbeatRunner(opts: {
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const intervalMs = resolveHeartbeatIntervalMs(cfg);
|
||||
const heartbeatAgents = resolveHeartbeatAgents(cfg);
|
||||
const intervals = heartbeatAgents
|
||||
.map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat))
|
||||
.filter((value): value is number => typeof value === "number");
|
||||
const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null;
|
||||
if (!intervalMs) {
|
||||
log.info("heartbeat: disabled", { enabled: false });
|
||||
}
|
||||
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
const run = async (params?: { reason?: string }) => {
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
reason: params?.reason,
|
||||
deps: { runtime },
|
||||
});
|
||||
return res;
|
||||
const lastRunByAgent = new Map<string, number>();
|
||||
const run: HeartbeatWakeHandler = async (params) => {
|
||||
if (!heartbeatsEnabled) {
|
||||
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||
}
|
||||
if (heartbeatAgents.length === 0) {
|
||||
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||
}
|
||||
|
||||
const reason = params?.reason;
|
||||
const isInterval = reason === "interval";
|
||||
const startedAt = Date.now();
|
||||
const now = startedAt;
|
||||
let ran = false;
|
||||
|
||||
for (const agent of heartbeatAgents) {
|
||||
const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
|
||||
if (!agentIntervalMs) continue;
|
||||
const lastRun = lastRunByAgent.get(agent.agentId);
|
||||
if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: agent.agentId,
|
||||
heartbeat: agent.heartbeat,
|
||||
reason,
|
||||
deps: { runtime },
|
||||
});
|
||||
if (res.status === "skipped" && res.reason === "requests-in-flight") {
|
||||
return res;
|
||||
}
|
||||
if (res.status !== "skipped" || res.reason !== "disabled") {
|
||||
lastRunByAgent.set(agent.agentId, now);
|
||||
}
|
||||
if (res.status === "ran") ran = true;
|
||||
}
|
||||
|
||||
if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
|
||||
};
|
||||
|
||||
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/ind
|
||||
import type { ChannelId, ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
|
||||
import type {
|
||||
DeliverableMessageChannel,
|
||||
GatewayMessageChannel,
|
||||
@@ -79,9 +80,11 @@ export function resolveOutboundTarget(params: {
|
||||
export function resolveHeartbeatDeliveryTarget(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
entry?: SessionEntry;
|
||||
heartbeat?: AgentDefaultsConfig["heartbeat"];
|
||||
}): OutboundTarget {
|
||||
const { cfg, entry } = params;
|
||||
const rawTarget = cfg.agents?.defaults?.heartbeat?.target;
|
||||
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
|
||||
const rawTarget = heartbeat?.target;
|
||||
let target: HeartbeatTarget = "last";
|
||||
if (rawTarget === "none" || rawTarget === "last") {
|
||||
target = rawTarget;
|
||||
@@ -95,10 +98,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
}
|
||||
|
||||
const explicitTo =
|
||||
typeof cfg.agents?.defaults?.heartbeat?.to === "string" &&
|
||||
cfg.agents.defaults.heartbeat.to.trim()
|
||||
? cfg.agents.defaults.heartbeat.to.trim()
|
||||
: undefined;
|
||||
typeof heartbeat?.to === "string" && heartbeat.to.trim() ? heartbeat.to.trim() : undefined;
|
||||
|
||||
const lastChannel =
|
||||
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clampPercent } from "./provider-usage.shared.js";
|
||||
import type { UsageSummary, UsageWindow } from "./provider-usage.types.js";
|
||||
import type { ProviderUsageSnapshot, UsageSummary, UsageWindow } from "./provider-usage.types.js";
|
||||
|
||||
function formatResetRemaining(targetMs?: number, now?: number): string | null {
|
||||
if (!targetMs) return null;
|
||||
@@ -35,6 +35,28 @@ function formatWindowShort(window: UsageWindow, now?: number): string {
|
||||
return `${remaining.toFixed(0)}% left (${window.label}${resetSuffix})`;
|
||||
}
|
||||
|
||||
export function formatUsageWindowSummary(
|
||||
snapshot: ProviderUsageSnapshot,
|
||||
opts?: { now?: number; maxWindows?: number; includeResets?: boolean },
|
||||
): string | null {
|
||||
if (snapshot.error) return `error: ${snapshot.error}`;
|
||||
if (snapshot.windows.length === 0) return null;
|
||||
const now = opts?.now ?? Date.now();
|
||||
const maxWindows =
|
||||
typeof opts?.maxWindows === "number" && opts.maxWindows > 0
|
||||
? Math.min(opts.maxWindows, snapshot.windows.length)
|
||||
: snapshot.windows.length;
|
||||
const includeResets = opts?.includeResets ?? false;
|
||||
const windows = snapshot.windows.slice(0, maxWindows);
|
||||
const parts = windows.map((window) => {
|
||||
const remaining = clampPercent(100 - window.usedPercent);
|
||||
const reset = includeResets ? formatResetRemaining(window.resetAt, now) : null;
|
||||
const resetSuffix = reset ? ` ⏱${reset}` : "";
|
||||
return `${window.label} ${remaining.toFixed(0)}% left${resetSuffix}`;
|
||||
});
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function formatUsageSummaryLine(
|
||||
summary: UsageSummary,
|
||||
opts?: { now?: number; maxProviders?: number },
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export { formatUsageReportLines, formatUsageSummaryLine } from "./provider-usage.format.js";
|
||||
export {
|
||||
formatUsageReportLines,
|
||||
formatUsageSummaryLine,
|
||||
formatUsageWindowSummary,
|
||||
} from "./provider-usage.format.js";
|
||||
export { loadProviderUsageSummary } from "./provider-usage.load.js";
|
||||
export { resolveUsageProviderId } from "./provider-usage.shared.js";
|
||||
export type {
|
||||
|
||||
@@ -190,6 +190,7 @@ function createPluginRecord(params: {
|
||||
status: params.enabled ? "loaded" : "disabled",
|
||||
toolNames: [],
|
||||
channelIds: [],
|
||||
providerIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { loadClawdbotPlugins } from "./loader.js";
|
||||
import type { ProviderPlugin } from "./types.js";
|
||||
|
||||
const log = createSubsystemLogger("plugins");
|
||||
|
||||
export function resolvePluginProviders(params: {
|
||||
config?: Parameters<typeof loadClawdbotPlugins>[0]["config"];
|
||||
workspaceDir?: string;
|
||||
}): ProviderPlugin[] {
|
||||
const registry = loadClawdbotPlugins({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
logger: {
|
||||
info: (msg) => log.info(msg),
|
||||
warn: (msg) => log.warn(msg),
|
||||
error: (msg) => log.error(msg),
|
||||
debug: (msg) => log.debug(msg),
|
||||
},
|
||||
});
|
||||
|
||||
return registry.providers.map((entry) => entry.provider);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ClawdbotPluginChannelRegistration,
|
||||
ClawdbotPluginCliRegistrar,
|
||||
ClawdbotPluginHttpHandler,
|
||||
ProviderPlugin,
|
||||
ClawdbotPluginService,
|
||||
ClawdbotPluginToolContext,
|
||||
ClawdbotPluginToolFactory,
|
||||
@@ -47,6 +48,12 @@ export type PluginChannelRegistration = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginProviderRegistration = {
|
||||
pluginId: string;
|
||||
provider: ProviderPlugin;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginServiceRegistration = {
|
||||
pluginId: string;
|
||||
service: ClawdbotPluginService;
|
||||
@@ -66,6 +73,7 @@ export type PluginRecord = {
|
||||
error?: string;
|
||||
toolNames: string[];
|
||||
channelIds: string[];
|
||||
providerIds: string[];
|
||||
gatewayMethods: string[];
|
||||
cliCommands: string[];
|
||||
services: string[];
|
||||
@@ -78,6 +86,7 @@ export type PluginRegistry = {
|
||||
plugins: PluginRecord[];
|
||||
tools: PluginToolRegistration[];
|
||||
channels: PluginChannelRegistration[];
|
||||
providers: PluginProviderRegistration[];
|
||||
gatewayHandlers: GatewayRequestHandlers;
|
||||
httpHandlers: PluginHttpRegistration[];
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
@@ -95,6 +104,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
plugins: [],
|
||||
tools: [],
|
||||
channels: [],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
@@ -189,6 +199,35 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
};
|
||||
|
||||
const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => {
|
||||
const id = typeof provider?.id === "string" ? provider.id.trim() : "";
|
||||
if (!id) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "provider registration missing id",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existing = registry.providers.find((entry) => entry.provider.id === id);
|
||||
if (existing) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `provider already registered: ${id} (${existing.pluginId})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
record.providerIds.push(id);
|
||||
registry.providers.push({
|
||||
pluginId: record.id,
|
||||
provider,
|
||||
source: record.source,
|
||||
});
|
||||
};
|
||||
|
||||
const registerCli = (
|
||||
record: PluginRecord,
|
||||
registrar: ClawdbotPluginCliRegistrar,
|
||||
@@ -241,6 +280,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerTool: (tool, opts) => registerTool(record, tool, opts),
|
||||
registerHttpHandler: (handler) => registerHttpHandler(record, handler),
|
||||
registerChannel: (registration) => registerChannel(record, registration),
|
||||
registerProvider: (provider) => registerProvider(record, provider),
|
||||
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
registerService: (service) => registerService(record, service),
|
||||
@@ -254,6 +294,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
pushDiagnostic,
|
||||
registerTool,
|
||||
registerChannel,
|
||||
registerProvider,
|
||||
registerGatewayMethod,
|
||||
registerCli,
|
||||
registerService,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Command } from "commander";
|
||||
|
||||
import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js";
|
||||
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
||||
|
||||
export type PluginLogger = {
|
||||
@@ -54,6 +59,48 @@ export type ClawdbotPluginToolFactory = (
|
||||
ctx: ClawdbotPluginToolContext,
|
||||
) => AnyAgentTool | AnyAgentTool[] | null | undefined;
|
||||
|
||||
export type ProviderAuthKind = "oauth" | "api_key" | "token" | "device_code" | "custom";
|
||||
|
||||
export type ProviderAuthResult = {
|
||||
profiles: Array<{ profileId: string; credential: AuthProfileCredential }>;
|
||||
configPatch?: Partial<ClawdbotConfig>;
|
||||
defaultModel?: string;
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
export type ProviderAuthContext = {
|
||||
config: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
oauth: {
|
||||
createVpsAwareHandlers: typeof createVpsAwareOAuthHandlers;
|
||||
};
|
||||
};
|
||||
|
||||
export type ProviderAuthMethod = {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
kind: ProviderAuthKind;
|
||||
run: (ctx: ProviderAuthContext) => Promise<ProviderAuthResult>;
|
||||
};
|
||||
|
||||
export type ProviderPlugin = {
|
||||
id: string;
|
||||
label: string;
|
||||
docsPath?: string;
|
||||
aliases?: string[];
|
||||
envVars?: string[];
|
||||
models?: ModelProviderConfig;
|
||||
auth: ProviderAuthMethod[];
|
||||
formatApiKey?: (cred: AuthProfileCredential) => string;
|
||||
refreshOAuth?: (cred: OAuthCredential) => Promise<OAuthCredential>;
|
||||
};
|
||||
|
||||
export type ClawdbotPluginGatewayMethod = {
|
||||
method: string;
|
||||
handler: GatewayRequestHandler;
|
||||
@@ -123,6 +170,7 @@ export type ClawdbotPluginApi = {
|
||||
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
|
||||
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
||||
registerService: (service: ClawdbotPluginService) => void;
|
||||
registerProvider: (provider: ProviderPlugin) => void;
|
||||
resolvePath: (input: string) => string;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
buildSenderName,
|
||||
buildTelegramGroupFrom,
|
||||
buildTelegramGroupPeerId,
|
||||
buildTelegramThreadParams,
|
||||
buildTypingThreadParams,
|
||||
describeReplyTarget,
|
||||
extractTelegramLocation,
|
||||
hasBotMention,
|
||||
@@ -92,7 +92,7 @@ export const buildTelegramMessageContext = async ({
|
||||
|
||||
const sendTyping = async () => {
|
||||
try {
|
||||
await bot.api.sendChatAction(chatId, "typing", buildTelegramThreadParams(resolvedThreadId));
|
||||
await bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId));
|
||||
} catch (err) {
|
||||
logVerbose(`telegram typing cue failed for chat ${chatId}: ${String(err)}`);
|
||||
}
|
||||
|
||||
+3
-5
@@ -352,10 +352,8 @@ describe("createTelegramBot", () => {
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledWith(
|
||||
"-1001234567890",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ message_thread_id: 1 }),
|
||||
);
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
const sendParams = sendMessageSpy.mock.calls[0]?.[2] as { message_thread_id?: number };
|
||||
expect(sendParams?.message_thread_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1710,11 +1710,9 @@ describe("createTelegramBot", () => {
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledWith(
|
||||
"-1001234567890",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ message_thread_id: 1 }),
|
||||
);
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
const sendParams = sendMessageSpy.mock.calls[0]?.[2] as { message_thread_id?: number };
|
||||
expect(sendParams?.message_thread_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies topic skill filters and system prompts", async () => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTelegramThreadParams, buildTypingThreadParams } from "./helpers.js";
|
||||
|
||||
describe("buildTelegramThreadParams", () => {
|
||||
it("omits General topic thread id for message sends", () => {
|
||||
expect(buildTelegramThreadParams(1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes non-General topic thread ids", () => {
|
||||
expect(buildTelegramThreadParams(99)).toEqual({ message_thread_id: 99 });
|
||||
});
|
||||
|
||||
it("normalizes thread ids to integers", () => {
|
||||
expect(buildTelegramThreadParams(42.9)).toEqual({ message_thread_id: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTypingThreadParams", () => {
|
||||
it("returns undefined when no thread id is provided", () => {
|
||||
expect(buildTypingThreadParams(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes General topic thread id for typing indicators", () => {
|
||||
expect(buildTypingThreadParams(1)).toEqual({ message_thread_id: 1 });
|
||||
});
|
||||
|
||||
it("normalizes thread ids to integers", () => {
|
||||
expect(buildTypingThreadParams(42.9)).toEqual({ message_thread_id: 42 });
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,31 @@ export function resolveTelegramForumThreadId(params: {
|
||||
return params.messageThreadId ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build thread params for Telegram API calls (messages, media).
|
||||
* General forum topic (id=1) must be treated like a regular supergroup send:
|
||||
* Telegram rejects sendMessage/sendMedia with message_thread_id=1 ("thread not found").
|
||||
*/
|
||||
export function buildTelegramThreadParams(messageThreadId?: number) {
|
||||
return messageThreadId != null ? { message_thread_id: messageThreadId } : undefined;
|
||||
if (messageThreadId == null) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = Math.trunc(messageThreadId);
|
||||
if (normalized === TELEGRAM_GENERAL_TOPIC_ID) {
|
||||
return undefined;
|
||||
}
|
||||
return { message_thread_id: normalized };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build thread params for typing indicators (sendChatAction).
|
||||
* Empirically, General topic (id=1) needs message_thread_id for typing to appear.
|
||||
*/
|
||||
export function buildTypingThreadParams(messageThreadId?: number) {
|
||||
if (messageThreadId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return { message_thread_id: Math.trunc(messageThreadId) };
|
||||
}
|
||||
|
||||
export function resolveTelegramStreamMode(
|
||||
|
||||
@@ -20,6 +20,7 @@ import { markdownToTelegramHtml } from "./format.js";
|
||||
import { recordSentMessage } from "./sent-message-cache.js";
|
||||
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
|
||||
import { resolveTelegramVoiceSend } from "./voice.js";
|
||||
import { buildTelegramThreadParams } from "./bot/helpers.js";
|
||||
|
||||
type TelegramSendOpts = {
|
||||
token?: string;
|
||||
@@ -166,12 +167,10 @@ export async function sendMessageTelegram(
|
||||
|
||||
// Build optional params for forum topics and reply threading.
|
||||
// Only include these if actually provided to keep API calls clean.
|
||||
const threadParams: Record<string, number> = {};
|
||||
const messageThreadId =
|
||||
opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId;
|
||||
if (messageThreadId != null) {
|
||||
threadParams.message_thread_id = Math.trunc(messageThreadId);
|
||||
}
|
||||
const threadIdParams = buildTelegramThreadParams(messageThreadId);
|
||||
const threadParams: Record<string, number> = threadIdParams ? { ...threadIdParams } : {};
|
||||
if (opts.replyToMessageId != null) {
|
||||
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export type GatewaySessionList = {
|
||||
ts: number;
|
||||
path: string;
|
||||
count: number;
|
||||
defaults?: { model?: string | null; contextTokens?: number | null };
|
||||
defaults?: { model?: string | null; modelProvider?: string | null; contextTokens?: number | null };
|
||||
sessions: Array<{
|
||||
key: string;
|
||||
sessionId?: string;
|
||||
|
||||
@@ -117,7 +117,7 @@ export function createSessionActions(context: SessionActionContext) {
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
model: entry?.model ?? result.defaults?.model ?? undefined,
|
||||
modelProvider: entry?.modelProvider,
|
||||
modelProvider: entry?.modelProvider ?? result.defaults?.modelProvider ?? undefined,
|
||||
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
|
||||
inputTokens: entry?.inputTokens ?? null,
|
||||
outputTokens: entry?.outputTokens ?? null,
|
||||
|
||||
@@ -37,7 +37,10 @@ export function formatStatusSummary(summary: GatewayStatusSummary) {
|
||||
if (sessionPath) lines.push(`Session store: ${sessionPath}`);
|
||||
|
||||
const defaults = summary.sessions?.defaults;
|
||||
const defaultModel = defaults?.model ?? "unknown";
|
||||
const defaultModel =
|
||||
defaults?.model && defaults?.modelProvider
|
||||
? `${defaults.modelProvider}/${defaults.model}`
|
||||
: defaults?.model ?? "unknown";
|
||||
const defaultCtx =
|
||||
typeof defaults?.contextTokens === "number"
|
||||
? ` (${formatTokenCount(defaults.contextTokens)} ctx)`
|
||||
|
||||
Reference in new issue
Block a user