Compare commits

..
156 changed files with 621 additions and 3657 deletions

No files matched your search

+1 -15
View File
@@ -5,32 +5,22 @@ Docs: https://docs.clawd.bot
## 2026.1.23 (Unreleased)
### Changes
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
- 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.
- 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.
- Agents: add diagnostics-configured Anthropic payload logging. (#1501) Thanks @parubets.
- 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
- 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)
- Logging: guard console settings resolution to avoid recursion on config warnings. (#1555) Thanks @travisp.
- 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.
@@ -43,19 +33,15 @@ 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
@@ -385,7 +385,6 @@ 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?
@@ -396,7 +395,6 @@ public struct SendParams: Codable, Sendable {
to: String,
message: String,
mediaurl: String?,
mediaurls: [String]?,
gifplayback: Bool?,
channel: String?,
accountid: String?,
@@ -406,7 +404,6 @@ 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
@@ -417,7 +414,6 @@ public struct SendParams: Codable, Sendable {
case to
case message
case mediaurl = "mediaUrl"
case mediaurls = "mediaUrls"
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
@@ -482,9 +478,6 @@ 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?
@@ -507,9 +500,6 @@ public struct AgentParams: Codable, Sendable {
accountid: String?,
replyaccountid: String?,
threadid: String?,
groupid: String?,
groupchannel: String?,
groupspace: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -531,9 +521,6 @@ 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
@@ -556,9 +543,6 @@ 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"
@@ -385,7 +385,6 @@ 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?
@@ -396,7 +395,6 @@ public struct SendParams: Codable, Sendable {
to: String,
message: String,
mediaurl: String?,
mediaurls: [String]?,
gifplayback: Bool?,
channel: String?,
accountid: String?,
@@ -406,7 +404,6 @@ 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
@@ -417,7 +414,6 @@ public struct SendParams: Codable, Sendable {
case to
case message
case mediaurl = "mediaUrl"
case mediaurls = "mediaUrls"
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
@@ -482,9 +478,6 @@ 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?
@@ -507,9 +500,6 @@ public struct AgentParams: Codable, Sendable {
accountid: String?,
replyaccountid: String?,
threadid: String?,
groupid: String?,
groupchannel: String?,
groupspace: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -531,9 +521,6 @@ 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
@@ -556,9 +543,6 @@ 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"
+1 -1
View File
@@ -20,7 +20,7 @@ services:
[
"node",
"dist/index.js",
"gateway",
"gateway-daemon",
"--bind",
"${CLAWDBOT_GATEWAY_BIND:-lan}",
"--port",
+3 -3
View File
@@ -263,15 +263,15 @@ Run history:
clawdbot cron runs --id <jobId> --limit 50
```
Immediate system event without creating a job:
Immediate wake without creating a job:
```bash
clawdbot system event --mode now --text "Next heartbeat: check battery."
clawdbot wake --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`
For immediate system events without a job, use [`clawdbot system event`](/cli/system).
- `wake` (enqueue system event + optional heartbeat)
## Troubleshooting
+1 -1
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
- [System](/cli/system) - system events + heartbeat controls
- [Wake](/cli/wake) - manual wake command
-1
View File
@@ -44,7 +44,6 @@ 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
+5 -22
View File
@@ -29,7 +29,6 @@ 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)
@@ -39,6 +38,7 @@ 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,10 +145,6 @@ clawdbot [--dev] [--profile <name>] <command>
restart
run
logs
system
event
heartbeat last|enable|disable
presence
models
list
status
@@ -164,6 +160,7 @@ clawdbot [--dev] [--profile <name>] <command>
list
recreate
explain
wake
cron
status
list
@@ -766,9 +763,9 @@ Options:
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
- `clear`: `--provider <name>`, `--agent <id>`
## System
## Cron + wake
### `system event`
### `wake`
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
Required:
@@ -779,21 +776,7 @@ Options:
- `--json`
- `--url`, `--token`, `--timeout`, `--expect-final`
### `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
### `cron`
Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cron-jobs).
Subcommands:
-18
View File
@@ -23,24 +23,6 @@ Common use cases:
Execution is still guarded by **exec approvals** and per‑agent 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
View File
@@ -1,55 +0,0 @@
---
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.
+35
View File
@@ -0,0 +1,35 @@
---
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 don’t 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 you’re using sandboxing, `wake` still targets the Gateway; sandboxing does not block the command itself.
+5 -5
View File
@@ -66,12 +66,12 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
## Time handling
The system prompt includes a dedicated **Current Date & Time** section when the
user timezone is known. To keep the prompt cache-stable, it now only includes
the **time zone** (no dynamic clock or time format).
The system prompt includes a dedicated **Current Date & Time** section when user
time or timezone is known. It is explicit about:
Use `session_status` when the agent needs the current time; the status card
includes a timestamp line.
- The user’s **local time** (already converted).
- The **time zone** used for the conversion.
- The **time format** (12-hour / 24-hour).
Configure with:
+8 -8
View File
@@ -7,8 +7,8 @@ read_when:
# Date & Time
Clawdbot defaults to **host-local time for transport timestamps** and **user timezone only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics (current time is available via `session_status`).
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics.
## Message envelopes (local by default)
@@ -63,16 +63,16 @@ You can override this behavior:
## System prompt: Current Date & Time
If the user timezone is known, the system prompt includes a dedicated
**Current Date & Time** section with the **time zone only** (no clock/time format)
to keep prompt caching stable:
If the user timezone or local time is known, the system prompt includes a dedicated
**Current Date & Time** section:
```
Time zone: America/Chicago
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
Time format: 12-hour
```
When the agent needs the current time, use the `session_status` tool; the status
card includes a timestamp line.
If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references.
## System event lines (local by default)
+1 -1
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",
+1 -5
View File
@@ -162,10 +162,6 @@ 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`:
@@ -199,7 +195,7 @@ Safety note: don’t put secrets (API keys, phone numbers, private tokens) into
You can enqueue a system event and trigger an immediate heartbeat with:
```bash
clawdbot system event --text "Check for urgent follow-ups" --mode now
clawdbot wake --text "Check for urgent follow-ups" --mode now
```
If multiple agents have `heartbeat` configured, a manual wake runs each of those
-23
View File
@@ -95,29 +95,6 @@ Console logs are **TTY-aware** and formatted for readability:
Console formatting is controlled by `logging.consoleStyle`.
## Anthropic payload log (debugging)
For Anthropic-only runs, you can enable a dedicated JSONL log that captures the
exact request payload (as sent) plus per-run usage stats. This log includes full
prompt/message data; treat it as sensitive.
```json
{
"diagnostics": {
"anthropicPayloadLog": {
"enabled": true,
"filePath": "/path/to/anthropic-payload.jsonl"
}
}
}
```
Defaults + overrides:
- Default path: `$CLAWDBOT_STATE_DIR/logs/anthropic-payload.jsonl`
- Env enable: `CLAWDBOT_ANTHROPIC_PAYLOAD_LOG=1`
- Env path: `CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE=/path/to/anthropic-payload.jsonl`
## Configuring logging
All logging configuration lives under `logging` in `~/.clawdbot/clawdbot.json`.
+1 -1
View File
@@ -184,7 +184,7 @@ services:
[
"node",
"dist/index.js",
"gateway",
"gateway-daemon",
"--bind",
"${CLAWDBOT_GATEWAY_BIND}",
"--port",
+1 -2
View File
@@ -5,5 +5,4 @@ read_when:
---
# HEARTBEAT.md
# 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.
Keep this file empty unless you want a tiny checklist. Keep it small.
+5 -10
View File
@@ -7,16 +7,11 @@ 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)*
---
-2
View File
@@ -182,8 +182,6 @@ 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.
-4
View File
@@ -971,10 +971,6 @@ 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?
-13
View File
@@ -166,19 +166,6 @@ 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 node’s own `browser.profiles` config (same as local).
- Disable if you don’t 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
-1
View File
@@ -12,7 +12,6 @@ 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).
-2
View File
@@ -10,7 +10,6 @@ import {
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
@@ -63,7 +62,6 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
},
groups: {
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
+1 -2
View File
@@ -1,4 +1,4 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -21,7 +21,6 @@ const bluebubblesActionSchema = z
const bluebubblesGroupConfigSchema = z.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
});
const bluebubblesAccountSchema = z.object({
-2
View File
@@ -4,8 +4,6 @@ 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 = {
-2
View File
@@ -20,7 +20,6 @@ import {
resolveDiscordAccount,
resolveDefaultDiscordAccountId,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelPlugin,
@@ -145,7 +144,6 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
resolveToolPolicy: resolveDiscordGroupToolPolicy,
},
mentions: {
stripPatterns: () => ["<@!?\\d+>"],
-2
View File
@@ -15,7 +15,6 @@ import {
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type ResolvedIMessageAccount,
@@ -107,7 +106,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
resolveToolPolicy: resolveIMessageGroupToolPolicy,
},
messaging: {
targetResolver: {
+1 -2
View File
@@ -12,7 +12,7 @@ import {
import { matrixMessageActions } from "./actions.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js";
import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
import type { CoreConfig } from "./types.js";
import {
listMatrixAccountIds,
@@ -167,7 +167,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
},
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg }) =>
+1 -2
View File
@@ -1,4 +1,4 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -26,7 +26,6 @@ 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(),
+1 -28
View File
@@ -1,4 +1,4 @@
import type { ChannelGroupContext, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
import type { CoreConfig } from "./types.js";
@@ -32,30 +32,3 @@ 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;
}
-2
View File
@@ -18,8 +18,6 @@ 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). */
-4
View File
@@ -9,7 +9,6 @@ import {
import { msteamsOnboardingAdapter } from "./onboarding.js";
import { msteamsOutbound } from "./outbound.js";
import { probeMSTeams } from "./probe.js";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import {
normalizeMSTeamsMessagingTarget,
normalizeMSTeamsUserInput,
@@ -78,9 +77,6 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
hasRepliedRef,
}),
},
groups: {
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
},
reload: { configPrefixes: ["channels.msteams"] },
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
config: {
-46
View File
@@ -1,8 +1,6 @@
import type {
AllowlistMatch,
ChannelGroupContext,
GroupPolicy,
GroupToolPolicyConfig,
MSTeamsChannelConfig,
MSTeamsConfig,
MSTeamsReplyStyle,
@@ -88,50 +86,6 @@ 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;
-2
View File
@@ -24,7 +24,6 @@ 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",
@@ -160,7 +159,6 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
return true;
},
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
},
messaging: {
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
@@ -4,7 +4,6 @@ import {
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
ToolPolicySchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
import { z } from "zod";
@@ -12,7 +11,6 @@ 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(),
+1 -16
View File
@@ -1,4 +1,4 @@
import type { AllowlistMatch, ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk";
import {
buildChannelKeyCandidates,
normalizeChannelSlug,
@@ -86,21 +86,6 @@ 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;
-2
View File
@@ -7,8 +7,6 @@ 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. */
-2
View File
@@ -21,7 +21,6 @@ import {
resolveSlackAccount,
resolveSlackReplyToMode,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
buildSlackThreadingToolContext,
setAccountEnabledInConfigSection,
slackOnboardingAdapter,
@@ -162,7 +161,6 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
-2
View File
@@ -17,7 +17,6 @@ import {
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
setAccountEnabledInConfigSection,
telegramOnboardingAdapter,
TelegramConfigSchema,
@@ -155,7 +154,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
resolveToolPolicy: resolveTelegramGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
-2
View File
@@ -21,7 +21,6 @@ import {
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAccount,
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
resolveWhatsAppHeartbeatRecipients,
whatsappOnboardingAdapter,
WhatsAppConfigSchema,
@@ -199,7 +198,6 @@ 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).",
},
-24
View File
@@ -2,10 +2,8 @@ import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
ChannelDock,
ChannelGroupContext,
ChannelPlugin,
ClawdbotConfig,
GroupToolPolicyConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
@@ -81,26 +79,6 @@ 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: {
@@ -123,7 +101,6 @@ export const zalouserDock: ChannelDock = {
},
groups: {
resolveRequireMention: () => true,
resolveToolPolicy: resolveZalouserGroupToolPolicy,
},
threading: {
resolveReplyToMode: () => "off",
@@ -211,7 +188,6 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
groups: {
resolveRequireMention: () => true,
resolveToolPolicy: resolveZalouserGroupToolPolicy,
},
threading: {
resolveReplyToMode: () => "off",
+1 -2
View File
@@ -1,4 +1,4 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -6,7 +6,6 @@ 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({
+2 -2
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; tools?: { allow?: string[]; deny?: string[] } }>;
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
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; tools?: { allow?: string[]; deny?: string[] } }>;
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
messagePrefix?: string;
accounts?: Record<string, ZalouserAccountConfig>;
};
-182
View File
@@ -1,182 +0,0 @@
import { describe, expect, it } from "vitest";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { createAnthropicPayloadLogger } from "./anthropic-payload-log.js";
describe("createAnthropicPayloadLogger", () => {
it("returns null when diagnostics payload logging is disabled", () => {
const logger = createAnthropicPayloadLogger({
cfg: {} as ClawdbotConfig,
env: {},
modelApi: "anthropic-messages",
});
expect(logger).toBeNull();
});
it("returns null when model api is not anthropic", () => {
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "openai",
writer: {
filePath: "memory",
write: () => undefined,
},
});
expect(logger).toBeNull();
});
it("honors diagnostics config and expands file paths", () => {
const lines: string[] = [];
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
filePath: "~/.clawdbot/logs/anthropic-payload.jsonl",
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
expect(logger).not.toBeNull();
expect(logger?.filePath).toBe(resolveUserPath("~/.clawdbot/logs/anthropic-payload.jsonl"));
logger?.recordUsage([
{
role: "assistant",
usage: {
input: 12,
},
} as unknown as {
role: string;
usage: { input: number };
},
]);
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.stage).toBe("usage");
expect(event.usage).toEqual({ input: 12 });
});
it("skips usage when no new assistant message was added", () => {
const lines: string[] = [];
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
logger?.recordUsage(
[
{
role: "assistant",
usage: {
input: 1,
},
} as unknown as {
role: string;
usage: { input: number };
},
],
undefined,
1,
);
expect(lines.length).toBe(0);
});
it("records request payloads and forwards onPayload", async () => {
const lines: string[] = [];
let forwarded: unknown;
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
const streamFn = ((_, __, options) => {
options?.onPayload?.({ hello: "world" });
return Promise.resolve(undefined);
}) as StreamFn;
const wrapped = logger?.wrapStreamFn(streamFn);
await wrapped?.(
{ api: "anthropic-messages" } as unknown as { api: string },
{},
{
onPayload: (payload) => {
forwarded = payload;
},
},
);
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.stage).toBe("request");
expect(event.payload).toEqual({ hello: "world" });
expect(event.payloadDigest).toBeTruthy();
expect(forwarded).toEqual({ hello: "world" });
});
it("records errors when usage is missing", () => {
const lines: string[] = [];
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
logger?.recordUsage([], new Error("boom"));
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.stage).toBe("usage");
expect(event.error).toContain("boom");
});
});
-224
View File
@@ -1,224 +0,0 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { formatErrorMessage } from "../infra/errors.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { resolveUserPath } from "../utils.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
type PayloadLogStage = "request" | "usage";
type PayloadLogEvent = {
ts: string;
stage: PayloadLogStage;
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
payload?: unknown;
usage?: Record<string, unknown>;
error?: string;
payloadDigest?: string;
};
type PayloadLogConfig = {
enabled: boolean;
filePath: string;
};
type PayloadLogInit = {
cfg?: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
};
type PayloadLogWriter = {
filePath: string;
write: (line: string) => void;
};
const writers = new Map<string, PayloadLogWriter>();
const log = createSubsystemLogger("agent/anthropic-payload");
function resolvePayloadLogConfig(params: PayloadLogInit): PayloadLogConfig {
const env = params.env ?? process.env;
const config = params.cfg?.diagnostics?.anthropicPayloadLog;
const envEnabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG);
const enabled = envEnabled ?? config?.enabled ?? false;
const fileOverride = config?.filePath?.trim() || env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
const filePath = fileOverride
? resolveUserPath(fileOverride)
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
return { enabled, filePath };
}
function getWriter(filePath: string): PayloadLogWriter {
const existing = writers.get(filePath);
if (existing) return existing;
const dir = path.dirname(filePath);
const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
let queue = Promise.resolve();
const writer: PayloadLogWriter = {
filePath,
write: (line: string) => {
queue = queue
.then(() => ready)
.then(() => fs.appendFile(filePath, line, "utf8"))
.catch(() => undefined);
},
};
writers.set(filePath, writer);
return writer;
}
function safeJsonStringify(value: unknown): string | null {
try {
return JSON.stringify(value, (_key, val) => {
if (typeof val === "bigint") return val.toString();
if (typeof val === "function") return "[Function]";
if (val instanceof Error) {
return { name: val.name, message: val.message, stack: val.stack };
}
if (val instanceof Uint8Array) {
return { type: "Uint8Array", data: Buffer.from(val).toString("base64") };
}
return val;
});
} catch {
return null;
}
}
function digest(value: unknown): string | undefined {
const serialized = safeJsonStringify(value);
if (!serialized) return undefined;
return crypto.createHash("sha256").update(serialized).digest("hex");
}
function isAnthropicModel(model: Model<Api> | undefined | null): boolean {
return (model as { api?: unknown })?.api === "anthropic-messages";
}
function findLastAssistantUsage(
messages: AgentMessage[],
minIndex = 0,
): Record<string, unknown> | null {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (i < minIndex) break;
const msg = messages[i] as { role?: unknown; usage?: unknown };
if (msg?.role === "assistant" && msg.usage && typeof msg.usage === "object") {
return msg.usage as Record<string, unknown>;
}
}
return null;
}
export type AnthropicPayloadLogger = {
enabled: true;
filePath: string;
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
recordUsage: (messages: AgentMessage[], error?: unknown, baselineMessageCount?: number) => void;
};
export function createAnthropicPayloadLogger(params: {
cfg?: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
writer?: PayloadLogWriter;
}): AnthropicPayloadLogger | null {
const env = params.env ?? process.env;
const cfg = resolvePayloadLogConfig({ env, cfg: params.cfg });
if (!cfg.enabled) return null;
if (params.modelApi !== "anthropic-messages") return null;
const writer = params.writer ?? getWriter(cfg.filePath);
const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
workspaceDir: params.workspaceDir,
};
const record = (event: PayloadLogEvent) => {
const line = safeJsonStringify(event);
if (!line) return;
writer.write(`${line}\n`);
};
const wrapStreamFn: AnthropicPayloadLogger["wrapStreamFn"] = (streamFn) => {
const wrapped: StreamFn = (model, context, options) => {
if (!isAnthropicModel(model as Model<Api>)) {
return streamFn(model, context, options);
}
const nextOnPayload = (payload: unknown) => {
record({
...base,
ts: new Date().toISOString(),
stage: "request",
payload,
payloadDigest: digest(payload),
});
options?.onPayload?.(payload);
};
return streamFn(model, context, {
...options,
onPayload: nextOnPayload,
});
};
return wrapped;
};
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (
messages,
error,
baselineMessageCount,
) => {
const usage = findLastAssistantUsage(messages, baselineMessageCount ?? 0);
if (!usage) {
if (error) {
record({
...base,
ts: new Date().toISOString(),
stage: "usage",
error: formatErrorMessage(error),
});
}
return;
}
record({
...base,
ts: new Date().toISOString(),
stage: "usage",
usage,
error: error ? formatErrorMessage(error) : undefined,
});
log.info("anthropic usage", {
runId: params.runId,
sessionId: params.sessionId,
usage,
});
};
log.info("anthropic payload logger enabled", { filePath: cfg.filePath });
return { enabled: true, filePath: cfg.filePath, wrapStreamFn, recordUsage };
}
@@ -129,25 +129,4 @@ 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");
});
});
+5 -2
View File
@@ -838,7 +838,10 @@ export function createExecTool(
applyPathPrepend(env, defaultPathPrepend);
if (host === "node") {
const approvals = resolveExecApprovals(agentId, { security, ask });
const approvals = resolveExecApprovals(
agentId,
host === "node" ? { security: "allowlist" } : undefined,
);
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
@@ -1109,7 +1112,7 @@ export function createExecTool(
}
if (host === "gateway" && !bypassApprovals) {
const approvals = resolveExecApprovals(agentId, { security, ask });
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
-9
View File
@@ -31,12 +31,6 @@ 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;
@@ -120,9 +114,6 @@ 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({
-37
View File
@@ -1,37 +0,0 @@
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",
});
});
});
-25
View File
@@ -12,30 +12,6 @@ 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/);
@@ -49,7 +25,6 @@ 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;
@@ -63,12 +63,12 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
...overrides,
});
const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotConfig =>
const makeConfig = (): ClawdbotConfig =>
({
agents: {
defaults: {
model: {
fallbacks: opts?.fallbacks ?? [],
fallbacks: [],
},
},
},
@@ -76,7 +76,7 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotC
providers: {
openai: {
api: "openai-responses",
apiKey: opts?.apiKey ?? "sk-test",
apiKey: "sk-test",
baseUrl: "https://example.com",
models: [
{
@@ -94,13 +94,7 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotC
},
}) satisfies ClawdbotConfig;
const writeAuthStore = async (
agentDir: string,
opts?: {
includeAnthropic?: boolean;
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
},
) => {
const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
@@ -111,12 +105,10 @@ const writeAuthStore = async (
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
: {}),
},
usageStats:
opts?.usageStats ??
({
"openai:p1": { lastUsed: 1 },
"openai:p2": { lastUsed: 2 },
} as Record<string, { lastUsed?: number }>),
usageStats: {
"openai:p1": { lastUsed: 1 },
"openai:p2": { lastUsed: 2 },
},
};
await fs.writeFile(authPath, JSON.stringify(payload));
};
@@ -392,92 +384,6 @@ 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 {
-12
View File
@@ -73,14 +73,6 @@ 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;
@@ -215,10 +207,6 @@ 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,
+10 -52
View File
@@ -38,7 +38,6 @@ import {
isRateLimitAssistantError,
isTimeoutErrorMessage,
pickFallbackThinkingLevel,
type FailoverReason,
} from "../pi-embedded-helpers.js";
import { normalizeUsage, type UsageLike } from "../usage.js";
@@ -93,8 +92,6 @@ 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(
@@ -168,42 +165,6 @@ 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,
@@ -277,17 +238,14 @@ export async function runEmbeddedPiAgent(
break;
}
if (profileIndex >= profileCandidates.length) {
throwAuthProfileFailover({ allInCooldown: true });
throw new Error(
`No available auth profile for ${provider} (all in cooldown or unavailable).`,
);
}
} catch (err) {
if (err instanceof FailoverError) throw err;
if (profileCandidates[profileIndex] === lockedProfileId) {
throwAuthProfileFailover({ allInCooldown: false, error: err });
}
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
const advanced = await advanceAuthProfile();
if (!advanced) {
throwAuthProfileFailover({ allInCooldown: false, error: err });
}
if (!advanced) throw err;
}
try {
@@ -306,10 +264,6 @@ 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,
@@ -435,7 +389,9 @@ 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
if (fallbackConfigured && isFailoverErrorMessage(errorText)) {
const promptFallbackConfigured =
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
if (promptFallbackConfigured && isFailoverErrorMessage(errorText)) {
throw new FailoverError(errorText, {
reason: promptFailoverReason ?? "unknown",
provider,
@@ -459,6 +415,8 @@ 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);
@@ -20,7 +20,6 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveUserPath } from "../../../utils.js";
import { createCacheTrace } from "../../cache-trace.js";
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
@@ -209,10 +208,6 @@ 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,
@@ -459,17 +454,6 @@ export async function runEmbeddedAttempt(
modelApi: params.model.api,
workspaceDir: params.workspaceDir,
});
const anthropicPayloadLogger = createAnthropicPayloadLogger({
cfg: params.config,
env: process.env,
runId: params.runId,
sessionId: activeSession.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.model.api,
workspaceDir: params.workspaceDir,
});
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
activeSession.agent.streamFn = streamSimple;
@@ -490,11 +474,6 @@ export async function runEmbeddedAttempt(
});
activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn);
}
if (anthropicPayloadLogger) {
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
activeSession.agent.streamFn,
);
}
try {
const prior = await sanitizeSessionHistory({
@@ -643,7 +622,6 @@ export async function runEmbeddedAttempt(
let messagesSnapshot: AgentMessage[] = [];
let sessionIdUsed = activeSession.sessionId;
let promptStartMessageCount = activeSession.messages.length;
const onAbort = () => {
const reason = params.abortSignal ? getAbortReason(params.abortSignal) : undefined;
const timeout = reason ? isTimeoutError(reason) : false;
@@ -715,8 +693,6 @@ export async function runEmbeddedAttempt(
);
}
promptStartMessageCount = activeSession.messages.length;
try {
// Detect and load images referenced in the prompt for vision-capable models.
// This eliminates the need for an explicit "view" tool call by injecting
@@ -792,7 +768,6 @@ export async function runEmbeddedAttempt(
messages: messagesSnapshot,
note: promptError ? "prompt error" : undefined,
});
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError, promptStartMessageCount);
// Run agent_end hooks to allow plugins to analyze the conversation
// This is fire-and-forget, so we don't await
@@ -27,14 +27,6 @@ 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). */
@@ -23,14 +23,6 @@ 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";
+3 -3
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.debug(`aborting run: sessionId=${sessionId}`);
diag.info(`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.debug(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
diag.info(`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.debug(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
notifyEmbeddedRunEnded(sessionId);
} else {
-89
View File
@@ -231,95 +231,6 @@ 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: {
-65
View File
@@ -1,12 +1,8 @@
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" }
@@ -112,26 +108,6 @@ 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;
@@ -198,47 +174,6 @@ 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>,
+2 -33
View File
@@ -23,7 +23,6 @@ import {
filterToolsByPolicy,
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveSubagentToolPolicy,
} from "./pi-tools.policy.js";
import {
@@ -129,14 +128,6 @@ 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). */
@@ -160,16 +151,6 @@ 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);
@@ -184,7 +165,6 @@ export function createClawdbotCodingTools(options?: {
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandbox?.tools,
subagentPolicy,
]);
@@ -293,9 +273,6 @@ 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,
@@ -308,7 +285,6 @@ export function createClawdbotCodingTools(options?: {
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandbox?.tools,
subagentPolicy,
]),
@@ -347,10 +323,6 @@ 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);
@@ -372,12 +344,9 @@ export function createClawdbotCodingTools(options?: {
const agentProviderFiltered = agentProviderExpanded
? filterToolsByPolicy(agentFiltered, agentProviderExpanded)
: agentFiltered;
const groupFiltered = groupPolicyExpanded
? filterToolsByPolicy(agentProviderFiltered, groupPolicyExpanded)
: agentProviderFiltered;
const sandboxed = sandboxPolicyExpanded
? filterToolsByPolicy(groupFiltered, sandboxPolicyExpanded)
: groupFiltered;
? filterToolsByPolicy(agentProviderFiltered, sandboxPolicyExpanded)
: agentProviderFiltered;
const subagentFiltered = subagentPolicyExpanded
? filterToolsByPolicy(sandboxed, subagentPolicyExpanded)
: sandboxed;
+10 -6
View File
@@ -124,7 +124,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Reminder: commit your changes in this workspace after edits.");
});
it("includes user timezone when provided (12-hour)", () => {
it("includes user time when provided (12-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
@@ -133,10 +133,11 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Time zone: America/Chicago");
expect(prompt).toContain("Monday, January 5th, 2026 — 3:26 PM (America/Chicago)");
expect(prompt).toContain("Time format: 12-hour");
});
it("includes user timezone when provided (24-hour)", () => {
it("includes user time when provided (24-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
@@ -145,10 +146,11 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Time zone: America/Chicago");
expect(prompt).toContain("Monday, January 5th, 2026 — 15:26 (America/Chicago)");
expect(prompt).toContain("Time format: 24-hour");
});
it("shows timezone when only timezone is provided", () => {
it("shows UTC fallback when only timezone is provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
@@ -156,7 +158,9 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Time zone: America/Chicago");
expect(prompt).toContain(
"Time zone: America/Chicago. Current time unknown; assume UTC for date/time references.",
);
});
it("includes model alias guidance when aliases are provided", () => {
+20 -4
View File
@@ -49,9 +49,22 @@ function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: bool
return ["## User Identity", ownerLine, ""];
}
function buildTimeSection(params: { userTimezone?: string }) {
if (!params.userTimezone) return [];
return ["## Current Date & Time", `Time zone: ${params.userTimezone}`, ""];
function buildTimeSection(params: {
userTimezone?: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
}) {
if (!params.userTimezone && !params.userTime) return [];
return [
"## Current Date & Time",
params.userTime
? `${params.userTime} (${params.userTimezone ?? "unknown"})`
: `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
];
}
function buildReplyTagsSection(isMinimal: boolean) {
@@ -199,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
session_status:
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
image: "Analyze an image with the configured image model",
};
@@ -289,6 +302,7 @@ export function buildAgentSystemPrompt(params: {
: undefined;
const reasoningLevel = params.reasoningLevel ?? "off";
const userTimezone = params.userTimezone?.trim();
const userTime = params.userTime?.trim();
const skillsPrompt = params.skillsPrompt?.trim();
const heartbeatPrompt = params.heartbeatPrompt?.trim();
const heartbeatPromptLine = heartbeatPrompt
@@ -451,6 +465,8 @@ export function buildAgentSystemPrompt(params: {
...buildUserIdentitySection(ownerLine, isMinimal),
...buildTimeSection({
userTimezone,
userTime,
userTimeFormat: params.userTimeFormat,
}),
"## Workspace Files (injected)",
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
+1 -2
View File
@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
"act",
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
@@ -84,7 +84,6 @@ 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()),
-84
View File
@@ -49,25 +49,6 @@ 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: {} })),
}));
@@ -91,7 +72,6 @@ describe("browser tool snapshot maxChars", () => {
afterEach(() => {
vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
nodesUtilsMocks.listNodes.mockResolvedValue([]);
});
it("applies the default ai snapshot limit", async () => {
@@ -195,70 +175,6 @@ 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", () => {
+37 -444
View File
@@ -18,173 +18,11 @@ 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";
@@ -289,12 +127,11 @@ 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|node). Default: ${targetDefault}.`,
`target selects browser location (sandbox|host|custom). Default: ${targetDefault}.`,
"controlUrl implies target=custom (remote control server).",
hostHint,
allowlistHint,
@@ -305,184 +142,49 @@ export function createBrowserTool(opts?: {
const action = readStringParam(params, "action", { required: true });
const controlUrl = readStringParam(params, "controlUrl");
const profile = readStringParam(params, "profile");
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.
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.
target = "host";
}
const nodeTarget = await resolveBrowserNodeTarget({
requestedNode: requestedNode ?? undefined,
const baseUrl = resolveBrowserBaseUrl({
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 });
@@ -530,41 +232,21 @@ 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 = 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,
});
const snapshot = 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({
@@ -587,27 +269,14 @@ export function createBrowserTool(opts?: {
const ref = readStringParam(params, "ref");
const element = readStringParam(params, "element");
const type = params.type === "jpeg" ? "jpeg" : "png";
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,
});
const result = await browserScreenshotAction(baseUrl, {
targetId,
fullPage,
ref,
element,
type,
profile,
});
return await imageResultFromFile({
label: "browser:screenshot",
path: result.path,
@@ -619,18 +288,6 @@ 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,
@@ -642,30 +299,11 @@ 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 = proxyRequest
? ((await proxyRequest({
method: "POST",
path: "/pdf",
profile,
body: { targetId },
})) as Awaited<ReturnType<typeof browserPdfSave>>)
: await browserPdfSave(baseUrl, { targetId, profile });
const result = await browserPdfSave(baseUrl, { targetId, profile });
return {
content: [{ type: "text", text: `FILE:${result.path}` }],
details: result,
@@ -682,22 +320,6 @@ 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,
@@ -718,20 +340,6 @@ 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,
@@ -748,29 +356,14 @@ export function createBrowserTool(opts?: {
throw new Error("request required");
}
try {
const result = proxyRequest
? await proxyRequest({
method: "POST",
path: "/act",
profile,
body: request,
})
: await browserAct(baseUrl, request as Parameters<typeof browserAct>[1], {
profile,
});
const result = 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 = proxyRequest
? ((
(await proxyRequest({
method: "GET",
path: "/tabs",
profile,
})) as { tabs?: unknown[] }
).tabs ?? [])
: await browserTabs(baseUrl, { profile }).catch(() => []);
const 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.",
+1 -10
View File
@@ -15,7 +15,6 @@ import {
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
import { buildStatusMessage } from "../../auto-reply/status.js";
@@ -216,7 +215,7 @@ export function createSessionStatusTool(opts?: {
label: "Session Status",
name: "session_status",
description:
"Show a /status-equivalent session status card (usage + time + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -325,13 +324,6 @@ export function createSessionStatusTool(opts?: {
resolved.entry.queueDebounceMs ?? resolved.entry.queueCap ?? resolved.entry.queueDrop,
);
const userTimezone = resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(cfg.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const timeLine = userTime
? `🕒 Time: ${userTime} (${userTimezone})`
: `🕒 Time zone: ${userTimezone}`;
const agentDefaults = cfg.agents?.defaults ?? {};
const defaultLabel = `${configured.provider}/${configured.model}`;
const agentModel =
@@ -354,7 +346,6 @@ export function createSessionStatusTool(opts?: {
agentDir,
}),
usageLine,
timeLine,
queue: {
mode: queueSettings.mode,
depth: queueDepth,
+2 -8
View File
@@ -63,9 +63,6 @@ export function createSessionsSpawnTool(opts?: {
agentAccountId?: string;
agentTo?: string;
agentThreadId?: string | number;
agentGroupId?: string | null;
agentGroupChannel?: string | null;
agentGroupSpace?: string | null;
sandboxed?: boolean;
}): AnyAgentTool {
return {
@@ -156,7 +153,7 @@ export function createSessionsSpawnTool(opts?: {
}
}
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
const spawnedByKey = requesterInternalKey;
const shouldPatchSpawnedBy = opts?.sandboxed === true;
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
const resolvedModel =
normalizeModelSelection(modelOverride) ??
@@ -222,10 +219,7 @@ export function createSessionsSpawnTool(opts?: {
thinking: thinkingOverride,
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
label: label || undefined,
spawnedBy: spawnedByKey,
groupId: opts?.agentGroupId ?? undefined,
groupChannel: opts?.agentGroupChannel ?? undefined,
groupSpace: opts?.agentGroupSpace ?? undefined,
spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined,
},
timeoutMs: 10_000,
})) as { runId?: string };
+1 -78
View File
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
isHeartbeatContentEffectivelyEmpty,
stripHeartbeatToken,
} from "./heartbeat.js";
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "./heartbeat.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
describe("stripHeartbeatToken", () => {
@@ -109,76 +105,3 @@ 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);
});
});
-32
View File
@@ -7,38 +7,6 @@ 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;
@@ -14,7 +14,6 @@ import {
} from "../../agents/pi-embedded-helpers.js";
import {
resolveAgentIdFromSessionKey,
resolveGroupSessionKey,
resolveSessionTranscriptPath,
type SessionEntry,
updateSessionStore,
@@ -215,10 +214,6 @@ 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,
-4
View File
@@ -67,10 +67,6 @@ 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,
@@ -81,10 +81,6 @@ 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,
});
-3
View File
@@ -147,9 +147,6 @@ 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,
-4
View File
@@ -9,7 +9,6 @@ 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,
@@ -367,9 +366,6 @@ 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,
-3
View File
@@ -48,9 +48,6 @@ export type FollowupRun = {
sessionKey?: string;
messageProvider?: string;
agentAccountId?: string;
groupId?: string;
groupChannel?: string;
groupSpace?: string;
sessionFile: string;
workspaceDir: string;
config: ClawdbotConfig;
-2
View File
@@ -52,7 +52,6 @@ type StatusArgs = {
resolvedElevated?: ElevatedLevel;
modelAuth?: string;
usageLine?: string;
timeLine?: string;
queue?: QueueStatus;
mediaDecisions?: MediaUnderstandingDecision[];
subagentsLine?: string;
@@ -382,7 +381,6 @@ export function buildStatusMessage(args: StatusArgs): string {
return [
versionLine,
args.timeLine,
modelLine,
usageCostLine,
`📚 ${contextLine}`,
-67
View File
@@ -247,71 +247,4 @@ 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();
});
});
+3 -13
View File
@@ -477,23 +477,13 @@ export async function ensureChromeExtensionRelayServer(opts: {
const targetType = attached?.targetInfo?.type ?? "page";
if (targetType !== "page") return;
if (attached?.sessionId && attached?.targetInfo?.targetId) {
const prev = connectedTargets.get(attached.sessionId);
const nextTargetId = attached.targetInfo.targetId;
const prevTargetId = prev?.targetId;
const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId);
const already = connectedTargets.has(attached.sessionId);
connectedTargets.set(attached.sessionId, {
sessionId: attached.sessionId,
targetId: nextTargetId,
targetId: attached.targetInfo.targetId,
targetInfo: attached.targetInfo,
});
if (changedTarget && prevTargetId) {
broadcastToCdpClients({
method: "Target.detachedFromTarget",
params: { sessionId: attached.sessionId, targetId: prevTargetId },
sessionId: attached.sessionId,
});
}
if (!prev || changedTarget) {
if (!already) {
broadcastToCdpClients({ method, params, sessionId });
}
return;
-10
View File
@@ -11,15 +11,10 @@ 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,
@@ -108,7 +103,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
resolveToolPolicy: resolveTelegramGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
@@ -147,7 +141,6 @@ 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).",
},
@@ -196,7 +189,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
resolveToolPolicy: resolveDiscordGroupToolPolicy,
},
mentions: {
stripPatterns: () => ["<@!?\\d+>"],
@@ -230,7 +222,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
@@ -293,7 +284,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
resolveToolPolicy: resolveIMessageGroupToolPolicy,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => {
+1 -105
View File
@@ -1,10 +1,6 @@
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy,
} from "../../config/group-policy.js";
import { resolveChannelGroupRequireMention } 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 = {
@@ -196,103 +192,3 @@ 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,
});
}
-2
View File
@@ -1,5 +1,4 @@
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 {
@@ -66,7 +65,6 @@ 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 = {
+3
View File
@@ -8,8 +8,11 @@ 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)")
+37
View File
@@ -0,0 +1,37 @@
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);
}
});
}
+1 -1
View File
@@ -248,7 +248,7 @@ export function registerNodesInvokeCommands(nodes: Command) {
const approvals = resolveExecApprovalsFromFile({
file: approvalsFile as ExecApprovalsFile,
agentId,
overrides: { security, ask },
overrides: { security: "allowlist" },
});
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
-8
View File
@@ -60,14 +60,6 @@ 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
View File
@@ -1,125 +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";
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);
}
});
}
-5
View File
@@ -377,7 +377,6 @@ export async function agentCommand(
runContext.messageChannel,
opts.replyChannel ?? opts.channel,
);
const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy;
const fallbackResult = await runWithModelFallback({
cfg,
provider,
@@ -413,10 +412,6 @@ 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,
-9
View File
@@ -14,15 +14,6 @@ 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 &&
-11
View File
@@ -17,9 +17,6 @@ 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";
@@ -58,14 +55,6 @@ 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;
-14
View File
@@ -1,13 +1,11 @@
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 = {
@@ -93,15 +91,3 @@ 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;
}
-18
View File
@@ -50,7 +50,6 @@ const GROUP_LABELS: Record<string, string> = {
diagnostics: "Diagnostics",
logging: "Logging",
gateway: "Gateway",
nodeHost: "Node Host",
agents: "Agents",
tools: "Tools",
bindings: "Bindings",
@@ -77,7 +76,6 @@ const GROUP_ORDER: Record<string, number> = {
update: 25,
diagnostics: 27,
gateway: 30,
nodeHost: 35,
agents: 40,
tools: 50,
bindings: 55,
@@ -121,8 +119,6 @@ const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"diagnostics.anthropicPayloadLog.enabled": "Anthropic Payload Log Enabled",
"diagnostics.anthropicPayloadLog.filePath": "Anthropic Payload Log File Path",
"agents.list.*.identity.avatar": "Identity Avatar",
"gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
@@ -197,12 +193,8 @@ 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",
@@ -374,16 +366,10 @@ 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":
@@ -392,10 +378,6 @@ const FIELD_HELP: Record<string, string> = {
"Include full message payloads in trace output (default: true).",
"diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).",
"diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).",
"diagnostics.anthropicPayloadLog.enabled":
"Log Anthropic request payloads + usage for embedded runs (default: false).",
"diagnostics.anthropicPayloadLog.filePath":
"JSONL output path for Anthropic payload logs (default: $CLAWDBOT_STATE_DIR/logs/anthropic-payload.jsonl).",
"tools.exec.applyPatch.enabled":
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
"tools.exec.applyPatch.allowModels":
-16
View File
@@ -256,22 +256,6 @@ 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");
+1 -5
View File
@@ -29,10 +29,6 @@ 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,
@@ -119,7 +115,7 @@ export function loadSessionStore(
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (isSessionStoreRecord(parsed)) {
if (parsed && typeof parsed === "object") {
store = parsed as Record<string, SessionEntry>;
}
mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs;
-6
View File
@@ -133,16 +133,10 @@ export type DiagnosticsCacheTraceConfig = {
includeSystem?: boolean;
};
export type DiagnosticsAnthropicPayloadLogConfig = {
enabled?: boolean;
filePath?: string;
};
export type DiagnosticsConfig = {
enabled?: boolean;
otel?: DiagnosticsOtelConfig;
cacheTrace?: DiagnosticsCacheTraceConfig;
anthropicPayloadLog?: DiagnosticsAnthropicPayloadLogConfig;
};
export type WebReconnectConfig = {
-2
View File
@@ -18,7 +18,6 @@ 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";
@@ -76,7 +75,6 @@ export type ClawdbotConfig = {
skills?: SkillsConfig;
plugins?: PluginsConfig;
models?: ModelsConfig;
nodeHost?: NodeHostConfig;
agents?: AgentsConfig;
tools?: ToolsConfig;
bindings?: AgentBinding[];
-5
View File
@@ -7,7 +7,6 @@ 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. */
@@ -25,8 +24,6 @@ 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. */
@@ -42,8 +39,6 @@ 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>;
-7
View File
@@ -175,13 +175,6 @@ 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. */
-2
View File
@@ -5,7 +5,6 @@ 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). */
@@ -60,7 +59,6 @@ export type IMessageAccountConfig = {
string,
{
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
}
>;
};
-5
View File
@@ -5,7 +5,6 @@ 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. */
@@ -21,8 +20,6 @@ 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;
};
@@ -31,8 +28,6 @@ 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"). */
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more