Compare commits

..

35 Commits

Author SHA1 Message Date
Peter Steinberger
185ffca274 fix: filter telegram native command names (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Peter Steinberger
c2940adc80 fix: register plugin commands natively (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Peter Steinberger
29043209c9 fix: refresh reserved plugin commands (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Peter Steinberger
bfc0fb742e fix: harden plugin commands (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Glucksberg
051c92651f fix: address code review findings for plugin commands
- Add registry lock during command execution to prevent race conditions
- Add input sanitization for command arguments (defense in depth)
- Validate handler is a function during registration
- Remove redundant case-insensitive regex flag
- Add success logging for command execution
- Simplify handler return type (always returns result now)
- Remove dead code branch in commands-plugin.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Glucksberg
b16576f298 fix: clear plugin commands on reload to prevent duplicates
Add clearPluginCommands() call in loadClawdbotPlugins() to ensure
previously registered commands are cleaned up before reloading plugins.
This prevents command conflicts during hot-reload scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Glucksberg
691adccf32 fix: address code review findings for plugin command API
Blockers fixed:
- Fix documentation: requireAuth defaults to true (not false)
- Add command name validation (must start with letter, alphanumeric only)
- Add reserved commands list to prevent shadowing built-in commands
- Emit diagnostic errors for invalid/duplicate command registration

Other improvements:
- Return user-friendly message for unauthorized commands (instead of silence)
- Sanitize error messages to avoid leaking internal details
- Document acceptsArgs behavior when arguments are provided
- Add notes about reserved commands and validation rules to docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Glucksberg
420af3285f feat: add plugin command API for LLM-free auto-reply commands
This adds a new `api.registerCommand()` method to the plugin API, allowing
plugins to register slash commands that execute without invoking the AI agent.

Features:
- Plugin commands are processed before built-in commands and the agent
- Commands can optionally require authorization
- Commands can accept arguments
- Async handlers are supported

Use case: plugins can implement toggle commands (like /tts_on, /tts_off)
that respond immediately without consuming LLM API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Peter Steinberger
675019cb6f fix: trigger fallback on auth profile exhaustion 2026-01-24 06:14:23 +00:00
Peter Steinberger
795b592286 fix: sync protocol swift models 2026-01-24 06:01:19 +00:00
Peter Steinberger
9d98e55ed5 fix: enforce group tool policy inheritance for subagents (#1557) (thanks @adam91holt) 2026-01-24 05:49:39 +00:00
Adam Holt
c07949a99c Channels: add per-group tool policies 2026-01-24 05:49:39 +00:00
Peter Steinberger
e51bf46abe fix: regenerate protocol swift models 2026-01-24 05:41:00 +00:00
Peter Steinberger
eba0625a70 fix: ignore identity template placeholders 2026-01-24 05:35:50 +00:00
Peter Steinberger
886752217d fix: gate diagnostic logs behind verbose 2026-01-24 05:06:42 +00:00
Peter Steinberger
5662a9cdfc fix: honor tools.exec ask/security in approvals 2026-01-24 04:53:44 +00:00
Peter Steinberger
fd23b9b209 fix: normalize outbound media payloads 2026-01-24 04:53:34 +00:00
Peter Steinberger
975f5a5284 fix: guard session store against array corruption 2026-01-24 04:51:46 +00:00
Peter Steinberger
63176ccb8a test: isolate heartbeat runner workspace in tests 2026-01-24 04:48:01 +00:00
Peter Steinberger
6c3a9fc092 fix: handle extension relay session reuse 2026-01-24 04:41:28 +00:00
Peter Steinberger
d9f173a03d test: stabilize service-env path tests on windows 2026-01-24 04:36:52 +00:00
Peter Steinberger
c3cb26f7ca feat: add node browser proxy routing 2026-01-24 04:21:47 +00:00
JustYannicc
dd06028827 feat(heartbeat): skip API calls when HEARTBEAT.md is effectively empty (#1535)
* feat: skip heartbeat API calls when HEARTBEAT.md is effectively empty

- Added isHeartbeatContentEffectivelyEmpty() to detect files with only headers/comments
- Modified runHeartbeatOnce() to check HEARTBEAT.md content before polling the LLM
- Returns early with 'empty-heartbeat-file' reason when no actionable tasks exist
- Preserves existing behavior when file is missing (lets LLM decide)
- Added comprehensive test coverage for empty file detection
- Saves API calls/costs when heartbeat file has no meaningful content

* chore: update HEARTBEAT.md template to be effectively empty by default

Changed instruction text to comment format so new workspaces benefit from
heartbeat optimization immediately. Users still get clear guidance on usage.

* fix: only treat markdown headers (# followed by space) as comments, not #TODO etc

* refactor: simplify regex per code review suggestion

* docs: clarify heartbeat empty file behavior (#1535) (thanks @JustYannicc)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 04:19:01 +00:00
Peter Steinberger
71203829d8 feat: add system cli 2026-01-24 04:03:07 +00:00
Peter Steinberger
dfa80e1e5d fix(ui): align control ui chat and config rendering 2026-01-24 03:55:43 +00:00
Peter Steinberger
951a4ea065 fix: anchor MEDIA tag parsing 2026-01-24 03:46:27 +00:00
Peter Steinberger
4fa1517e6d docs: add channels list usage troubleshooting 2026-01-24 03:44:44 +00:00
Peter Steinberger
de2d986008 fix: render Telegram media captions 2026-01-24 03:39:25 +00:00
Peter Steinberger
d57cb2e1a8 fix(ui): cache control ui markdown 2026-01-24 03:27:28 +00:00
Peter Steinberger
b697374ce5 fix: update docker gateway command 2026-01-24 03:24:28 +00:00
Peter Steinberger
b9106ba5f9 fix: guard console settings recursion (#1555) (thanks @travisp) 2026-01-24 03:15:05 +00:00
Travis
3ba9821254 Logging: guard console settings recursion 2026-01-24 03:12:40 +00:00
Peter Steinberger
17f2a990a8 docs: add changelog entry for memory slot none (#1554) (thanks @andreabadesso) 2026-01-24 03:11:31 +00:00
André Abadesso
71f7bd1cfd test: add tests for normalizePluginsConfig memory slot handling 2026-01-24 03:08:27 +00:00
André Abadesso
c4c01089ab fix: respect "none" value for plugins.slots.memory 2026-01-24 03:08:27 +00:00
164 changed files with 3894 additions and 586 deletions

View File

@@ -5,22 +5,31 @@ Docs: https://docs.clawd.bot
## 2026.1.23 (Unreleased)
### Changes
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- Plugins: add LLM-free plugin slash commands and include them in `/commands`. (#1558) Thanks @Glucksberg.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
### Fixes
- Logging: guard console settings resolution to avoid recursion on config warnings. (#1555) Thanks @travisp.
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
@@ -33,15 +42,19 @@ Docs: https://docs.clawd.bot
- CLI: explain when auth profiles are excluded by auth.order in probe details.
- CLI: drop the em dash when the banner tagline wraps to a second line.
- CLI: inline auth probe errors in status rows to reduce wrapping.
- Telegram: render markdown in media captions. (#1478)
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
- Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)
- Daemon: use platform PATH delimiters when building minimal service paths.
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
## 2026.1.22

View File

@@ -385,6 +385,7 @@ public struct SendParams: Codable, Sendable {
public let to: String
public let message: String
public let mediaurl: String?
public let mediaurls: [String]?
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
@@ -395,6 +396,7 @@ public struct SendParams: Codable, Sendable {
to: String,
message: String,
mediaurl: String?,
mediaurls: [String]?,
gifplayback: Bool?,
channel: String?,
accountid: String?,
@@ -404,6 +406,7 @@ public struct SendParams: Codable, Sendable {
self.to = to
self.message = message
self.mediaurl = mediaurl
self.mediaurls = mediaurls
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
@@ -414,6 +417,7 @@ public struct SendParams: Codable, Sendable {
case to
case message
case mediaurl = "mediaUrl"
case mediaurls = "mediaUrls"
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
@@ -478,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
public let accountid: String?
public let replyaccountid: String?
public let threadid: String?
public let groupid: String?
public let groupchannel: String?
public let groupspace: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -500,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
accountid: String?,
replyaccountid: String?,
threadid: String?,
groupid: String?,
groupchannel: String?,
groupspace: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -521,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
self.accountid = accountid
self.replyaccountid = replyaccountid
self.threadid = threadid
self.groupid = groupid
self.groupchannel = groupchannel
self.groupspace = groupspace
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -543,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case threadid = "threadId"
case groupid = "groupId"
case groupchannel = "groupChannel"
case groupspace = "groupSpace"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"

View File

@@ -385,6 +385,7 @@ public struct SendParams: Codable, Sendable {
public let to: String
public let message: String
public let mediaurl: String?
public let mediaurls: [String]?
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
@@ -395,6 +396,7 @@ public struct SendParams: Codable, Sendable {
to: String,
message: String,
mediaurl: String?,
mediaurls: [String]?,
gifplayback: Bool?,
channel: String?,
accountid: String?,
@@ -404,6 +406,7 @@ public struct SendParams: Codable, Sendable {
self.to = to
self.message = message
self.mediaurl = mediaurl
self.mediaurls = mediaurls
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
@@ -414,6 +417,7 @@ public struct SendParams: Codable, Sendable {
case to
case message
case mediaurl = "mediaUrl"
case mediaurls = "mediaUrls"
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
@@ -478,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
public let accountid: String?
public let replyaccountid: String?
public let threadid: String?
public let groupid: String?
public let groupchannel: String?
public let groupspace: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -500,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
accountid: String?,
replyaccountid: String?,
threadid: String?,
groupid: String?,
groupchannel: String?,
groupspace: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -521,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
self.accountid = accountid
self.replyaccountid = replyaccountid
self.threadid = threadid
self.groupid = groupid
self.groupchannel = groupchannel
self.groupspace = groupspace
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -543,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case threadid = "threadId"
case groupid = "groupId"
case groupchannel = "groupChannel"
case groupspace = "groupSpace"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"

View File

@@ -20,7 +20,7 @@ services:
[
"node",
"dist/index.js",
"gateway-daemon",
"gateway",
"--bind",
"${CLAWDBOT_GATEWAY_BIND:-lan}",
"--port",

View File

@@ -263,15 +263,15 @@ Run history:
clawdbot cron runs --id <jobId> --limit 50
```
Immediate wake without creating a job:
Immediate system event without creating a job:
```bash
clawdbot wake --mode now --text "Next heartbeat: check battery."
clawdbot system event --mode now --text "Next heartbeat: check battery."
```
## Gateway API surface
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
- `cron.run` (force or due), `cron.runs`
- `wake` (enqueue system event + optional heartbeat)
For immediate system events without a job, use [`clawdbot system event`](/cli/system).
## Troubleshooting

View File

@@ -271,4 +271,4 @@ clawdbot cron add \
- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration
- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference
- [Wake](/cli/wake) - manual wake command
- [System](/cli/system) - system events + heartbeat controls

View File

@@ -44,6 +44,7 @@ clawdbot channels logout --channel whatsapp
- Run `clawdbot status --deep` for a broad probe.
- Use `clawdbot doctor` for guided fixes.
- `clawdbot channels list` prints `Claude: HTTP 403 ... user:profile` → usage snapshot needs the `user:profile` scope. Use `--no-usage`, or provide a claude.ai session key (`CLAUDE_WEB_SESSION_KEY` / `CLAUDE_WEB_COOKIE`), or re-auth via Claude Code CLI.
## Capabilities probe

View File

@@ -29,6 +29,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`sessions`](/cli/sessions)
- [`gateway`](/cli/gateway)
- [`logs`](/cli/logs)
- [`system`](/cli/system)
- [`models`](/cli/models)
- [`memory`](/cli/memory)
- [`nodes`](/cli/nodes)
@@ -38,7 +39,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`sandbox`](/cli/sandbox)
- [`tui`](/cli/tui)
- [`browser`](/cli/browser)
- [`wake`](/cli/wake)
- [`cron`](/cli/cron)
- [`dns`](/cli/dns)
- [`docs`](/cli/docs)
@@ -145,6 +145,10 @@ clawdbot [--dev] [--profile <name>] <command>
restart
run
logs
system
event
heartbeat last|enable|disable
presence
models
list
status
@@ -160,7 +164,6 @@ clawdbot [--dev] [--profile <name>] <command>
list
recreate
explain
wake
cron
status
list
@@ -763,9 +766,9 @@ Options:
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
- `clear`: `--provider <name>`, `--agent <id>`
## Cron + wake
## System
### `wake`
### `system event`
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
Required:
@@ -776,7 +779,21 @@ Options:
- `--json`
- `--url`, `--token`, `--timeout`, `--expect-final`
### `cron`
### `system heartbeat last|enable|disable`
Heartbeat controls (Gateway RPC).
Options:
- `--json`
- `--url`, `--token`, `--timeout`, `--expect-final`
### `system presence`
List system presence entries (Gateway RPC).
Options:
- `--json`
- `--url`, `--token`, `--timeout`, `--expect-final`
## Cron
Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cron-jobs).
Subcommands:

View File

@@ -23,6 +23,24 @@ Common use cases:
Execution is still guarded by **exec approvals** and peragent allowlists on the
node host, so you can keep command access scoped and explicit.
## Browser proxy (zero-config)
Node hosts automatically advertise a browser proxy if `browser.enabled` is not
disabled on the node. This lets the agent use browser automation on that node
without extra configuration.
Disable it on the node if needed:
```json5
{
nodeHost: {
browserProxy: {
enabled: false
}
}
}
```
## Run (foreground)
```bash

55
docs/cli/system.md Normal file
View File

@@ -0,0 +1,55 @@
---
summary: "CLI reference for `clawdbot system` (system events, heartbeat, presence)"
read_when:
- You want to enqueue a system event without creating a cron job
- You need to enable or disable heartbeats
- You want to inspect system presence entries
---
# `clawdbot system`
System-level helpers for the Gateway: enqueue system events, control heartbeats,
and view presence.
## Common commands
```bash
clawdbot system event --text "Check for urgent follow-ups" --mode now
clawdbot system heartbeat enable
clawdbot system heartbeat last
clawdbot system presence
```
## `system event`
Enqueue a system event on the **main** session. The next heartbeat will inject
it as a `System:` line in the prompt. Use `--mode now` to trigger the heartbeat
immediately; `next-heartbeat` waits for the next scheduled tick.
Flags:
- `--text <text>`: required system event text.
- `--mode <mode>`: `now` or `next-heartbeat` (default).
- `--json`: machine-readable output.
## `system heartbeat last|enable|disable`
Heartbeat controls:
- `last`: show the last heartbeat event.
- `enable`: turn heartbeats back on (use this if they were disabled).
- `disable`: pause heartbeats.
Flags:
- `--json`: machine-readable output.
## `system presence`
List the current system presence entries the Gateway knows about (nodes,
instances, and similar status lines).
Flags:
- `--json`: machine-readable output.
## Notes
- Requires a running Gateway reachable by your current config (local or remote).
- System events are ephemeral and not persisted across restarts.

View File

@@ -1,35 +0,0 @@
---
summary: "CLI reference for `clawdbot wake` (enqueue a system event and optionally trigger an immediate heartbeat)"
read_when:
- You want to “poke” a running Gateway to process a system event
- You use `wake` with cron jobs or remote nodes
---
# `clawdbot wake`
Enqueue a system event on the Gateway and optionally trigger an immediate heartbeat.
This is a lightweight “poke” for automation flows where you dont want to run a full command, but you do want the Gateway to react quickly.
Related:
- Cron jobs: [Cron](/cli/cron)
- Gateway heartbeat: [Heartbeat](/gateway/heartbeat)
## Common commands
```bash
clawdbot wake --text "sync"
clawdbot wake --text "sync" --mode now
```
## Flags
- `--text <text>`: system event text.
- `--mode <mode>`: `now` or `next-heartbeat` (default).
- `--json`: machine-readable output.
## Notes
- Requires a running Gateway reachable by your current config (local or remote).
- If youre using sandboxing, `wake` still targets the Gateway; sandboxing does not block the command itself.

View File

@@ -850,12 +850,12 @@
"cli/memory",
"cli/models",
"cli/logs",
"cli/system",
"cli/nodes",
"cli/approvals",
"cli/gateway",
"cli/tui",
"cli/voicecall",
"cli/wake",
"cli/cron",
"cli/dns",
"cli/docs",

View File

@@ -162,6 +162,10 @@ If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
safe to include every 30 minutes.
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
If the file is missing, the heartbeat still runs and the model decides what to do.
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
Example `HEARTBEAT.md`:
@@ -195,7 +199,7 @@ Safety note: dont put secrets (API keys, phone numbers, private tokens) into
You can enqueue a system event and trigger an immediate heartbeat with:
```bash
clawdbot wake --text "Check for urgent follow-ups" --mode now
clawdbot system event --text "Check for urgent follow-ups" --mode now
```
If multiple agents have `heartbeat` configured, a manual wake runs each of those

View File

@@ -184,7 +184,7 @@ services:
[
"node",
"dist/index.js",
"gateway-daemon",
"gateway",
"--bind",
"${CLAWDBOT_GATEWAY_BIND}",
"--port",

View File

@@ -62,6 +62,7 @@ Plugins can register:
- Background services
- Optional config validation
- **Skills** (by listing `skills` directories in the plugin manifest)
- **Auto-reply commands** (execute without invoking the AI agent)
Plugins run **inprocess** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
@@ -494,6 +495,66 @@ export default function (api) {
}
```
### Register auto-reply commands
Plugins can register custom slash commands that execute **without invoking the
AI agent**. This is useful for toggle commands, status checks, or quick actions
that don't need LLM processing.
```ts
export default function (api) {
api.registerCommand({
name: "mystatus",
description: "Show plugin status",
handler: (ctx) => ({
text: `Plugin is running! Channel: ${ctx.channel}`,
}),
});
}
```
Command handler context:
- `senderId`: The sender's ID (if available)
- `channel`: The channel where the command was sent
- `isAuthorizedSender`: Whether the sender is an authorized user
- `args`: Arguments passed after the command (if `acceptsArgs: true`)
- `commandBody`: The full command text
- `config`: The current Clawdbot config
Command options:
- `name`: Command name (without the leading `/`)
- `description`: Help text shown in command lists
- `acceptsArgs`: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
- `requireAuth`: Whether to require authorized sender (default: true)
- `handler`: Function that returns `{ text: string }` (can be async)
Example with authorization and arguments:
```ts
api.registerCommand({
name: "setmode",
description: "Set plugin mode",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx) => {
const mode = ctx.args?.trim() || "default";
await saveMode(mode);
return { text: `Mode set to: ${mode}` };
},
});
```
Notes:
- Plugin commands are processed **before** built-in commands and the AI agent
- Commands are registered globally and work across all channels
- Command names are case-insensitive (`/MyStatus` matches `/mystatus`)
- Command names must start with a letter and contain only letters, numbers, hyphens, and underscores
- Telegram native commands only allow `a-z0-9_` (max 32 chars). Use underscores (not hyphens) if you want a plugin command to appear in Telegrams native command list.
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
- Duplicate command registration across plugins will fail with a diagnostic error
### Register background services
```ts

View File

@@ -5,4 +5,5 @@ read_when:
---
# HEARTBEAT.md
Keep this file empty unless you want a tiny checklist. Keep it small.
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.

View File

@@ -7,11 +7,16 @@ read_when:
*Fill this in during your first conversation. Make it yours.*
- **Name:** *(pick something you like)*
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
- **Emoji:** *(your signature — pick one that feels right)*
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
- **Name:**
*(pick something you like)*
- **Creature:**
*(AI? robot? familiar? ghost in the machine? something weirder?)*
- **Vibe:**
*(how do you come across? sharp? warm? chaotic? calm?)*
- **Emoji:**
*(your signature — pick one that feels right)*
- **Avatar:**
*(workspace-relative path, http(s) URL, or data URI)*
---

View File

@@ -182,6 +182,8 @@ By default, Clawdbot runs a heartbeat every 30 minutes with the 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.`
Set `agents.defaults.heartbeat.every: "0m"` to disable.
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
- If the file is missing, the heartbeat still runs and the model decides what to do.
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
- Heartbeats run full agent turns — shorter intervals burn more tokens.

View File

@@ -971,6 +971,10 @@ Heartbeats run every **30m** by default. Tune or disable them:
}
```
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
If the file is missing, the heartbeat still runs and the model decides what to do.
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
### Do I need to add a “bot account” to a WhatsApp group?

View File

@@ -166,6 +166,19 @@ Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
to the CDP WebSocket. Prefer environment variables or secrets managers for
tokens instead of committing them to config files.
### Node browser proxy (zero-config default)
If you run a **node host** on the machine that has your browser, Clawdbot can
auto-route browser tool calls to that node without any custom `controlUrl`
setup. This is the default path for remote gateways.
Notes:
- The node host exposes its local browser control server via a **proxy command**.
- Profiles come from the nodes own `browser.profiles` config (same as local).
- Disable if you dont want it:
- On the node: `nodeHost.browserProxy.enabled=false`
- On the gateway: `gateway.nodes.browser.mode="off"`
### Browserless (hosted remote CDP)
[Browserless](https://browserless.io) is a hosted Chromium service that exposes

View File

@@ -12,6 +12,7 @@ Exec approvals are the **companion app / node host guardrail** for letting a san
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
commands are allowed only when policy + allowlist + (optional) user approval all agree.
Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals).
Effective policy is the **stricter** of `tools.exec.*` and approvals defaults; if an approvals field is omitted, the `tools.exec` value is used.
If the companion app UI is **not available**, any request that requires a prompt is
resolved by the **ask fallback** (default: deny).

View File

@@ -10,6 +10,7 @@ import {
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
@@ -62,6 +63,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
},
groups: {
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({

View File

@@ -1,4 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -21,6 +21,7 @@ const bluebubblesActionSchema = z
const bluebubblesGroupConfigSchema = z.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
});
const bluebubblesAccountSchema = z.object({

View File

@@ -4,6 +4,8 @@ export type GroupPolicy = "open" | "disabled" | "allowlist";
export type BlueBubblesGroupConfig = {
/** If true, only respond in this group when mentioned. */
requireMention?: boolean;
/** Optional tool policy overrides for this group. */
tools?: { allow?: string[]; deny?: string[] };
};
export type BlueBubblesAccountConfig = {

View File

@@ -20,6 +20,7 @@ import {
resolveDiscordAccount,
resolveDefaultDiscordAccountId,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelPlugin,
@@ -144,6 +145,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
resolveToolPolicy: resolveDiscordGroupToolPolicy,
},
mentions: {
stripPatterns: () => ["<@!?\\d+>"],

View File

@@ -15,6 +15,7 @@ import {
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type ResolvedIMessageAccount,
@@ -106,6 +107,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
resolveToolPolicy: resolveIMessageGroupToolPolicy,
},
messaging: {
targetResolver: {

View File

@@ -12,7 +12,7 @@ import {
import { matrixMessageActions } from "./actions.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js";
import type { CoreConfig } from "./types.js";
import {
listMatrixAccountIds,
@@ -167,6 +167,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
},
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg }) =>

View File

@@ -1,4 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -26,6 +26,7 @@ const matrixRoomSchema = z
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
autoReply: z.boolean().optional(),
users: z.array(allowFromEntry).optional(),
skills: z.array(z.string()).optional(),

View File

@@ -1,4 +1,4 @@
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
import type { ChannelGroupContext, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
import type { CoreConfig } from "./types.js";
@@ -32,3 +32,30 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
}
return true;
}
export function resolveMatrixGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const rawGroupId = params.groupId?.trim() ?? "";
let roomId = rawGroupId;
const lower = roomId.toLowerCase();
if (lower.startsWith("matrix:")) {
roomId = roomId.slice("matrix:".length).trim();
}
if (roomId.toLowerCase().startsWith("channel:")) {
roomId = roomId.slice("channel:".length).trim();
}
if (roomId.toLowerCase().startsWith("room:")) {
roomId = roomId.slice("room:".length).trim();
}
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
roomId,
aliases,
name: groupChannel || undefined,
}).config;
return resolved?.tools;
}

View File

@@ -18,6 +18,8 @@ export type MatrixRoomConfig = {
allow?: boolean;
/** Require mentioning the bot to trigger replies. */
requireMention?: boolean;
/** Optional tool policy overrides for this room. */
tools?: { allow?: string[]; deny?: string[] };
/** If true, reply without mention requirements. */
autoReply?: boolean;
/** Optional allowlist for room senders (user IDs or localparts). */

View File

@@ -9,6 +9,7 @@ import {
import { msteamsOnboardingAdapter } from "./onboarding.js";
import { msteamsOutbound } from "./outbound.js";
import { probeMSTeams } from "./probe.js";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import {
normalizeMSTeamsMessagingTarget,
normalizeMSTeamsUserInput,
@@ -77,6 +78,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
hasRepliedRef,
}),
},
groups: {
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
},
reload: { configPrefixes: ["channels.msteams"] },
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
config: {

View File

@@ -1,6 +1,8 @@
import type {
AllowlistMatch,
ChannelGroupContext,
GroupPolicy,
GroupToolPolicyConfig,
MSTeamsChannelConfig,
MSTeamsConfig,
MSTeamsReplyStyle,
@@ -86,6 +88,50 @@ export function resolveMSTeamsRouteConfig(params: {
};
}
export function resolveMSTeamsGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const cfg = params.cfg.channels?.msteams;
if (!cfg) return undefined;
const groupId = params.groupId?.trim();
const groupChannel = params.groupChannel?.trim();
const groupSpace = params.groupSpace?.trim();
const resolved = resolveMSTeamsRouteConfig({
cfg,
teamId: groupSpace,
teamName: groupSpace,
conversationId: groupId,
channelName: groupChannel,
});
if (resolved.channelConfig) {
return resolved.channelConfig.tools ?? resolved.teamConfig?.tools;
}
if (resolved.teamConfig?.tools) return resolved.teamConfig.tools;
if (!groupId) return undefined;
const channelCandidates = buildChannelKeyCandidates(
groupId,
groupChannel,
groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
);
for (const teamConfig of Object.values(cfg.teams ?? {})) {
const match = resolveChannelEntryMatchWithFallback({
entries: teamConfig?.channels ?? {},
keys: channelCandidates,
wildcardKey: "*",
normalizeKey: normalizeChannelSlug,
});
if (match.entry) {
return match.entry.tools ?? teamConfig?.tools;
}
}
return undefined;
}
export type MSTeamsReplyPolicy = {
requireMention: boolean;
replyStyle: MSTeamsReplyStyle;

View File

@@ -24,6 +24,7 @@ import { nextcloudTalkOnboardingAdapter } from "./onboarding.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { sendMessageNextcloudTalk } from "./send.js";
import type { CoreConfig } from "./types.js";
import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js";
const meta = {
id: "nextcloud-talk",
@@ -159,6 +160,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
return true;
},
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
},
messaging: {
normalizeTarget: normalizeNextcloudTalkMessagingTarget,

View File

@@ -4,6 +4,7 @@ import {
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
ToolPolicySchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
import { z } from "zod";
@@ -11,6 +12,7 @@ import { z } from "zod";
export const NextcloudTalkRoomSchema = z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),

View File

@@ -1,4 +1,4 @@
import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk";
import type { AllowlistMatch, ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
import {
buildChannelKeyCandidates,
normalizeChannelSlug,
@@ -86,6 +86,21 @@ export function resolveNextcloudTalkRoomMatch(params: {
};
}
export function resolveNextcloudTalkGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const cfg = params.cfg as { channels?: { "nextcloud-talk"?: { rooms?: Record<string, NextcloudTalkRoomConfig> } } };
const roomToken = params.groupId?.trim();
if (!roomToken) return undefined;
const roomName = params.groupChannel?.trim() || undefined;
const match = resolveNextcloudTalkRoomMatch({
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
roomToken,
roomName,
});
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
}
export function resolveNextcloudTalkRequireMention(params: {
roomConfig?: NextcloudTalkRoomConfig;
wildcardConfig?: NextcloudTalkRoomConfig;

View File

@@ -7,6 +7,8 @@ import type {
export type NextcloudTalkRoomConfig = {
requireMention?: boolean;
/** Optional tool policy overrides for this room. */
tools?: { allow?: string[]; deny?: string[] };
/** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this room. */

View File

@@ -21,6 +21,7 @@ import {
resolveSlackAccount,
resolveSlackReplyToMode,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
buildSlackThreadingToolContext,
setAccountEnabledInConfigSection,
slackOnboardingAdapter,
@@ -161,6 +162,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>

View File

@@ -17,6 +17,7 @@ import {
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
setAccountEnabledInConfigSection,
telegramOnboardingAdapter,
TelegramConfigSchema,
@@ -154,6 +155,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
resolveToolPolicy: resolveTelegramGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",

View File

@@ -21,6 +21,7 @@ import {
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAccount,
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
resolveWhatsAppHeartbeatRecipients,
whatsappOnboardingAdapter,
WhatsAppConfigSchema,
@@ -198,6 +199,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
},
groups: {
resolveRequireMention: resolveWhatsAppGroupRequireMention,
resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
resolveGroupIntroHint: () =>
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
},

View File

@@ -2,8 +2,10 @@ import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
ChannelDock,
ChannelGroupContext,
ChannelPlugin,
ClawdbotConfig,
GroupToolPolicyConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
@@ -79,6 +81,26 @@ function mapGroup(params: {
};
}
function resolveZalouserGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const account = resolveZalouserAccountSync({
cfg: params.cfg as ClawdbotConfig,
accountId: params.accountId ?? undefined,
});
const groups = account.config.groups ?? {};
const groupId = params.groupId?.trim();
const groupChannel = params.groupChannel?.trim();
const candidates = [groupId, groupChannel, "*"].filter(
(value): value is string => Boolean(value),
);
for (const key of candidates) {
const entry = groups[key];
if (entry?.tools) return entry.tools;
}
return undefined;
}
export const zalouserDock: ChannelDock = {
id: "zalouser",
capabilities: {
@@ -101,6 +123,7 @@ export const zalouserDock: ChannelDock = {
},
groups: {
resolveRequireMention: () => true,
resolveToolPolicy: resolveZalouserGroupToolPolicy,
},
threading: {
resolveReplyToMode: () => "off",
@@ -188,6 +211,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
groups: {
resolveRequireMention: () => true,
resolveToolPolicy: resolveZalouserGroupToolPolicy,
},
threading: {
resolveReplyToMode: () => "off",

View File

@@ -1,4 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -6,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
const groupConfigSchema = z.object({
allow: z.boolean().optional(),
enabled: z.boolean().optional(),
tools: ToolPolicySchema,
});
const zalouserAccountSchema = z.object({

View File

@@ -75,7 +75,7 @@ export type ZalouserAccountConfig = {
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
messagePrefix?: string;
};
@@ -87,7 +87,7 @@ export type ZalouserConfig = {
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
messagePrefix?: string;
accounts?: Record<string, ZalouserAccountConfig>;
};

View File

@@ -129,4 +129,25 @@ describe("exec approvals", () => {
expect(calls).toContain("node.invoke");
expect(calls).not.toContain("exec.approval.request");
});
it("honors ask=off for elevated gateway exec without prompting", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
ask: "off",
security: "full",
approvalRunningNoticeMs: 0,
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const result = await tool.execute("call3", { command: "echo ok", elevated: true });
expect(result.details.status).toBe("completed");
expect(calls).not.toContain("exec.approval.request");
});
});

View File

@@ -838,10 +838,7 @@ export function createExecTool(
applyPathPrepend(env, defaultPathPrepend);
if (host === "node") {
const approvals = resolveExecApprovals(
agentId,
host === "node" ? { security: "allowlist" } : undefined,
);
const approvals = resolveExecApprovals(agentId, { security, ask });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
@@ -1112,7 +1109,7 @@ export function createExecTool(
}
if (host === "gateway" && !bypassApprovals) {
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const approvals = resolveExecApprovals(agentId, { security, ask });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;

View File

@@ -31,6 +31,12 @@ export function createClawdbotTools(options?: {
agentTo?: string;
/** Thread/topic identifier for routing replies to the originating thread. */
agentThreadId?: string | number;
/** Group id for channel-level tool policy inheritance. */
agentGroupId?: string | null;
/** Group channel label for channel-level tool policy inheritance. */
agentGroupChannel?: string | null;
/** Group space label for channel-level tool policy inheritance. */
agentGroupSpace?: string | null;
agentDir?: string;
sandboxRoot?: string;
workspaceDir?: string;
@@ -114,6 +120,9 @@ export function createClawdbotTools(options?: {
agentAccountId: options?.agentAccountId,
agentTo: options?.agentTo,
agentThreadId: options?.agentThreadId,
agentGroupId: options?.agentGroupId,
agentGroupChannel: options?.agentGroupChannel,
agentGroupSpace: options?.agentGroupSpace,
sandboxed: options?.sandboxed,
}),
createSessionStatusTool({

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { parseIdentityMarkdown } from "./identity-file.js";
describe("parseIdentityMarkdown", () => {
it("ignores identity template placeholders", () => {
const content = `
# IDENTITY.md - Who Am I?
- **Name:** *(pick something you like)*
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
- **Emoji:** *(your signature - pick one that feels right)*
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
`;
const parsed = parseIdentityMarkdown(content);
expect(parsed).toEqual({});
});
it("parses explicit identity values", () => {
const content = `
- **Name:** Samantha
- **Creature:** Robot
- **Vibe:** Warm
- **Emoji:** :robot:
- **Avatar:** avatars/clawd.png
`;
const parsed = parseIdentityMarkdown(content);
expect(parsed).toEqual({
name: "Samantha",
creature: "Robot",
vibe: "Warm",
emoji: ":robot:",
avatar: "avatars/clawd.png",
});
});
});

View File

@@ -12,6 +12,30 @@ export type AgentIdentityFile = {
avatar?: string;
};
const IDENTITY_PLACEHOLDER_VALUES = new Set([
"pick something you like",
"ai? robot? familiar? ghost in the machine? something weirder?",
"how do you come across? sharp? warm? chaotic? calm?",
"your signature - pick one that feels right",
"workspace-relative path, http(s) url, or data uri",
]);
function normalizeIdentityValue(value: string): string {
let normalized = value.trim();
normalized = normalized.replace(/^[*_]+|[*_]+$/g, "").trim();
if (normalized.startsWith("(") && normalized.endsWith(")")) {
normalized = normalized.slice(1, -1).trim();
}
normalized = normalized.replace(/[\u2013\u2014]/g, "-");
normalized = normalized.replace(/\s+/g, " ").toLowerCase();
return normalized;
}
function isIdentityPlaceholder(value: string): boolean {
const normalized = normalizeIdentityValue(value);
return IDENTITY_PLACEHOLDER_VALUES.has(normalized);
}
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
const identity: AgentIdentityFile = {};
const lines = content.split(/\r?\n/);
@@ -25,6 +49,7 @@ export function parseIdentityMarkdown(content: string): AgentIdentityFile {
.replace(/^[*_]+|[*_]+$/g, "")
.trim();
if (!value) continue;
if (isIdentityPlaceholder(value)) continue;
if (label === "name") identity.name = value;
if (label === "emoji") identity.emoji = value;
if (label === "creature") identity.creature = value;

View File

@@ -63,12 +63,12 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
...overrides,
});
const makeConfig = (): ClawdbotConfig =>
const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotConfig =>
({
agents: {
defaults: {
model: {
fallbacks: [],
fallbacks: opts?.fallbacks ?? [],
},
},
},
@@ -76,7 +76,7 @@ const makeConfig = (): ClawdbotConfig =>
providers: {
openai: {
api: "openai-responses",
apiKey: "sk-test",
apiKey: opts?.apiKey ?? "sk-test",
baseUrl: "https://example.com",
models: [
{
@@ -94,7 +94,13 @@ const makeConfig = (): ClawdbotConfig =>
},
}) satisfies ClawdbotConfig;
const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
const writeAuthStore = async (
agentDir: string,
opts?: {
includeAnthropic?: boolean;
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
},
) => {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
@@ -105,10 +111,12 @@ const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: bool
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
: {}),
},
usageStats: {
"openai:p1": { lastUsed: 1 },
"openai:p2": { lastUsed: 2 },
},
usageStats:
opts?.usageStats ??
({
"openai:p1": { lastUsed: 1 },
"openai:p2": { lastUsed: 2 },
} as Record<string, { lastUsed?: number }>),
};
await fs.writeFile(authPath, JSON.stringify(payload));
};
@@ -384,6 +392,92 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}
});
it("fails over when all profiles are in cooldown and fallbacks are configured", async () => {
vi.useFakeTimers();
try {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const now = Date.now();
vi.setSystemTime(now);
try {
await writeAuthStore(agentDir, {
usageStats: {
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
"openai:p2": { lastUsed: 2, cooldownUntil: now + 60 * 60 * 1000 },
},
});
await expect(
runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:cooldown-failover",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileIdSource: "auto",
timeoutMs: 5_000,
runId: "run:cooldown-failover",
}),
).rejects.toMatchObject({
name: "FailoverError",
reason: "rate_limit",
provider: "openai",
model: "mock-1",
});
expect(runEmbeddedAttemptMock).not.toHaveBeenCalled();
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
} finally {
vi.useRealTimers();
}
});
it("fails over when auth is unavailable and fallbacks are configured", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const previousOpenAiKey = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
const authPath = path.join(agentDir, "auth-profiles.json");
await fs.writeFile(authPath, JSON.stringify({ version: 1, profiles: {}, usageStats: {} }));
await expect(
runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:auth-unavailable",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig({ fallbacks: ["openai/mock-2"], apiKey: "" }),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileIdSource: "auto",
timeoutMs: 5_000,
runId: "run:auth-unavailable",
}),
).rejects.toMatchObject({ name: "FailoverError", reason: "auth" });
expect(runEmbeddedAttemptMock).not.toHaveBeenCalled();
} finally {
if (previousOpenAiKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = previousOpenAiKey;
}
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("skips profiles in cooldown when rotating after failure", async () => {
vi.useFakeTimers();
try {

View File

@@ -73,6 +73,14 @@ export async function compactEmbeddedPiSession(params: {
messageChannel?: string;
messageProvider?: string;
agentAccountId?: string;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
sessionFile: string;
workspaceDir: string;
agentDir?: string;
@@ -207,6 +215,10 @@ export async function compactEmbeddedPiSession(params: {
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,

View File

@@ -38,6 +38,7 @@ import {
isRateLimitAssistantError,
isTimeoutErrorMessage,
pickFallbackThinkingLevel,
type FailoverReason,
} from "../pi-embedded-helpers.js";
import { normalizeUsage, type UsageLike } from "../usage.js";
@@ -92,6 +93,8 @@ export async function runEmbeddedPiAgent(
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
const fallbackConfigured =
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
await ensureClawdbotModelsJson(params.config, agentDir);
const { model, error, authStorage, modelRegistry } = resolveModel(
@@ -165,6 +168,42 @@ export async function runEmbeddedPiAgent(
let apiKeyInfo: ApiKeyInfo | null = null;
let lastProfileId: string | undefined;
const resolveAuthProfileFailoverReason = (params: {
allInCooldown: boolean;
message: string;
}): FailoverReason => {
if (params.allInCooldown) return "rate_limit";
const classified = classifyFailoverReason(params.message);
return classified ?? "auth";
};
const throwAuthProfileFailover = (params: {
allInCooldown: boolean;
message?: string;
error?: unknown;
}): never => {
const fallbackMessage = `No available auth profile for ${provider} (all in cooldown or unavailable).`;
const message =
params.message?.trim() ||
(params.error ? describeUnknownError(params.error).trim() : "") ||
fallbackMessage;
const reason = resolveAuthProfileFailoverReason({
allInCooldown: params.allInCooldown,
message,
});
if (fallbackConfigured) {
throw new FailoverError(message, {
reason,
provider,
model: modelId,
status: resolveFailoverStatus(reason),
cause: params.error,
});
}
if (params.error instanceof Error) throw params.error;
throw new Error(message);
};
const resolveApiKeyForCandidate = async (candidate?: string) => {
return getApiKeyForModel({
model,
@@ -238,14 +277,17 @@ export async function runEmbeddedPiAgent(
break;
}
if (profileIndex >= profileCandidates.length) {
throw new Error(
`No available auth profile for ${provider} (all in cooldown or unavailable).`,
);
throwAuthProfileFailover({ allInCooldown: true });
}
} catch (err) {
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
if (err instanceof FailoverError) throw err;
if (profileCandidates[profileIndex] === lockedProfileId) {
throwAuthProfileFailover({ allInCooldown: false, error: err });
}
const advanced = await advanceAuthProfile();
if (!advanced) throw err;
if (!advanced) {
throwAuthProfileFailover({ allInCooldown: false, error: err });
}
}
try {
@@ -264,6 +306,10 @@ export async function runEmbeddedPiAgent(
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode,
@@ -389,9 +435,7 @@ export async function runEmbeddedPiAgent(
}
// FIX: Throw FailoverError for prompt errors when fallbacks configured
// This enables model fallback for quota/rate limit errors during prompt submission
const promptFallbackConfigured =
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
if (promptFallbackConfigured && isFailoverErrorMessage(errorText)) {
if (fallbackConfigured && isFailoverErrorMessage(errorText)) {
throw new FailoverError(errorText, {
reason: promptFailoverReason ?? "unknown",
provider,
@@ -415,8 +459,6 @@ export async function runEmbeddedPiAgent(
continue;
}
const fallbackConfigured =
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
const authFailure = isAuthAssistantError(lastAssistant);
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
const failoverFailure = isFailoverAssistantError(lastAssistant);

View File

@@ -208,6 +208,10 @@ export async function runEmbeddedAttempt(
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
workspaceDir: effectiveWorkspace,

View File

@@ -27,6 +27,14 @@ export type RunEmbeddedPiAgentParams = {
messageTo?: string;
/** Thread/topic identifier for routing replies to the originating thread. */
messageThreadId?: string | number;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */

View File

@@ -23,6 +23,14 @@ export type EmbeddedRunAttemptParams = {
agentAccountId?: string;
messageTo?: string;
messageThreadId?: string | number;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";

View File

@@ -43,7 +43,7 @@ export function abortEmbeddedPiRun(sessionId: string): boolean {
diag.debug(`abort failed: sessionId=${sessionId} reason=no_active_run`);
return false;
}
diag.info(`aborting run: sessionId=${sessionId}`);
diag.debug(`aborting run: sessionId=${sessionId}`);
handle.abort();
return true;
}
@@ -110,7 +110,7 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
reason: wasActive ? "run_replaced" : "run_started",
});
if (!sessionId.startsWith("probe-")) {
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
diag.debug(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
}
@@ -119,7 +119,7 @@ export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueu
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
if (!sessionId.startsWith("probe-")) {
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
diag.debug(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
notifyEmbeddedRunEnded(sessionId);
} else {

View File

@@ -231,6 +231,95 @@ describe("Agent-specific tool filtering", () => {
expect(familyToolNames).not.toContain("apply_patch");
});
it("should apply group tool policy overrides (group-specific beats wildcard)", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
groups: {
"*": {
tools: { allow: ["read"] },
},
trusted: {
tools: { allow: ["read", "exec"] },
},
},
},
},
};
const trustedTools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:whatsapp:group:trusted",
messageProvider: "whatsapp",
workspaceDir: "/tmp/test-group-trusted",
agentDir: "/tmp/agent-group",
});
const trustedNames = trustedTools.map((t) => t.name);
expect(trustedNames).toContain("read");
expect(trustedNames).toContain("exec");
const defaultTools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:whatsapp:group:unknown",
messageProvider: "whatsapp",
workspaceDir: "/tmp/test-group-default",
agentDir: "/tmp/agent-group",
});
const defaultNames = defaultTools.map((t) => t.name);
expect(defaultNames).toContain("read");
expect(defaultNames).not.toContain("exec");
});
it("should resolve telegram group tool policy for topic session keys", () => {
const cfg: ClawdbotConfig = {
channels: {
telegram: {
groups: {
"123": {
tools: { allow: ["read"] },
},
},
},
},
};
const tools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:telegram:group:123:topic:456",
messageProvider: "telegram",
workspaceDir: "/tmp/test-telegram-topic",
agentDir: "/tmp/agent-telegram",
});
const names = tools.map((t) => t.name);
expect(names).toContain("read");
expect(names).not.toContain("exec");
});
it("should inherit group tool policy for subagents from spawnedBy session keys", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
groups: {
trusted: {
tools: { allow: ["read"] },
},
},
},
},
};
const tools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:subagent:test",
spawnedBy: "agent:main:whatsapp:group:trusted",
workspaceDir: "/tmp/test-subagent-group",
agentDir: "/tmp/agent-subagent",
});
const names = tools.map((t) => t.name);
expect(names).toContain("read");
expect(names).not.toContain("exec");
});
it("should apply global tool policy before agent-specific policy", () => {
const cfg: ClawdbotConfig = {
tools: {

View File

@@ -1,8 +1,12 @@
import type { ClawdbotConfig } from "../config/config.js";
import { getChannelDock } from "../channels/dock.js";
import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js";
import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxToolPolicy } from "./sandbox.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js";
type CompiledPattern =
| { kind: "all" }
@@ -108,6 +112,26 @@ function normalizeProviderKey(value: string): string {
return value.trim().toLowerCase();
}
function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
channel?: string;
groupId?: string;
} {
const raw = (sessionKey ?? "").trim();
if (!raw) return {};
const base = resolveThreadParentSessionKey(raw) ?? raw;
const parts = base.split(":").filter(Boolean);
let body = parts[0] === "agent" ? parts.slice(2) : parts;
if (body[0] === "subagent") {
body = body.slice(1);
}
if (body.length < 3) return {};
const [channel, kind, ...rest] = body;
if (kind !== "group" && kind !== "channel") return {};
const groupId = rest.join(":").trim();
if (!groupId) return {};
return { channel: channel.trim().toLowerCase(), groupId };
}
function resolveProviderToolPolicy(params: {
byProvider?: Record<string, ToolPolicyConfig>;
modelProvider?: string;
@@ -174,6 +198,47 @@ export function resolveEffectiveToolPolicy(params: {
};
}
export function resolveGroupToolPolicy(params: {
config?: ClawdbotConfig;
sessionKey?: string;
spawnedBy?: string | null;
messageProvider?: string;
groupId?: string | null;
groupChannel?: string | null;
groupSpace?: string | null;
accountId?: string | null;
}): SandboxToolPolicy | undefined {
if (!params.config) return undefined;
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
const spawnedContext = resolveGroupContextFromSessionKey(params.spawnedBy);
const groupId = params.groupId ?? sessionContext.groupId ?? spawnedContext.groupId;
if (!groupId) return undefined;
const channelRaw = params.messageProvider ?? sessionContext.channel ?? spawnedContext.channel;
const channel = normalizeMessageChannel(channelRaw);
if (!channel) return undefined;
let dock;
try {
dock = getChannelDock(channel);
} catch {
dock = undefined;
}
const toolsConfig =
dock?.groups?.resolveToolPolicy?.({
cfg: params.config,
groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
accountId: params.accountId,
}) ??
resolveChannelGroupToolsPolicy({
cfg: params.config,
channel,
groupId,
accountId: params.accountId,
});
return pickToolPolicy(toolsConfig);
}
export function isToolAllowedByPolicies(
name: string,
policies: Array<SandboxToolPolicy | undefined>,

View File

@@ -23,6 +23,7 @@ import {
filterToolsByPolicy,
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveSubagentToolPolicy,
} from "./pi-tools.policy.js";
import {
@@ -128,6 +129,14 @@ export function createClawdbotCodingTools(options?: {
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Parent session key for subagent group policy inheritance. */
spawnedBy?: string | null;
/** Reply-to mode for Slack auto-threading. */
replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */
@@ -151,6 +160,16 @@ export function createClawdbotCodingTools(options?: {
modelProvider: options?.modelProvider,
modelId: options?.modelId,
});
const groupPolicy = resolveGroupToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
spawnedBy: options?.spawnedBy,
messageProvider: options?.messageProvider,
groupId: options?.groupId,
groupChannel: options?.groupChannel,
groupSpace: options?.groupSpace,
accountId: options?.agentAccountId,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
@@ -165,6 +184,7 @@ export function createClawdbotCodingTools(options?: {
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandbox?.tools,
subagentPolicy,
]);
@@ -273,6 +293,9 @@ export function createClawdbotCodingTools(options?: {
agentAccountId: options?.agentAccountId,
agentTo: options?.messageTo,
agentThreadId: options?.messageThreadId,
agentGroupId: options?.groupId ?? null,
agentGroupChannel: options?.groupChannel ?? null,
agentGroupSpace: options?.groupSpace ?? null,
agentDir: options?.agentDir,
sandboxRoot,
workspaceDir: options?.workspaceDir,
@@ -285,6 +308,7 @@ export function createClawdbotCodingTools(options?: {
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandbox?.tools,
subagentPolicy,
]),
@@ -323,6 +347,10 @@ export function createClawdbotCodingTools(options?: {
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
pluginGroups,
);
const groupPolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(groupPolicy, pluginGroups),
pluginGroups,
);
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
@@ -344,9 +372,12 @@ export function createClawdbotCodingTools(options?: {
const agentProviderFiltered = agentProviderExpanded
? filterToolsByPolicy(agentFiltered, agentProviderExpanded)
: agentFiltered;
const sandboxed = sandboxPolicyExpanded
? filterToolsByPolicy(agentProviderFiltered, sandboxPolicyExpanded)
const groupFiltered = groupPolicyExpanded
? filterToolsByPolicy(agentProviderFiltered, groupPolicyExpanded)
: agentProviderFiltered;
const sandboxed = sandboxPolicyExpanded
? filterToolsByPolicy(groupFiltered, sandboxPolicyExpanded)
: groupFiltered;
const subagentFiltered = subagentPolicyExpanded
? filterToolsByPolicy(sandboxed, subagentPolicyExpanded)
: sandboxed;

View File

@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
"act",
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
@@ -84,6 +84,7 @@ const BrowserActSchema = Type.Object({
export const BrowserToolSchema = Type.Object({
action: stringEnum(BROWSER_TOOL_ACTIONS),
target: optionalStringEnum(BROWSER_TARGETS),
node: Type.Optional(Type.String()),
profile: Type.Optional(Type.String()),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),

View File

@@ -49,6 +49,25 @@ const browserConfigMocks = vi.hoisted(() => ({
}));
vi.mock("../../browser/config.js", () => browserConfigMocks);
const nodesUtilsMocks = vi.hoisted(() => ({
listNodes: vi.fn(async () => []),
}));
vi.mock("./nodes-utils.js", async () => {
const actual = await vi.importActual<typeof import("./nodes-utils.js")>("./nodes-utils.js");
return {
...actual,
listNodes: nodesUtilsMocks.listNodes,
};
});
const gatewayMocks = vi.hoisted(() => ({
callGatewayTool: vi.fn(async () => ({
ok: true,
payload: { result: { ok: true, running: true } },
})),
}));
vi.mock("./gateway.js", () => gatewayMocks);
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({ browser: {} })),
}));
@@ -72,6 +91,7 @@ describe("browser tool snapshot maxChars", () => {
afterEach(() => {
vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
nodesUtilsMocks.listNodes.mockResolvedValue([]);
});
it("applies the default ai snapshot limit", async () => {
@@ -175,6 +195,70 @@ describe("browser tool snapshot maxChars", () => {
}),
);
});
it("routes to node proxy when target=node", async () => {
nodesUtilsMocks.listNodes.mockResolvedValue([
{
nodeId: "node-1",
displayName: "Browser Node",
connected: true,
caps: ["browser"],
commands: ["browser.proxy"],
},
]);
const tool = createBrowserTool();
await tool.execute?.(null, { action: "status", target: "node" });
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 20000 },
expect.objectContaining({
nodeId: "node-1",
command: "browser.proxy",
}),
);
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
});
it("keeps sandbox control url when node proxy is available", async () => {
nodesUtilsMocks.listNodes.mockResolvedValue([
{
nodeId: "node-1",
displayName: "Browser Node",
connected: true,
caps: ["browser"],
commands: ["browser.proxy"],
},
]);
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
await tool.execute?.(null, { action: "status" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
"http://127.0.0.1:9999",
expect.objectContaining({ profile: undefined }),
);
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
it("keeps chrome profile on host when node proxy is available", async () => {
nodesUtilsMocks.listNodes.mockResolvedValue([
{
nodeId: "node-1",
displayName: "Browser Node",
connected: true,
caps: ["browser"],
commands: ["browser.proxy"],
},
]);
const tool = createBrowserTool();
await tool.execute?.(null, { action: "status", profile: "chrome" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
expect.objectContaining({ profile: "chrome" }),
);
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
});
describe("browser tool snapshot labels", () => {

View File

@@ -18,11 +18,173 @@ import {
browserPdfSave,
browserScreenshotAction,
} from "../../browser/client-actions.js";
import crypto from "node:crypto";
import { resolveBrowserConfig } from "../../browser/config.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { loadConfig } from "../../config/config.js";
import { saveMediaBuffer } from "../../media/store.js";
import { listNodes, resolveNodeIdFromList, type NodeListNode } from "./nodes-utils.js";
import { BrowserToolSchema } from "./browser-tool.schema.js";
import { type AnyAgentTool, imageResultFromFile, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool } from "./gateway.js";
type BrowserProxyFile = {
path: string;
base64: string;
mimeType?: string;
};
type BrowserProxyResult = {
result: unknown;
files?: BrowserProxyFile[];
};
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
type BrowserNodeTarget = {
nodeId: string;
label?: string;
};
function isBrowserNode(node: NodeListNode) {
const caps = Array.isArray(node.caps) ? node.caps : [];
const commands = Array.isArray(node.commands) ? node.commands : [];
return caps.includes("browser") || commands.includes("browser.proxy");
}
async function resolveBrowserNodeTarget(params: {
requestedNode?: string;
target?: "sandbox" | "host" | "custom" | "node";
controlUrl?: string;
defaultControlUrl?: string;
}): Promise<BrowserNodeTarget | null> {
const cfg = loadConfig();
const policy = cfg.gateway?.nodes?.browser;
const mode = policy?.mode ?? "auto";
if (mode === "off") {
if (params.target === "node" || params.requestedNode) {
throw new Error("Node browser proxy is disabled (gateway.nodes.browser.mode=off).");
}
return null;
}
if (params.defaultControlUrl?.trim() && params.target !== "node" && !params.requestedNode) {
return null;
}
if (params.controlUrl?.trim()) return null;
if (params.target && params.target !== "node") return null;
if (mode === "manual" && params.target !== "node" && !params.requestedNode) {
return null;
}
const nodes = await listNodes({});
const browserNodes = nodes.filter((node) => node.connected && isBrowserNode(node));
if (browserNodes.length === 0) {
if (params.target === "node" || params.requestedNode) {
throw new Error("No connected browser-capable nodes.");
}
return null;
}
const requested = params.requestedNode?.trim() || policy?.node?.trim();
if (requested) {
const nodeId = resolveNodeIdFromList(browserNodes, requested, false);
const node = browserNodes.find((entry) => entry.nodeId === nodeId);
return { nodeId, label: node?.displayName ?? node?.remoteIp ?? nodeId };
}
if (params.target === "node") {
if (browserNodes.length === 1) {
const node = browserNodes[0]!;
return { nodeId: node.nodeId, label: node.displayName ?? node.remoteIp ?? node.nodeId };
}
throw new Error(
`Multiple browser-capable nodes connected (${browserNodes.length}). Set gateway.nodes.browser.node or pass node=<id>.`,
);
}
if (mode === "manual") return null;
if (browserNodes.length === 1) {
const node = browserNodes[0]!;
return { nodeId: node.nodeId, label: node.displayName ?? node.remoteIp ?? node.nodeId };
}
return null;
}
async function callBrowserProxy(params: {
nodeId: string;
method: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
timeoutMs?: number;
profile?: string;
}): Promise<BrowserProxyResult> {
const gatewayTimeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? Math.max(1, Math.floor(params.timeoutMs))
: DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
const payload = (await callGatewayTool(
"node.invoke",
{ timeoutMs: gatewayTimeoutMs },
{
nodeId: params.nodeId,
command: "browser.proxy",
params: {
method: params.method,
path: params.path,
query: params.query,
body: params.body,
timeoutMs: params.timeoutMs,
profile: params.profile,
},
idempotencyKey: crypto.randomUUID(),
},
)) as {
ok?: boolean;
payload?: BrowserProxyResult;
payloadJSON?: string | null;
};
const parsed =
payload?.payload ??
(typeof payload?.payloadJSON === "string" && payload.payloadJSON
? (JSON.parse(payload.payloadJSON) as BrowserProxyResult)
: null);
if (!parsed || typeof parsed !== "object") {
throw new Error("browser proxy failed");
}
return parsed;
}
async function persistProxyFiles(files: BrowserProxyFile[] | undefined) {
if (!files || files.length === 0) return new Map<string, string>();
const mapping = new Map<string, string>();
for (const file of files) {
const buffer = Buffer.from(file.base64, "base64");
const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", buffer.byteLength);
mapping.set(file.path, saved.path);
}
return mapping;
}
function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
if (!result || typeof result !== "object") return;
const obj = result as Record<string, unknown>;
if (typeof obj.path === "string" && mapping.has(obj.path)) {
obj.path = mapping.get(obj.path);
}
if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) {
obj.imagePath = mapping.get(obj.imagePath);
}
const download = obj.download;
if (download && typeof download === "object") {
const d = download as Record<string, unknown>;
if (typeof d.path === "string" && mapping.has(d.path)) {
d.path = mapping.get(d.path);
}
}
}
function resolveBrowserBaseUrl(params: {
target?: "sandbox" | "host" | "custom";
@@ -127,11 +289,12 @@ export function createBrowserTool(opts?: {
"Control the browser via Clawdbot's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
'Profiles: use profile="chrome" for Chrome extension relay takeover (your existing Chrome tabs). Use profile="clawd" for the isolated clawd-managed browser.',
'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS use profile="chrome" (do not ask which profile).',
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
"Chrome extension relay needs an attached tab: user must click the Clawdbot Browser Relay toolbar icon on the tab (badge ON). If no tab is connected, ask them to attach it.",
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
`target selects browser location (sandbox|host|custom). Default: ${targetDefault}.`,
`target selects browser location (sandbox|host|custom|node). Default: ${targetDefault}.`,
"controlUrl implies target=custom (remote control server).",
hostHint,
allowlistHint,
@@ -142,49 +305,184 @@ export function createBrowserTool(opts?: {
const action = readStringParam(params, "action", { required: true });
const controlUrl = readStringParam(params, "controlUrl");
const profile = readStringParam(params, "profile");
let target = readStringParam(params, "target") as "sandbox" | "host" | "custom" | undefined;
if (profile === "chrome" && !target && !controlUrl?.trim()) {
// Chrome extension relay takeover is a host Chrome feature; default to host even in sandboxed sessions.
const requestedNode = readStringParam(params, "node");
let target = readStringParam(params, "target") as
| "sandbox"
| "host"
| "custom"
| "node"
| undefined;
if (controlUrl?.trim() && (target === "node" || requestedNode)) {
throw new Error('controlUrl is not supported with target="node".');
}
if (target === "custom" && requestedNode) {
throw new Error('node is not supported with target="custom".');
}
if (!target && !controlUrl?.trim() && !requestedNode && profile === "chrome") {
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
target = "host";
}
const baseUrl = resolveBrowserBaseUrl({
const nodeTarget = await resolveBrowserNodeTarget({
requestedNode: requestedNode ?? undefined,
target,
controlUrl,
defaultControlUrl: opts?.defaultControlUrl,
allowHostControl: opts?.allowHostControl,
allowedControlUrls: opts?.allowedControlUrls,
allowedControlHosts: opts?.allowedControlHosts,
allowedControlPorts: opts?.allowedControlPorts,
});
const resolvedTarget = target === "node" ? undefined : target;
const baseUrl = nodeTarget
? ""
: resolveBrowserBaseUrl({
target: resolvedTarget,
controlUrl,
defaultControlUrl: opts?.defaultControlUrl,
allowHostControl: opts?.allowHostControl,
allowedControlUrls: opts?.allowedControlUrls,
allowedControlHosts: opts?.allowedControlHosts,
allowedControlPorts: opts?.allowedControlPorts,
});
const proxyRequest = nodeTarget
? async (opts: {
method: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
timeoutMs?: number;
profile?: string;
}) => {
const proxy = await callBrowserProxy({
nodeId: nodeTarget.nodeId,
method: opts.method,
path: opts.path,
query: opts.query,
body: opts.body,
timeoutMs: opts.timeoutMs,
profile: opts.profile,
});
const mapping = await persistProxyFiles(proxy.files);
applyProxyPaths(proxy.result, mapping);
return proxy.result;
}
: null;
switch (action) {
case "status":
if (proxyRequest) {
return jsonResult(
await proxyRequest({
method: "GET",
path: "/",
profile,
}),
);
}
return jsonResult(await browserStatus(baseUrl, { profile }));
case "start":
if (proxyRequest) {
await proxyRequest({
method: "POST",
path: "/start",
profile,
});
return jsonResult(
await proxyRequest({
method: "GET",
path: "/",
profile,
}),
);
}
await browserStart(baseUrl, { profile });
return jsonResult(await browserStatus(baseUrl, { profile }));
case "stop":
if (proxyRequest) {
await proxyRequest({
method: "POST",
path: "/stop",
profile,
});
return jsonResult(
await proxyRequest({
method: "GET",
path: "/",
profile,
}),
);
}
await browserStop(baseUrl, { profile });
return jsonResult(await browserStatus(baseUrl, { profile }));
case "profiles":
if (proxyRequest) {
const result = await proxyRequest({
method: "GET",
path: "/profiles",
});
return jsonResult(result);
}
return jsonResult({ profiles: await browserProfiles(baseUrl) });
case "tabs":
if (proxyRequest) {
const result = await proxyRequest({
method: "GET",
path: "/tabs",
profile,
});
const tabs = (result as { tabs?: unknown[] }).tabs ?? [];
return jsonResult({ tabs });
}
return jsonResult({ tabs: await browserTabs(baseUrl, { profile }) });
case "open": {
const targetUrl = readStringParam(params, "targetUrl", {
required: true,
});
if (proxyRequest) {
const result = await proxyRequest({
method: "POST",
path: "/tabs/open",
profile,
body: { url: targetUrl },
});
return jsonResult(result);
}
return jsonResult(await browserOpenTab(baseUrl, targetUrl, { profile }));
}
case "focus": {
const targetId = readStringParam(params, "targetId", {
required: true,
});
if (proxyRequest) {
const result = await proxyRequest({
method: "POST",
path: "/tabs/focus",
profile,
body: { targetId },
});
return jsonResult(result);
}
await browserFocusTab(baseUrl, targetId, { profile });
return jsonResult({ ok: true });
}
case "close": {
const targetId = readStringParam(params, "targetId");
if (proxyRequest) {
const result = targetId
? await proxyRequest({
method: "DELETE",
path: `/tabs/${encodeURIComponent(targetId)}`,
profile,
})
: await proxyRequest({
method: "POST",
path: "/act",
profile,
body: { kind: "close" },
});
return jsonResult(result);
}
if (targetId) await browserCloseTab(baseUrl, targetId, { profile });
else await browserAct(baseUrl, { kind: "close" }, { profile });
return jsonResult({ ok: true });
@@ -232,21 +530,41 @@ export function createBrowserTool(opts?: {
: undefined;
const selector = typeof params.selector === "string" ? params.selector.trim() : undefined;
const frame = typeof params.frame === "string" ? params.frame.trim() : undefined;
const snapshot = await browserSnapshot(baseUrl, {
format,
targetId,
limit,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
mode,
profile,
});
const snapshot = proxyRequest
? ((await proxyRequest({
method: "GET",
path: "/snapshot",
profile,
query: {
format,
targetId,
limit,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
mode,
},
})) as Awaited<ReturnType<typeof browserSnapshot>>)
: await browserSnapshot(baseUrl, {
format,
targetId,
limit,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
mode,
profile,
});
if (snapshot.format === "ai") {
if (labels && snapshot.imagePath) {
return await imageResultFromFile({
@@ -269,14 +587,27 @@ export function createBrowserTool(opts?: {
const ref = readStringParam(params, "ref");
const element = readStringParam(params, "element");
const type = params.type === "jpeg" ? "jpeg" : "png";
const result = await browserScreenshotAction(baseUrl, {
targetId,
fullPage,
ref,
element,
type,
profile,
});
const result = proxyRequest
? ((await proxyRequest({
method: "POST",
path: "/screenshot",
profile,
body: {
targetId,
fullPage,
ref,
element,
type,
},
})) as Awaited<ReturnType<typeof browserScreenshotAction>>)
: await browserScreenshotAction(baseUrl, {
targetId,
fullPage,
ref,
element,
type,
profile,
});
return await imageResultFromFile({
label: "browser:screenshot",
path: result.path,
@@ -288,6 +619,18 @@ export function createBrowserTool(opts?: {
required: true,
});
const targetId = readStringParam(params, "targetId");
if (proxyRequest) {
const result = await proxyRequest({
method: "POST",
path: "/navigate",
profile,
body: {
url: targetUrl,
targetId,
},
});
return jsonResult(result);
}
return jsonResult(
await browserNavigate(baseUrl, {
url: targetUrl,
@@ -299,11 +642,30 @@ export function createBrowserTool(opts?: {
case "console": {
const level = typeof params.level === "string" ? params.level.trim() : undefined;
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
if (proxyRequest) {
const result = await proxyRequest({
method: "GET",
path: "/console",
profile,
query: {
level,
targetId,
},
});
return jsonResult(result);
}
return jsonResult(await browserConsoleMessages(baseUrl, { level, targetId, profile }));
}
case "pdf": {
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
const result = await browserPdfSave(baseUrl, { targetId, profile });
const result = proxyRequest
? ((await proxyRequest({
method: "POST",
path: "/pdf",
profile,
body: { targetId },
})) as Awaited<ReturnType<typeof browserPdfSave>>)
: await browserPdfSave(baseUrl, { targetId, profile });
return {
content: [{ type: "text", text: `FILE:${result.path}` }],
details: result,
@@ -320,6 +682,22 @@ export function createBrowserTool(opts?: {
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
if (proxyRequest) {
const result = await proxyRequest({
method: "POST",
path: "/hooks/file-chooser",
profile,
body: {
paths,
ref,
inputRef,
element,
targetId,
timeoutMs,
},
});
return jsonResult(result);
}
return jsonResult(
await browserArmFileChooser(baseUrl, {
paths,
@@ -340,6 +718,20 @@ export function createBrowserTool(opts?: {
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
if (proxyRequest) {
const result = await proxyRequest({
method: "POST",
path: "/hooks/dialog",
profile,
body: {
accept,
promptText,
targetId,
timeoutMs,
},
});
return jsonResult(result);
}
return jsonResult(
await browserArmDialog(baseUrl, {
accept,
@@ -356,14 +748,29 @@ export function createBrowserTool(opts?: {
throw new Error("request required");
}
try {
const result = await browserAct(baseUrl, request as Parameters<typeof browserAct>[1], {
profile,
});
const result = proxyRequest
? await proxyRequest({
method: "POST",
path: "/act",
profile,
body: request,
})
: await browserAct(baseUrl, request as Parameters<typeof browserAct>[1], {
profile,
});
return jsonResult(result);
} catch (err) {
const msg = String(err);
if (msg.includes("404:") && msg.includes("tab not found") && profile === "chrome") {
const tabs = await browserTabs(baseUrl, { profile }).catch(() => []);
const tabs = proxyRequest
? ((
(await proxyRequest({
method: "GET",
path: "/tabs",
profile,
})) as { tabs?: unknown[] }
).tabs ?? [])
: await browserTabs(baseUrl, { profile }).catch(() => []);
if (!tabs.length) {
throw new Error(
"No Chrome tabs are attached via the Clawdbot Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry.",

View File

@@ -63,6 +63,9 @@ export function createSessionsSpawnTool(opts?: {
agentAccountId?: string;
agentTo?: string;
agentThreadId?: string | number;
agentGroupId?: string | null;
agentGroupChannel?: string | null;
agentGroupSpace?: string | null;
sandboxed?: boolean;
}): AnyAgentTool {
return {
@@ -153,7 +156,7 @@ export function createSessionsSpawnTool(opts?: {
}
}
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
const shouldPatchSpawnedBy = opts?.sandboxed === true;
const spawnedByKey = requesterInternalKey;
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
const resolvedModel =
normalizeModelSelection(modelOverride) ??
@@ -219,7 +222,10 @@ export function createSessionsSpawnTool(opts?: {
thinking: thinkingOverride,
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
label: label || undefined,
spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined,
spawnedBy: spawnedByKey,
groupId: opts?.agentGroupId ?? undefined,
groupChannel: opts?.agentGroupChannel ?? undefined,
groupSpace: opts?.agentGroupSpace ?? undefined,
},
timeoutMs: 10_000,
})) as { runId?: string };

View File

@@ -8,6 +8,7 @@ import {
listChatCommandsForConfig,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
normalizeNativeCommandSpecsForSurface,
normalizeCommandBody,
parseCommandArgs,
resolveCommandArgMenu,
@@ -15,15 +16,18 @@ import {
shouldHandleTextCommands,
} from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
clearPluginCommands();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
clearPluginCommands();
});
describe("commands registry", () => {
@@ -42,6 +46,20 @@ describe("commands registry", () => {
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
it("normalizes telegram native command specs", () => {
const specs = [
{ name: "OK", description: "Ok", acceptsArgs: false },
{ name: "bad-name", description: "Bad", acceptsArgs: false },
{ name: "fine_name", description: "Fine", acceptsArgs: false },
{ name: "ok", description: "Dup", acceptsArgs: false },
];
const normalized = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs,
});
expect(normalized.map((spec) => spec.name)).toEqual(["ok", "fine_name"]);
});
it("filters commands based on config flags", () => {
const disabled = listChatCommandsForConfig({
commands: { config: false, debug: false },
@@ -85,6 +103,19 @@ describe("commands registry", () => {
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
});
it("includes plugin commands in native specs", () => {
registerPluginCommand("plugin-core", {
name: "plugstatus",
description: "Plugin status",
handler: () => ({ text: "ok" }),
});
const native = listNativeCommandSpecsForConfig(
{ commands: { config: false, debug: false, native: true } },
{ skillCommands: [] },
);
expect(native.find((spec) => spec.name === "plugstatus")).toBeTruthy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -1,8 +1,13 @@
import type { ClawdbotConfig } from "../config/types.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js";
import { getPluginCommandSpecs } from "../plugins/commands.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import {
normalizeTelegramCommandName,
TELEGRAM_COMMAND_NAME_PATTERN,
} from "../config/telegram-custom-commands.js";
import type {
ChatCommandDefinition,
CommandArgChoiceContext,
@@ -108,7 +113,7 @@ export function listChatCommandsForConfig(
export function listNativeCommandSpecs(params?: {
skillCommands?: SkillCommandSpec[];
}): NativeCommandSpec[] {
return listChatCommands({ skillCommands: params?.skillCommands })
const base = listChatCommands({ skillCommands: params?.skillCommands })
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
@@ -116,13 +121,18 @@ export function listNativeCommandSpecs(params?: {
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}));
const pluginSpecs = getPluginCommandSpecs();
if (pluginSpecs.length === 0) return base;
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
return extras.length > 0 ? [...base, ...extras] : base;
}
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] },
): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg, params)
const base = listChatCommandsForConfig(cfg, params)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
@@ -130,6 +140,42 @@ export function listNativeCommandSpecsForConfig(
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}));
const pluginSpecs = getPluginCommandSpecs();
if (pluginSpecs.length === 0) return base;
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
return extras.length > 0 ? [...base, ...extras] : base;
}
function normalizeNativeCommandNameForSurface(name: string, surface: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (surface === "telegram") {
const normalized = normalizeTelegramCommandName(trimmed);
if (!normalized) return null;
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) return null;
return normalized;
}
return trimmed;
}
export function normalizeNativeCommandSpecsForSurface(params: {
surface: string;
specs: NativeCommandSpec[];
}): NativeCommandSpec[] {
const surface = params.surface.toLowerCase();
if (!surface) return params.specs;
const normalized: NativeCommandSpec[] = [];
const seen = new Set<string>();
for (const spec of params.specs) {
const normalizedName = normalizeNativeCommandNameForSurface(spec.name, surface);
if (!normalizedName) continue;
const key = normalizedName.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(normalizedName === spec.name ? spec : { ...spec, name: normalizedName });
}
return normalized;
}
export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined {

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "./heartbeat.js";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
isHeartbeatContentEffectivelyEmpty,
stripHeartbeatToken,
} from "./heartbeat.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
describe("stripHeartbeatToken", () => {
@@ -105,3 +109,76 @@ describe("stripHeartbeatToken", () => {
});
});
});
describe("isHeartbeatContentEffectivelyEmpty", () => {
it("returns false for undefined/null (missing file should not skip)", () => {
expect(isHeartbeatContentEffectivelyEmpty(undefined)).toBe(false);
expect(isHeartbeatContentEffectivelyEmpty(null)).toBe(false);
});
it("returns true for empty string", () => {
expect(isHeartbeatContentEffectivelyEmpty("")).toBe(true);
});
it("returns true for whitespace only", () => {
expect(isHeartbeatContentEffectivelyEmpty(" ")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("\n\n\n")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty(" \n \n ")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("\t\t")).toBe(true);
});
it("returns true for header-only content", () => {
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n")).toBe(true);
});
it("returns true for comments only", () => {
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
});
it("returns true for default template content (header + comment)", () => {
const defaultTemplate = `# HEARTBEAT.md
Keep this file empty unless you want a tiny checklist. Keep it small.
`;
// Note: The template has actual text content, so it's NOT effectively empty
expect(isHeartbeatContentEffectivelyEmpty(defaultTemplate)).toBe(false);
});
it("returns true for header with only empty lines", () => {
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n\n")).toBe(true);
});
it("returns false when actionable content exists", () => {
expect(isHeartbeatContentEffectivelyEmpty("- Check email")).toBe(false);
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n- Task 1")).toBe(false);
expect(isHeartbeatContentEffectivelyEmpty("Remind me to call mom")).toBe(false);
});
it("returns false for content with tasks after header", () => {
const content = `# HEARTBEAT.md
- Task 1
- Task 2
`;
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
});
it("returns false for mixed content with non-comment text", () => {
const content = `# HEARTBEAT.md
## Tasks
Check the server logs
`;
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
});
it("treats markdown headers as comments (effectively empty)", () => {
const content = `# HEARTBEAT.md
## Section 1
### Subsection
`;
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(true);
});
});

View File

@@ -7,6 +7,38 @@ export const HEARTBEAT_PROMPT =
export const DEFAULT_HEARTBEAT_EVERY = "30m";
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
/**
* Check if HEARTBEAT.md content is "effectively empty" - meaning it has no actionable tasks.
* This allows skipping heartbeat API calls when no tasks are configured.
*
* A file is considered effectively empty if it contains only:
* - Whitespace
* - Comment lines (lines starting with #)
* - Empty lines
*
* Note: A missing file returns false (not effectively empty) so the LLM can still
* decide what to do. This function is only for when the file exists but has no content.
*/
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
if (content === undefined || content === null) return false;
if (typeof content !== "string") return false;
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines
if (!trimmed) continue;
// Skip markdown header lines (# followed by space or EOL, ## etc)
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
// (Those aren't valid markdown headers - ATX headers require space after #)
if (/^#+(\s|$)/.test(trimmed)) continue;
// Found a non-empty, non-comment line - there's actionable content
return false;
}
// All lines were either empty or comments
return true;
}
export function resolveHeartbeatPrompt(raw?: string): string {
const trimmed = typeof raw === "string" ? raw.trim() : "";
return trimmed || HEARTBEAT_PROMPT;

View File

@@ -14,6 +14,7 @@ import {
} from "../../agents/pi-embedded-helpers.js";
import {
resolveAgentIdFromSessionKey,
resolveGroupSessionKey,
resolveSessionTranscriptPath,
type SessionEntry,
updateSessionStore,
@@ -214,6 +215,10 @@ export async function runAgentTurnWithFallback(params: {
agentAccountId: params.sessionCtx.AccountId,
messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To,
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
groupChannel:
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
sessionCtx: params.sessionCtx,

View File

@@ -67,6 +67,10 @@ export const handleCompactCommand: CommandHandler = async (params) => {
sessionId,
sessionKey: params.sessionKey,
messageChannel: params.command.channel,
groupId: params.sessionEntry.groupId,
groupChannel: params.sessionEntry.groupChannel,
groupSpace: params.sessionEntry.space,
spawnedBy: params.sessionEntry.spawnedBy,
sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry),
workspaceDir: params.workspaceDir,
config: params.cfg,

View File

@@ -81,6 +81,10 @@ async function resolveContextReport(
workspaceDir,
sessionKey: params.sessionKey,
messageProvider: params.command.channel,
groupId: params.sessionEntry?.groupId ?? undefined,
groupChannel: params.sessionEntry?.groupChannel ?? undefined,
groupSpace: params.sessionEntry?.space ?? undefined,
spawnedBy: params.sessionEntry?.spawnedBy ?? undefined,
modelProvider: params.provider,
modelId: params.model,
});

View File

@@ -24,6 +24,7 @@ import {
handleStopCommand,
handleUsageCommand,
} from "./commands-session.js";
import { handlePluginCommand } from "./commands-plugin.js";
import type {
CommandHandler,
CommandHandlerResult,
@@ -31,6 +32,8 @@ import type {
} from "./commands-types.js";
const HANDLERS: CommandHandler[] = [
// Plugin commands are processed first, before built-in commands
handlePluginCommand,
handleBashCommand,
handleActivationCommand,
handleSendPolicyCommand,

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { handlePluginCommand } from "./commands-plugin.js";
describe("handlePluginCommand", () => {
beforeEach(() => {
clearPluginCommands();
});
it("skips plugin commands when text commands are disabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, false);
expect(result).toBeNull();
});
it("executes plugin commands when text commands are enabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, true);
expect(result?.reply?.text).toBe("pong");
});
});

View File

@@ -0,0 +1,42 @@
/**
* Plugin Command Handler
*
* Handles commands registered by plugins, bypassing the LLM agent.
* This handler is called before built-in command handlers.
*/
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
/**
* Handle plugin-registered commands.
* Returns a result if a plugin command was matched and executed,
* or null to continue to the next handler.
*/
export const handlePluginCommand: CommandHandler = async (
params,
allowTextCommands,
): Promise<CommandHandlerResult | null> => {
if (!allowTextCommands) return null;
const { command, cfg } = params;
// Try to match a plugin command
const match = matchPluginCommand(command.commandBodyNormalized);
if (!match) return null;
// Execute the plugin command (always returns a result)
const result = await executePluginCommand({
command: match.command,
args: match.args,
senderId: command.senderId,
channel: command.channel,
isAuthorizedSender: command.isAuthorizedSender,
commandBody: command.commandBodyNormalized,
config: cfg,
});
return {
shouldContinue: false,
reply: { text: result.text },
};
};

View File

@@ -147,6 +147,9 @@ export function createFollowupRunner(params: {
agentAccountId: queued.run.agentAccountId,
messageTo: queued.originatingTo,
messageThreadId: queued.originatingThreadId,
groupId: queued.run.groupId,
groupChannel: queued.run.groupChannel,
groupSpace: queued.run.groupSpace,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,

View File

@@ -9,6 +9,7 @@ import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/se
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveGroupSessionKey,
resolveSessionFilePath,
type SessionEntry,
updateSessionStore,
@@ -366,6 +367,9 @@ export async function runPreparedReply(
sessionKey,
messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: sessionCtx.AccountId,
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
sessionFile,
workspaceDir,
config: cfg,

View File

@@ -48,6 +48,9 @@ export type FollowupRun = {
sessionKey?: string;
messageProvider?: string;
agentAccountId?: string;
groupId?: string;
groupChannel?: string;
groupSpace?: string;
sessionFile: string;
workspaceDir: string;
config: ClawdbotConfig;

View File

@@ -4,9 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js";
afterEach(() => {
clearPluginCommands();
vi.restoreAllMocks();
});
@@ -423,6 +425,19 @@ describe("buildCommandsMessage", () => {
);
expect(text).toContain("/demo_skill - Demo skill");
});
it("includes plugin commands when registered", () => {
registerPluginCommand("plugin-core", {
name: "plugstatus",
description: "Plugin status",
handler: () => ({ text: "ok" }),
});
const text = buildCommandsMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("🔌 Plugin commands");
expect(text).toContain("/plugstatus - Plugin status");
});
});
describe("buildHelpMessage", () => {

View File

@@ -22,6 +22,7 @@ import {
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
@@ -442,5 +443,12 @@ export function buildCommandsMessage(
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
const pluginCommands = listPluginCommands();
if (pluginCommands.length > 0) {
lines.push("🔌 Plugin commands");
for (const command of pluginCommands) {
lines.push(`/${command.name} - ${command.description}`);
}
}
return lines.join("\n");
}

View File

@@ -247,4 +247,71 @@ describe("chrome extension relay server", () => {
cdp.close();
ext.close();
}, 15_000);
it("rebroadcasts attach when a session id is reused for a new target", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
await waitForOpen(ext);
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
await waitForOpen(cdp);
const q = createMessageQueue(cdp);
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "shared-session",
targetInfo: {
targetId: "t1",
type: "page",
title: "First",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
const first = JSON.parse(await q.next()) as { method?: string; params?: unknown };
expect(first.method).toBe("Target.attachedToTarget");
expect(JSON.stringify(first.params ?? {})).toContain("t1");
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "shared-session",
targetInfo: {
targetId: "t2",
type: "page",
title: "Second",
url: "https://example.org",
},
waitingForDebugger: false,
},
},
}),
);
const received: Array<{ method?: string; params?: unknown }> = [];
received.push(JSON.parse(await q.next()) as never);
received.push(JSON.parse(await q.next()) as never);
const detached = received.find((m) => m.method === "Target.detachedFromTarget");
const attached = received.find((m) => m.method === "Target.attachedToTarget");
expect(JSON.stringify(detached?.params ?? {})).toContain("t1");
expect(JSON.stringify(attached?.params ?? {})).toContain("t2");
cdp.close();
ext.close();
});
});

View File

@@ -477,13 +477,23 @@ export async function ensureChromeExtensionRelayServer(opts: {
const targetType = attached?.targetInfo?.type ?? "page";
if (targetType !== "page") return;
if (attached?.sessionId && attached?.targetInfo?.targetId) {
const already = connectedTargets.has(attached.sessionId);
const prev = connectedTargets.get(attached.sessionId);
const nextTargetId = attached.targetInfo.targetId;
const prevTargetId = prev?.targetId;
const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId);
connectedTargets.set(attached.sessionId, {
sessionId: attached.sessionId,
targetId: attached.targetInfo.targetId,
targetId: nextTargetId,
targetInfo: attached.targetInfo,
});
if (!already) {
if (changedTarget && prevTargetId) {
broadcastToCdpClients({
method: "Target.detachedFromTarget",
params: { sessionId: attached.sessionId, targetId: prevTargetId },
sessionId: attached.sessionId,
});
}
if (!prev || changedTarget) {
broadcastToCdpClients({ method, params, sessionId });
}
return;

View File

@@ -11,10 +11,15 @@ import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js";
import { requireActivePluginRegistry } from "../plugins/runtime.js";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
} from "./plugins/group-mentions.js";
import type {
ChannelCapabilities,
@@ -103,6 +108,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
resolveToolPolicy: resolveTelegramGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
@@ -141,6 +147,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
groups: {
resolveRequireMention: resolveWhatsAppGroupRequireMention,
resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
resolveGroupIntroHint: () =>
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
},
@@ -189,6 +196,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
resolveToolPolicy: resolveDiscordGroupToolPolicy,
},
mentions: {
stripPatterns: () => ["<@!?\\d+>"],
@@ -222,6 +230,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
@@ -284,6 +293,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
resolveToolPolicy: resolveIMessageGroupToolPolicy,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => {

View File

@@ -1,6 +1,10 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
import {
resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy,
} from "../../config/group-policy.js";
import type { DiscordConfig } from "../../config/types.js";
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
type GroupMentionParams = {
@@ -192,3 +196,103 @@ export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams
accountId: params.accountId,
});
}
export function resolveTelegramGroupToolPolicy(
params: GroupMentionParams,
): GroupToolPolicyConfig | undefined {
const { chatId } = parseTelegramGroupId(params.groupId);
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "telegram",
groupId: chatId ?? params.groupId,
accountId: params.accountId,
});
}
export function resolveWhatsAppGroupToolPolicy(
params: GroupMentionParams,
): GroupToolPolicyConfig | undefined {
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "whatsapp",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveIMessageGroupToolPolicy(
params: GroupMentionParams,
): GroupToolPolicyConfig | undefined {
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "imessage",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveDiscordGroupToolPolicy(
params: GroupMentionParams,
): GroupToolPolicyConfig | undefined {
const guildEntry = resolveDiscordGuildEntry(
params.cfg.channels?.discord?.guilds,
params.groupSpace,
);
const channelEntries = guildEntry?.channels;
if (channelEntries && Object.keys(channelEntries).length > 0) {
const groupChannel = params.groupChannel;
const channelSlug = normalizeDiscordSlug(groupChannel);
const entry =
(params.groupId ? channelEntries[params.groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
if (entry?.tools) return entry.tools;
}
if (guildEntry?.tools) return guildEntry.tools;
return undefined;
}
export function resolveSlackGroupToolPolicy(
params: GroupMentionParams,
): GroupToolPolicyConfig | undefined {
const account = resolveSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const channels = account.channels ?? {};
const keys = Object.keys(channels);
if (keys.length === 0) return undefined;
const channelId = params.groupId?.trim();
const groupChannel = params.groupChannel;
const channelName = groupChannel?.replace(/^#/, "");
const normalizedName = normalizeSlackSlug(channelName);
const candidates = [
channelId ?? "",
channelName ? `#${channelName}` : "",
channelName ?? "",
normalizedName,
].filter(Boolean);
let matched: { tools?: GroupToolPolicyConfig } | undefined;
for (const candidate of candidates) {
if (candidate && channels[candidate]) {
matched = channels[candidate];
break;
}
}
const resolved = matched ?? channels["*"];
if (resolved?.tools) return resolved.tools;
return undefined;
}
export function resolveBlueBubblesGroupToolPolicy(
params: GroupMentionParams,
): GroupToolPolicyConfig | undefined {
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "bluebubbles",
groupId: params.groupId,
accountId: params.accountId,
});
}

View File

@@ -1,4 +1,5 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
import type { RuntimeEnv } from "../../runtime.js";
import type {
@@ -65,6 +66,7 @@ export type ChannelConfigAdapter<ResolvedAccount> = {
export type ChannelGroupAdapter = {
resolveRequireMention?: (params: ChannelGroupContext) => boolean | undefined;
resolveGroupIntroHint?: (params: ChannelGroupContext) => string | undefined;
resolveToolPolicy?: (params: ChannelGroupContext) => GroupToolPolicyConfig | undefined;
};
export type ChannelOutboundContext = {

View File

@@ -8,11 +8,8 @@ import {
} from "./register.cron-add.js";
import { registerCronEditCommand } from "./register.cron-edit.js";
import { registerCronSimpleCommands } from "./register.cron-simple.js";
import { registerWakeCommand } from "./register.wake.js";
export function registerCronCli(program: Command) {
registerWakeCommand(program);
const cron = program
.command("cron")
.description("Manage cron jobs (via Gateway)")

View File

@@ -1,37 +0,0 @@
import type { Command } from "commander";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
export function registerWakeCommand(program: Command) {
addGatewayClientOptions(
program
.command("wake")
.description("Enqueue a system event and optionally trigger an immediate heartbeat")
.requiredOption("--text <text>", "System event text")
.option("--mode <mode>", "Wake mode (now|next-heartbeat)", "next-heartbeat")
.option("--json", "Output JSON", false),
)
.addHelpText(
"after",
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/wake", "docs.clawd.bot/cli/wake")}\n`,
)
.action(async (opts: GatewayRpcOpts & { text?: string; mode?: string }) => {
try {
const result = await callGatewayFromCli(
"wake",
opts,
{ mode: opts.mode, text: opts.text },
{ expectFinal: false },
);
if (opts.json) defaultRuntime.log(JSON.stringify(result, null, 2));
else defaultRuntime.log("ok");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}

View File

@@ -248,7 +248,7 @@ export function registerNodesInvokeCommands(nodes: Command) {
const approvals = resolveExecApprovalsFromFile({
file: approvalsFile as ExecApprovalsFile,
agentId,
overrides: { security: "allowlist" },
overrides: { security, ask },
});
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);

View File

@@ -60,6 +60,14 @@ const entries: SubCliEntry[] = [
mod.registerLogsCli(program);
},
},
{
name: "system",
description: "System events, heartbeat, and presence",
register: async (program) => {
const mod = await import("../system-cli.js");
mod.registerSystemCli(program);
},
},
{
name: "models",
description: "Model configuration",

125
src/cli/system-cli.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { Command } from "commander";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import type { GatewayRpcOpts } from "./gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
type SystemEventOpts = GatewayRpcOpts & { text?: string; mode?: string; json?: boolean };
const normalizeWakeMode = (raw: unknown) => {
const mode = typeof raw === "string" ? raw.trim() : "";
if (!mode) return "next-heartbeat" as const;
if (mode === "now" || mode === "next-heartbeat") return mode;
throw new Error("--mode must be now or next-heartbeat");
};
export function registerSystemCli(program: Command) {
const system = program
.command("system")
.description("System tools (events, heartbeat, presence)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/system", "docs.clawd.bot/cli/system")}\n`,
);
addGatewayClientOptions(
system
.command("event")
.description("Enqueue a system event and optionally trigger a heartbeat")
.requiredOption("--text <text>", "System event text")
.option("--mode <mode>", "Wake mode (now|next-heartbeat)", "next-heartbeat")
.option("--json", "Output JSON", false),
).action(async (opts: SystemEventOpts) => {
try {
const text = typeof opts.text === "string" ? opts.text.trim() : "";
if (!text) throw new Error("--text is required");
const mode = normalizeWakeMode(opts.mode);
const result = await callGatewayFromCli("wake", opts, { mode, text }, { expectFinal: false });
if (opts.json) defaultRuntime.log(JSON.stringify(result, null, 2));
else defaultRuntime.log("ok");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
const heartbeat = system.command("heartbeat").description("Heartbeat controls");
addGatewayClientOptions(
heartbeat
.command("last")
.description("Show the last heartbeat event")
.option("--json", "Output JSON", false),
).action(async (opts: GatewayRpcOpts & { json?: boolean }) => {
try {
const result = await callGatewayFromCli("last-heartbeat", opts, undefined, {
expectFinal: false,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
addGatewayClientOptions(
heartbeat
.command("enable")
.description("Enable heartbeats")
.option("--json", "Output JSON", false),
).action(async (opts: GatewayRpcOpts & { json?: boolean }) => {
try {
const result = await callGatewayFromCli(
"set-heartbeats",
opts,
{ enabled: true },
{ expectFinal: false },
);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
addGatewayClientOptions(
heartbeat
.command("disable")
.description("Disable heartbeats")
.option("--json", "Output JSON", false),
).action(async (opts: GatewayRpcOpts & { json?: boolean }) => {
try {
const result = await callGatewayFromCli(
"set-heartbeats",
opts,
{ enabled: false },
{ expectFinal: false },
);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
addGatewayClientOptions(
system
.command("presence")
.description("List system presence entries")
.option("--json", "Output JSON", false),
).action(async (opts: GatewayRpcOpts & { json?: boolean }) => {
try {
const result = await callGatewayFromCli("system-presence", opts, undefined, {
expectFinal: false,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}

View File

@@ -377,6 +377,7 @@ export async function agentCommand(
runContext.messageChannel,
opts.replyChannel ?? opts.channel,
);
const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy;
const fallbackResult = await runWithModelFallback({
cfg,
provider,
@@ -412,6 +413,10 @@ export async function agentCommand(
agentAccountId: runContext.accountId,
messageTo: opts.replyTo ?? opts.to,
messageThreadId: opts.threadId,
groupId: runContext.groupId,
groupChannel: runContext.groupChannel,
groupSpace: runContext.groupSpace,
spawnedBy,
currentChannelId: runContext.currentChannelId,
currentThreadTs: runContext.currentThreadTs,
replyToMode: runContext.replyToMode,

View File

@@ -14,6 +14,15 @@ export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext
const normalizedAccountId = normalizeAccountId(merged.accountId ?? opts.accountId);
if (normalizedAccountId) merged.accountId = normalizedAccountId;
const groupId = (merged.groupId ?? opts.groupId)?.toString().trim();
if (groupId) merged.groupId = groupId;
const groupChannel = (merged.groupChannel ?? opts.groupChannel)?.toString().trim();
if (groupChannel) merged.groupChannel = groupChannel;
const groupSpace = (merged.groupSpace ?? opts.groupSpace)?.toString().trim();
if (groupSpace) merged.groupSpace = groupSpace;
if (
merged.currentThreadTs == null &&
opts.threadId != null &&

View File

@@ -17,6 +17,9 @@ export type AgentStreamParams = {
export type AgentRunContext = {
messageChannel?: string;
accountId?: string;
groupId?: string | null;
groupChannel?: string | null;
groupSpace?: string | null;
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";
@@ -55,6 +58,14 @@ export type AgentCommandOpts = {
accountId?: string;
/** Context for embedded run routing (channel/account/thread). */
runContext?: AgentRunContext;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label for channel-level tool policy resolution. */
groupChannel?: string | null;
/** Group space label for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
deliveryTargetMode?: ChannelOutboundTargetMode;
bestEffortDeliver?: boolean;
abortSignal?: AbortSignal;

View File

@@ -1,11 +1,13 @@
import type { ChannelId } from "../channels/plugins/types.js";
import { normalizeAccountId } from "../routing/session-key.js";
import type { ClawdbotConfig } from "./config.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
export type GroupPolicyChannel = ChannelId;
export type ChannelGroupConfig = {
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
};
export type ChannelGroupPolicy = {
@@ -91,3 +93,15 @@ export function resolveChannelGroupRequireMention(params: {
}
return true;
}
export function resolveChannelGroupToolsPolicy(params: {
cfg: ClawdbotConfig;
channel: GroupPolicyChannel;
groupId?: string | null;
accountId?: string | null;
}): GroupToolPolicyConfig | undefined {
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
if (groupConfig?.tools) return groupConfig.tools;
if (defaultConfig?.tools) return defaultConfig.tools;
return undefined;
}

View File

@@ -50,6 +50,7 @@ const GROUP_LABELS: Record<string, string> = {
diagnostics: "Diagnostics",
logging: "Logging",
gateway: "Gateway",
nodeHost: "Node Host",
agents: "Agents",
tools: "Tools",
bindings: "Bindings",
@@ -76,6 +77,7 @@ const GROUP_ORDER: Record<string, number> = {
update: 25,
diagnostics: 27,
gateway: 30,
nodeHost: 35,
agents: 40,
tools: 50,
bindings: 55,
@@ -193,8 +195,12 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
"gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"gateway.nodes.browser.mode": "Gateway Node Browser Mode",
"gateway.nodes.browser.node": "Gateway Node Browser Pin",
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
"gateway.nodes.denyCommands": "Gateway Node Denylist",
"nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled",
"nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles",
"skills.load.watch": "Watch Skills",
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace",
@@ -366,10 +372,16 @@ const FIELD_HELP: Record<string, string> = {
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.",
"gateway.nodes.browser.mode":
'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).',
"gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).",
"gateway.nodes.allowCommands":
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).",
"gateway.nodes.denyCommands":
"Commands to block even if present in node claims or default allowlist.",
"nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.",
"nodeHost.browserProxy.allowProfiles":
"Optional allowlist of browser profile names exposed via the node proxy.",
"diagnostics.cacheTrace.enabled":
"Log cache trace snapshots for embedded agent runs (default: false).",
"diagnostics.cacheTrace.filePath":

View File

@@ -256,6 +256,22 @@ describe("sessions", () => {
expect(store["agent:main:two"]?.sessionId).toBe("sess-2");
});
it("recovers from array-backed session stores", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, "[]", "utf-8");
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = { sessionId: "sess-1", updatedAt: 1 };
});
const store = loadSessionStore(storePath);
expect(store["agent:main:main"]?.sessionId).toBe("sess-1");
const raw = await fs.readFile(storePath, "utf-8");
expect(raw.trim().startsWith("{")).toBe(true);
});
it("normalizes last route fields on write", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");

View File

@@ -29,6 +29,10 @@ type SessionStoreCacheEntry = {
const SESSION_STORE_CACHE = new Map<string, SessionStoreCacheEntry>();
const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s)
function isSessionStoreRecord(value: unknown): value is Record<string, SessionEntry> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function getSessionStoreTtl(): number {
return resolveCacheTtlMs({
envValue: process.env.CLAWDBOT_SESSION_CACHE_TTL_MS,
@@ -115,7 +119,7 @@ export function loadSessionStore(
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
if (isSessionStoreRecord(parsed)) {
store = parsed as Record<string, SessionEntry>;
}
mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs;

View File

@@ -18,6 +18,7 @@ import type {
MessagesConfig,
} from "./types.messages.js";
import type { ModelsConfig } from "./types.models.js";
import type { NodeHostConfig } from "./types.node-host.js";
import type { PluginsConfig } from "./types.plugins.js";
import type { SkillsConfig } from "./types.skills.js";
import type { ToolsConfig } from "./types.tools.js";
@@ -75,6 +76,7 @@ export type ClawdbotConfig = {
skills?: SkillsConfig;
plugins?: PluginsConfig;
models?: ModelsConfig;
nodeHost?: NodeHostConfig;
agents?: AgentsConfig;
tools?: ToolsConfig;
bindings?: AgentBinding[];

View File

@@ -7,6 +7,7 @@ import type {
ReplyToMode,
} from "./types.base.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
export type DiscordDmConfig = {
/** If false, ignore all incoming Discord DMs. Default: true. */
@@ -24,6 +25,8 @@ export type DiscordDmConfig = {
export type DiscordGuildChannelConfig = {
allow?: boolean;
requireMention?: boolean;
/** Optional tool policy overrides for this channel. */
tools?: GroupToolPolicyConfig;
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this channel. */
@@ -39,6 +42,8 @@ export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist
export type DiscordGuildEntry = {
slug?: string;
requireMention?: boolean;
/** Optional tool policy overrides for this guild (used when channel override is missing). */
tools?: GroupToolPolicyConfig;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: DiscordReactionNotificationMode;
users?: Array<string | number>;

View File

@@ -175,6 +175,13 @@ export type GatewayHttpConfig = {
};
export type GatewayNodesConfig = {
/** Browser routing policy for node-hosted browser proxies. */
browser?: {
/** Routing mode (default: auto). */
mode?: "auto" | "manual" | "off";
/** Pin to a specific node id/name (optional). */
node?: string;
};
/** Additional node.invoke commands to allow on the gateway. */
allowCommands?: string[];
/** Commands to deny even if they appear in the defaults or node claims. */

View File

@@ -5,6 +5,7 @@ import type {
MarkdownConfig,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
export type IMessageAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
@@ -59,6 +60,7 @@ export type IMessageAccountConfig = {
string,
{
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
}
>;
};

View File

@@ -5,6 +5,7 @@ import type {
MarkdownConfig,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
export type MSTeamsWebhookConfig = {
/** Port for the webhook server. Default: 3978. */
@@ -20,6 +21,8 @@ export type MSTeamsReplyStyle = "thread" | "top-level";
export type MSTeamsChannelConfig = {
/** Require @mention to respond. Default: true. */
requireMention?: boolean;
/** Optional tool policy overrides for this channel. */
tools?: GroupToolPolicyConfig;
/** Reply style: "thread" replies to the message, "top-level" posts a new message. */
replyStyle?: MSTeamsReplyStyle;
};
@@ -28,6 +31,8 @@ export type MSTeamsChannelConfig = {
export type MSTeamsTeamConfig = {
/** Default requireMention for channels in this team. */
requireMention?: boolean;
/** Default tool policy for channels in this team. */
tools?: GroupToolPolicyConfig;
/** Default reply style for channels in this team. */
replyStyle?: MSTeamsReplyStyle;
/** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */

View File

@@ -0,0 +1,11 @@
export type NodeHostBrowserProxyConfig = {
/** Enable the browser proxy on the node host (default: true). */
enabled?: boolean;
/** Optional allowlist of profile names exposed via the proxy. */
allowProfiles?: string[];
};
export type NodeHostConfig = {
/** Browser proxy settings for node hosts. */
browserProxy?: NodeHostBrowserProxyConfig;
};

View File

@@ -6,6 +6,7 @@ import type {
ReplyToMode,
} from "./types.base.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
export type SlackDmConfig = {
/** If false, ignore all incoming Slack DMs. Default: true. */
@@ -29,6 +30,8 @@ export type SlackChannelConfig = {
allow?: boolean;
/** Require mentioning the bot to trigger replies. */
requireMention?: boolean;
/** Optional tool policy overrides for this channel. */
tools?: GroupToolPolicyConfig;
/** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean;
/** Allowlist of users that can invoke the bot in this channel. */

Some files were not shown because too many files have changed in this diff Show More