Compare commits

..
Author SHA1 Message Date
Peter Steinberger c51724f39b fix: cleanup suspended CLI processes (#978) (thanks @Nachx639) 2026-01-16 01:37:50 +00:00
Peter Steinberger a70fcc8ae0 chore(cli): set process title 2026-01-16 01:33:28 +00:00
Peter Steinberger fa521154ff fix: show disabled channels in onboarding picker 2026-01-16 01:29:25 +00:00
Peter Steinberger dffc1a4dcd fix: switch channel onboarding to single-select loop 2026-01-16 01:21:17 +00:00
Peter Steinberger 61e385b331 feat: add per-agent heartbeat config 2026-01-16 01:17:34 +00:00
Peter Steinberger f8f319713f fix: show provider/model labels in TUI 2026-01-16 01:13:14 +00:00
Peter Steinberger bb665bf22c fix: persist local gateway mode in configure wizard 2026-01-16 01:05:55 +00:00
Peter Steinberger f17dcb6213 fix: refine channel onboarding selection flow 2026-01-16 01:05:50 +00:00
Peter Steinberger d54c101100 docs: update release checklist 2026-01-16 00:42:48 +00:00
Peter Steinberger 96daa51d45 docs: document provider auth plugins 2026-01-16 00:42:48 +00:00
Peter Steinberger 6084421ec6 feat: add provider auth plugins 2026-01-16 00:42:28 +00:00
Peter Steinberger bca5c0d569 refactor: system prompt sections + docs/tests 2026-01-16 00:28:43 +00:00
Peter Steinberger 8c3cdba21c feat: sticky auth profile rotation + usage headers 2026-01-16 00:25:49 +00:00
voidandvoid e274b5a040 fix: heartbeat prompt + dedupe (#980) (thanks @voidserf)
- tighten default heartbeat prompt guidance
- suppress duplicate heartbeat alerts within 24h

Co-authored-by: void <voidserf@users.noreply.github.com>
2026-01-16 00:24:52 +00:00
Josh Lehman a139d35fa2 feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes

User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.

Required user token scopes:
- 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
2026-01-16 00:11:33 +00:00
Peter Steinberger 8312a19f02 fix: handle Telegram General topic thread params (#848) (thanks @azade-c) 2026-01-16 00:08:56 +00:00
Peter Steinberger fe8b28cdd9 Merge pull request #848 from azade-c/fix/telegram-general-topic-messages
fix(telegram): skip message_thread_id for General topic messages
2026-01-16 00:08:07 +00:00
Azade 6146acbb69 fix(telegram): separate thread params for typing vs messages
Telegram General topic (id=1) has inconsistent API behavior:
- sendMessage: rejects explicit message_thread_id=1
- sendChatAction: requires message_thread_id=1 for typing to show

Split into two helper functions:
- buildTelegramThreadParams: excludes General topic for messages
- buildTypingThreadParams: includes General topic for typing
2026-01-15 23:32:30 +00:00
85 changed files with 2685 additions and 610 deletions

No files matched your search

+13 -3
View File
@@ -2,8 +2,14 @@
## 2026.1.15 (unreleased)
- Slack: add optional user token routing for read access with safe write fallback. (#981) — thanks @jalehman.
- 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.
@@ -11,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
@@ -33,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.
Submodule Peekaboo updated: 95ad7532c1...c1243a7978.
-23
View File
@@ -33,7 +33,6 @@ Minimal config:
- `app_mention`
- `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel`
- `channel_id_changed`
- `channel_rename`
- `pin_added`, `pin_removed`
6) Invite the bot to channels you want it to read.
@@ -113,21 +112,6 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
## 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`.
## Config writes
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
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:
```json5
{
channels: { slack: { configWrites: false } }
}
```
## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
@@ -212,7 +196,6 @@ user scopes if you plan to configure a user token.
"reaction_removed",
"member_joined_channel",
"member_left_channel",
"channel_id_changed",
"channel_rename",
"pin_added",
"pin_removed"
@@ -343,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).
@@ -412,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.
+1
View File
@@ -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).
+4 -2
View File
@@ -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`
+3 -1
View File
@@ -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
View File
@@ -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.
+11
View File
@@ -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:
+7
View File
@@ -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.
+16
View File
@@ -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:
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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).
+38
View File
@@ -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
{
+6 -4
View File
@@ -1052,8 +1052,6 @@ Slack runs in Socket Mode and requires both a bot token and app token:
enabled: true,
botToken: "xoxb-...",
appToken: "xapp-...",
userToken: "xoxp-...", // optional (read-only by default)
userTokenReadOnly: true, // default: true
dm: {
enabled: true,
policy: "pairing", // pairing | allowlist | open | disabled
@@ -1104,7 +1102,6 @@ Slack runs in Socket Mode and requires both a bot token and app token:
Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
Optional user tokens (`xoxp-...`) are configured in the config file (no env var support). Reads prefer the user token when present; writes use the bot token unless you set `channels.slack.userTokenReadOnly: false` and no bot token is available.
Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`).
Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
@@ -1678,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`.
+43 -6
View File
@@ -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)
+2 -2
View File
@@ -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.
+50
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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`)
+2 -2
View File
@@ -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.
+2 -1
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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,
+102 -5
View File
@@ -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"]);
});
});
+2
View File
@@ -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);
}
+76
View File
@@ -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);
@@ -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;
@@ -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();
});
});
+24
View File
@@ -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
View File
@@ -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) {
+81 -11
View File
@@ -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,
},
};
},
-34
View File
@@ -373,28 +373,6 @@ describe("handleSlackAction", () => {
expect(opts?.token).toBe("xoxp-1");
});
it("uses user token for pin reads when available", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as ClawdbotConfig;
listSlackPins.mockClear();
listSlackPins.mockResolvedValueOnce([]);
await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
const [, opts] = listSlackPins.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1");
});
it("uses user token for member info reads when available", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as ClawdbotConfig;
getSlackMemberInfo.mockClear();
getSlackMemberInfo.mockResolvedValueOnce({});
await handleSlackAction({ action: "memberInfo", userId: "U1" }, cfg);
const [, opts] = getSlackMemberInfo.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" } },
@@ -427,16 +405,4 @@ describe("handleSlackAction", () => {
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1");
});
it("prefers bot token for writes when both tokens are available", async () => {
const cfg = {
channels: {
slack: { botToken: "xoxb-1", 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).toBeUndefined();
});
});
+14 -9
View File
@@ -2,7 +2,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveSlackTokenOverride } from "../../slack/token.js";
import {
deleteSlackMessage,
editSlackMessage,
@@ -82,14 +81,20 @@ export async function handleSlackAction(
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 tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation,
});
const token = getTokenForOperation(operation);
const tokenOverride = token && token !== botToken ? token : undefined;
if (!accountId && !tokenOverride) return undefined;
return {
...(accountId ? { accountId } : {}),
@@ -255,7 +260,7 @@ export async function handleSlackAction(
}
return jsonResult({ ok: true });
}
const pins = readOpts
const pins = writeOpts
? await listSlackPins(channelId, readOpts)
: await listSlackPins(channelId);
const normalizedPins = pins.map((pin) => {
@@ -275,7 +280,7 @@ export async function handleSlackAction(
throw new Error("Slack member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
const info = readOpts
const info = writeOpts
? await getSlackMemberInfo(userId, readOpts)
: await getSlackMemberInfo(userId);
return jsonResult({ ok: true, info });
+6 -1
View File
@@ -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)
+3 -1
View File
@@ -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;
+95 -1
View File
@@ -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,
+2
View File
@@ -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) {
+1
View File
@@ -15,6 +15,7 @@ export type SetupChannelsOptions = {
promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void;
forceAllowFromChannels?: ChannelId[];
skipStatusNote?: boolean;
skipDmPolicyPrompt?: boolean;
skipConfirm?: boolean;
quickstartDefaults?: boolean;
+2 -20
View File
@@ -1,6 +1,4 @@
import { resolveSlackAccount } from "../../../slack/accounts.js";
import { sendMessageSlack } from "../../../slack/send.js";
import { resolveSlackTokenOverride } from "../../../slack/token.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const slackOutbound: ChannelOutboundAdapter = {
@@ -17,36 +15,20 @@ export const slackOutbound: ChannelOutboundAdapter = {
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, {
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
return { channel: "slack", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps, replyToId }) => {
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, {
mediaUrl,
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
return { channel: "slack", ...result };
},
+230 -28
View File
@@ -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,
@@ -7,7 +11,6 @@ import {
} from "../../slack/accounts.js";
import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js";
import { resolveSlackTokenOverride } from "../../slack/token.js";
import { getChatChannelMeta } from "../registry.js";
import {
deleteAccountFromConfigSection,
@@ -22,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,19 +51,22 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
pairing: {
idLabel: "slackUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveSlackAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
notifyApproval: async ({ id }) => {
const cfg = loadConfig();
const account = resolveSlackAccount({
cfg,
accountId: DEFAULT_ACCOUNT_ID,
});
await sendMessageSlack(
`user:${id}`,
PAIRING_APPROVED_MESSAGE,
tokenOverride ? { token: tokenOverride } : undefined,
);
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: {
@@ -151,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 }) =>
@@ -240,12 +448,9 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
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,
@@ -256,12 +461,9 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
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,
+30 -7
View File
@@ -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)")
+6
View File
@@ -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
View File
@@ -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();
+2
View File
@@ -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;
+134
View File
@@ -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" }),
}),
);
});
});
+23 -1
View File
@@ -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;
}
+1
View File
@@ -6,6 +6,7 @@ export {
} from "./models/aliases.js";
export {
modelsAuthAddCommand,
modelsAuthLoginCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
} from "./models/auth.js";
+211 -1
View File
@@ -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");
}
}
+57 -12
View File
@@ -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);
+115
View File
@@ -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
View File
@@ -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;
}
+9
View File
@@ -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";
+1 -1
View File
@@ -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;
+2
View File
@@ -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?: {
+1
View File
@@ -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
+2
View File
@@ -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");
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 }));
+5 -5
View File
@@ -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
+23 -1
View File
@@ -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 },
+5 -1
View File
@@ -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 {
+1
View File
@@ -190,6 +190,7 @@ function createPluginRecord(params: {
status: params.enabled ? "loaded" : "disabled",
toolNames: [],
channelIds: [],
providerIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
+23
View File
@@ -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);
}
+41
View File
@@ -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,
+48
View File
@@ -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;
};
+1 -4
View File
@@ -1,7 +1,7 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { SlackAccountConfig } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
export type SlackTokenSource = "env" | "config" | "none";
@@ -11,7 +11,6 @@ export type ResolvedSlackAccount = {
name?: string;
botToken?: string;
appToken?: string;
userToken?: string;
botTokenSource: SlackTokenSource;
appTokenSource: SlackTokenSource;
config: SlackAccountConfig;
@@ -76,7 +75,6 @@ export function resolveSlackAccount(params: {
const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined;
const configBot = resolveSlackBotToken(merged.botToken);
const configApp = resolveSlackAppToken(merged.appToken);
const configUser = resolveSlackUserToken(merged.userToken);
const botToken = configBot ?? envBot;
const appToken = configApp ?? envApp;
const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none";
@@ -88,7 +86,6 @@ export function resolveSlackAccount(params: {
name: merged.name?.trim() || undefined,
botToken,
appToken,
userToken: configUser,
botTokenSource,
appTokenSource,
config: merged,
-41
View File
@@ -10,44 +10,3 @@ export function resolveSlackBotToken(raw?: string): string | undefined {
export function resolveSlackAppToken(raw?: string): string | undefined {
return normalizeSlackToken(raw);
}
export function resolveSlackUserToken(raw?: string): string | undefined {
return normalizeSlackToken(raw);
}
export type SlackTokenOperation = "read" | "write";
export type SlackTokenSelection = {
botToken?: string;
userToken?: string;
userTokenReadOnly?: boolean;
operation: SlackTokenOperation;
};
function selectSlackTokenNormalized(params: SlackTokenSelection): string | undefined {
const allowUserWrites = params.userTokenReadOnly === false;
if (params.operation === "read") return params.userToken ?? params.botToken;
if (!allowUserWrites) return params.botToken;
return params.botToken ?? params.userToken;
}
export function selectSlackToken(params: SlackTokenSelection): string | undefined {
const botToken = resolveSlackBotToken(params.botToken);
const userToken = resolveSlackUserToken(params.userToken);
return selectSlackTokenNormalized({
...params,
botToken,
userToken,
});
}
export function resolveSlackTokenOverride(params: SlackTokenSelection): string | undefined {
const botToken = resolveSlackBotToken(params.botToken);
const userToken = resolveSlackUserToken(params.userToken);
const token = selectSlackTokenNormalized({
...params,
botToken,
userToken,
});
return token && token !== botToken ? token : undefined;
}
+2 -2
View File
@@ -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)}`);
}
@@ -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();
});
});
+3 -5
View File
@@ -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 () => {
+30
View File
@@ -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 });
});
});
+24 -1
View File
@@ -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(
+3 -4
View File
@@ -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);
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+4 -1
View File
@@ -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)`