Compare commits

..
Author SHA1 Message Date
Peter Steinberger 3ab4c3a3c4 fix: add object capabilities coverage (#1071) (thanks @danielz1z) 2026-01-17 07:31:19 +00:00
danielz1z be6536a635 fix: handle object-format capabilities in normalizeCapabilities
When capabilities is configured as an object (e.g., { inlineButtons: "dm" })
instead of a string array, normalizeCapabilities() would crash with
"capabilities.map is not a function".

This can occur when using the new Telegram inline buttons scoping feature:
  channels.telegram.capabilities.inlineButtons = "dm"

The fix adds an Array.isArray() guard to return undefined for non-array
capabilities, allowing channel-specific handlers (like
resolveTelegramInlineButtonsScope) to process the object format separately.

Fixes crash when using object-format TelegramCapabilitiesConfig.
2026-01-17 07:23:16 +00:00
Peter SteinbergerandAdam Holt c2e10710f4 refactor: share sessions list row type
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 07:23:08 +00:00
Peter Steinberger 86a46874da fix: preserve discord chunk whitespace 2026-01-17 07:11:21 +00:00
Peter Steinberger 3a6ee5ee00 feat: unify hooks installs and webhooks 2026-01-17 07:08:04 +00:00
Peter Steinberger 5dc87a2ed4 fix: respond to PTY cursor queries 2026-01-17 07:05:24 +00:00
Peter SteinbergerandAdam Holt a85ddf258c fix: expose deliveryContext in sessions_list
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 06:54:31 +00:00
Peter SteinbergerandAdam Holt 1f3a09b43b fix: persist deliveryContext on last-route updates
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 06:54:31 +00:00
Peter SteinbergerandAdam Holt 7b31b280f8 refactor: reuse agent outbound target resolution
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 06:54:31 +00:00
Peter Steinberger 6a3ed5c850 fix(security): gate slash/control commands 2026-01-17 06:49:34 +00:00
Peter Steinberger 7ed55682b7 fix(build): allow @lydell/node-pty builds 2026-01-17 06:49:33 +00:00
Peter Steinberger 37a2eee837 refactor: drop legacy session store keys 2026-01-17 06:48:44 +00:00
Peter Steinberger 353d778988 refactor: centralize target normalization 2026-01-17 06:45:11 +00:00
Peter Steinberger 5a1ff5b9e7 refactor: tune media understanding 2026-01-17 06:44:19 +00:00
Peter Steinberger 3dc4a96330 feat: add process submit helper 2026-01-17 06:38:56 +00:00
Peter Steinbergerandadam91holt 65a8a93854 fix: normalize delivery routing context
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 06:38:33 +00:00
Peter Steinberger eb8a0510e0 refactor: unify queue drop handling 2026-01-17 06:38:33 +00:00
Peter Steinberger a4178e4062 fix: stabilize pty send-keys tests 2026-01-17 06:32:24 +00:00
Peter Steinberger 5ebfc0738f feat: add session slug generator 2026-01-17 06:23:26 +00:00
Peter Steinberger bd32cc40e6 feat: add keypad key mappings 2026-01-17 06:22:05 +00:00
Peter Steinberger b31d8d3b10 feat: add tmux-style process key helpers 2026-01-17 06:12:56 +00:00
Peter SteinbergerandThinh Dinh 331141ad77 refactor: centralize message target resolution
Co-authored-by: Thinh Dinh <tobalsan@users.noreply.github.com>
2026-01-17 06:04:49 +00:00
Peter Steinbergerandadam91holt c7ae5100fa refactor: share queue helpers
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 06:02:27 +00:00
Peter Steinbergerandadam91holt 285ed8bac3 fix: sync delivery routing context
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 06:02:27 +00:00
Peter Steinberger e59d8c5436 style: oxfmt format 2026-01-17 05:48:56 +00:00
Peter Steinberger 8b42902cee refactor: drop legacy room chatType 2026-01-17 05:46:40 +00:00
Peter Steinberger 07a3db153d feat: notify on exec exit 2026-01-17 05:43:34 +00:00
Peter Steinberger 68d35be383 feat: emit tool outputs for full verbose 2026-01-17 05:40:21 +00:00
Peter Steinberger 99dd428862 feat: extend verbose tool feedback 2026-01-17 05:33:39 +00:00
Peter Steinbergerandadam91holt 4d314db750 refactor: extract subagent announce queue
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 05:29:07 +00:00
Peter Steinbergerandadam91holt ccea3a0615 refactor: unify delivery target resolution
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 05:29:06 +00:00
Peter Steinbergerandadam91holt f4f20c6762 refactor: normalize session route fields
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 05:29:06 +00:00
Peter Steinberger a624878973 fix(security): gate slash commands by sender 2026-01-17 05:25:42 +00:00
Peter Steinberger c8b826ea8c fix: add media understanding decision types 2026-01-17 05:24:54 +00:00
Peter Steinberger f7089cde54 fix: unify inbound sender labels 2026-01-17 05:21:09 +00:00
Peter Steinberger 572e04d5fb refactor(cli): split outbound send deps 2026-01-17 05:06:39 +00:00
Peter Steinberger bc49c20434 fix: finalize inbound contexts 2026-01-17 05:06:39 +00:00
Peter SteinbergerandThinh Dinh 4b085f23e0 docs: note live target cache refresh
Co-authored-by: Thinh Dinh <tobalsan@users.noreply.github.com>
2026-01-17 05:00:15 +00:00
Peter Steinberger c4ea25a509 feat: add exec pty support 2026-01-17 04:57:11 +00:00
Peter Steinberger 312cb75c50 fix: trim /status oauth output 2026-01-17 04:54:28 +00:00
Peter Steinberger ee738e6578 test: fix mocks for target resolver 2026-01-17 04:41:02 +00:00
Peter Steinberger fcb7c9ff65 refactor: unify media understanding pipeline 2026-01-17 04:39:00 +00:00
Peter Steinbergerandadam91holt 49ecbd8fea test: expand accountId routing coverage
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:33:24 +00:00
Peter Steinbergerandadam91holt 19ee6699d2 refactor: clarify subagent announce origin
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:33:24 +00:00
Peter Steinberger 5fcc9b3244 refactor: centralize target errors and cache lookups 2026-01-17 04:28:22 +00:00
Peter Steinbergerandadam91holt 3efc5e54fa fix: preserve account routing for explicit targets
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:24:59 +00:00
Peter Steinbergerandadam91holt 780c811146 refactor: migrate subagent registry store v2
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:24:59 +00:00
Peter Steinbergerandadam91holt 4f37f66264 refactor: normalize delivery context
Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
2026-01-17 04:24:59 +00:00
Peter Steinberger 8ebfa2950d refactor: align message target wording 2026-01-17 04:18:59 +00:00
Peter Steinberger 9a60d431c5 docs: credit #1034 in changelog 2026-01-17 04:15:46 +00:00
Peter Steinberger 6e4d86f426 refactor: require target for message actions 2026-01-17 04:15:46 +00:00
Peter Steinberger 87cecd0268 refactor: align channel chatType 2026-01-17 04:13:06 +00:00
Peter Steinberger 388b2bce01 refactor: add inbound context helpers 2026-01-17 04:05:34 +00:00
Peter Steinberger a2b5b1f0cb refactor: normalize inbound context 2026-01-17 04:05:33 +00:00
Peter SteinbergerandAdam Holt 9f4b7a1683 fix: normalize subagent announce delivery origin
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 04:03:28 +00:00
Peter Steinberger dd68faef23 refactor: split message tool schema + action handling 2026-01-17 03:58:55 +00:00
Peter Steinberger 97cfa0846c chore: remove legacy transcription helpers 2026-01-17 03:54:46 +00:00
Peter SteinbergerandTristan Manchester 1b973f7506 feat: add inbound media understanding
Co-authored-by: Tristan Manchester <tmanchester96@gmail.com>
2026-01-17 03:54:46 +00:00
Peter Steinberger 4b749f1b8f refactor: share telegram caption splitting 2026-01-17 03:50:09 +00:00
Peter Steinberger 7f1f9473a0 refactor: dedupe message action helpers 2026-01-17 03:46:03 +00:00
Peter Steinberger a32e5d040c Merge pull request #1063 from mukhtharcm/fix/telegram-caption-split
fix(telegram): split long captions into follow-up messages
2026-01-17 03:42:13 +00:00
Peter Steinberger a82217a5f3 chore: format + regenerate protocol 2026-01-17 03:40:49 +00:00
Muhammed Mukhthar CM 02184dd055 fix(telegram): split long captions into follow-up messages 2026-01-17 02:16:01 +00:00
328 changed files with 13294 additions and 3108 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
+26 -2
View File
@@ -11,31 +11,52 @@
### Breaking
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
- **BREAKING:** drop legacy target normalization helpers; use outbound target normalization and resolver flows.
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; internal hooks live under `clawdbot hooks`.
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
### Changes
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Tools: add Firecrawl fallback for `web_fetch` when configured.
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
- Tools: add `process submit` helper to send CR for PTY sessions.
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
- Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI.
- Skills: add user-invocable skill commands and expanded skill command registration.
- Telegram: default reaction level to minimal and enable reaction notifications by default.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
- Messages: refresh live directory cache results when resolving targets.
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras.
- Hooks: add hook pack installs (npm/path/zip/tar) with `clawdbot.hooks` manifests and `clawdbot hooks install/update`.
- Plugins: add zip installs and `--link` to avoid copying local paths.
### Fixes
- Sub-agents: route announce delivery through the correct channel account IDs. (#1061, #1058) — thanks @adam91holt.
- Telegram: split long media captions into follow-up text messages in bot delivery. (#1063) — thanks @mukhtharcm.
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
- Config: handle object-format Telegram capabilities in channel capability resolution. (#1071) — thanks @danielz1z.
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
- Sessions: repair orphaned user turns before embedded prompts.
- Channels: treat replies to the bot as implicit mentions across supported channels.
- Security: lock down slash/control commands to sender allowlists across Discord/Slack/Telegram/Signal/iMessage/WhatsApp (+ plugin channels like Matrix/Teams) and add stable `clawdbot security audit` checkIds for Slack/Discord command allowlists.
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
@@ -50,6 +71,8 @@
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
- Security: bump `tar` to 7.5.3.
- Models: align ZAI thinking toggles.
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
## 2026.1.15
@@ -90,6 +113,7 @@
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
- Media: add optional inbound media understanding for image/audio/video with provider + CLI fallbacks. (#1005) — thanks @tristanmanchester.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
@@ -357,7 +357,7 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?,
channel: String?,
accountid: String?,
sessionkey: String? = nil,
sessionkey: String?,
idempotencykey: String
) {
self.to = to
@@ -431,6 +431,7 @@ public struct AgentParams: Codable, Sendable {
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let channel: String?
public let accountid: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -447,6 +448,7 @@ public struct AgentParams: Codable, Sendable {
deliver: Bool?,
attachments: [AnyCodable]?,
channel: String?,
accountid: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -462,6 +464,7 @@ public struct AgentParams: Codable, Sendable {
self.deliver = deliver
self.attachments = attachments
self.channel = channel
self.accountid = accountid
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -478,6 +481,7 @@ public struct AgentParams: Codable, Sendable {
case deliver
case attachments
case channel
case accountid = "accountId"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
+4 -4
View File
@@ -92,13 +92,13 @@ under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
Use the Clawdbot helper to wire everything together (installs deps on macOS via brew):
```bash
clawdbot hooks gmail setup \
clawdbot webhooks gmail setup \
--account clawdbot@gmail.com
```
Defaults:
- Uses Tailscale Funnel for the public push endpoint.
- Writes `hooks.gmail` config for `clawdbot hooks gmail run`.
- Writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`).
Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
@@ -124,7 +124,7 @@ Gateway auto-start (recommended):
Manual daemon (starts `gog gmail watch serve` + auto-renew):
```bash
clawdbot hooks gmail run
clawdbot webhooks gmail run
```
## One-time setup
@@ -191,7 +191,7 @@ Notes:
- `--hook-url` points to Clawdbot `/hooks/gmail` (mapped; isolated run + summary to main).
- `--include-body` and `--max-bytes` control the body snippet sent to Clawdbot.
Recommended: `clawdbot hooks gmail run` wraps the same flow and auto-renews the watch.
Recommended: `clawdbot webhooks gmail run` wraps the same flow and auto-renews the watch.
## Expose the handler (advanced, unsupported)
+5 -5
View File
@@ -16,19 +16,19 @@ read_when:
```bash
# WhatsApp
clawdbot message poll --to +15555550123 \
clawdbot message poll --target +15555550123 \
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
clawdbot message poll --to 123456789@g.us \
clawdbot message poll --target 123456789@g.us \
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
# Discord
clawdbot message poll --channel discord --to channel:123456789 \
clawdbot message poll --channel discord --target channel:123456789 \
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
clawdbot message poll --channel discord --to channel:123456789 \
clawdbot message poll --channel discord --target channel:123456789 \
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
# MS Teams
clawdbot message poll --channel msteams --to conversation:19:abc@thread.tacv2 \
clawdbot message poll --channel msteams --target conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
```
+1 -1
View File
@@ -96,7 +96,7 @@ Mapping options (summary):
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
(`channel` defaults to `last` and falls back to WhatsApp).
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
- `clawdbot webhooks gmail setup` writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
## Responses
+1 -1
View File
@@ -447,7 +447,7 @@ By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Overri
## Polls (Adaptive Cards)
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
- CLI: `clawdbot message poll --channel msteams --to conversation:<id> ...`
- CLI: `clawdbot message poll --channel msteams --target conversation:<id> ...`
- Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`.
- The gateway must stay online to record votes.
- Polls do not auto-post result summaries yet (inspect the store file if needed).
+1 -1
View File
@@ -444,7 +444,7 @@ The agent sees reactions as **system notifications** in the conversation history
## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target.
- Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`.
- Example: `clawdbot message send --channel telegram --target 123456789 --message "hi"`.
## Troubleshooting
+1 -1
View File
@@ -124,7 +124,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
## Delivery targets (CLI/cron)
- Use a chat id as the target.
- Example: `clawdbot message send --channel zalo --to 123456789 --message "hi"`.
- Example: `clawdbot message send --channel zalo --target 123456789 --message "hi"`.
## Troubleshooting
+2 -2
View File
@@ -15,7 +15,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m
- `--json`: output JSON
## Notes
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --to ...`).
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --target ...`).
- For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory.
- Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting.
@@ -23,7 +23,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m
```bash
clawdbot directory peers list --channel slack --query "U0"
clawdbot message send --channel slack --to user:U012ABCDEF --message "hello"
clawdbot message send --channel slack --target user:U012ABCDEF --message "hello"
```
## ID formats (by channel)
+67 -36
View File
@@ -1,28 +1,21 @@
---
summary: "CLI reference for `clawdbot hooks` (internal hooks + Gmail Pub/Sub + webhook helpers)"
summary: "CLI reference for `clawdbot hooks` (internal hooks)"
read_when:
- You want to manage internal agent hooks
- You want to wire Gmail Pub/Sub events into Clawdbot hooks
- You want to run the gog watch service and renew loop
- You want to install or update internal hooks
---
# `clawdbot hooks`
Webhook helpers and hook-based integrations.
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Related:
- Internal Hooks: [Internal Agent Hooks](/internal-hooks)
- Webhooks: [Webhook](/automation/webhook)
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
## Internal Hooks
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
### List All Hooks
## List All Hooks
```bash
clawdbot hooks internal list
clawdbot hooks list
```
List all discovered internal hooks from workspace, managed, and bundled directories.
@@ -45,7 +38,7 @@ Ready:
**Example (verbose):**
```bash
clawdbot hooks internal list --verbose
clawdbot hooks list --verbose
```
Shows missing requirements for ineligible hooks.
@@ -53,15 +46,15 @@ Shows missing requirements for ineligible hooks.
**Example (JSON):**
```bash
clawdbot hooks internal list --json
clawdbot hooks list --json
```
Returns structured JSON for programmatic use.
### Get Hook Information
## Get Hook Information
```bash
clawdbot hooks internal info <name>
clawdbot hooks info <name>
```
Show detailed information about a specific hook.
@@ -75,7 +68,7 @@ Show detailed information about a specific hook.
**Example:**
```bash
clawdbot hooks internal info session-memory
clawdbot hooks info session-memory
```
**Output:**
@@ -96,10 +89,10 @@ Requirements:
Config: ✓ workspace.dir
```
### Check Hooks Eligibility
## Check Hooks Eligibility
```bash
clawdbot hooks internal check
clawdbot hooks check
```
Show summary of hook eligibility status (how many are ready vs. not ready).
@@ -117,10 +110,10 @@ Ready: 2
Not ready: 0
```
### Enable a Hook
## Enable a Hook
```bash
clawdbot hooks internal enable <name>
clawdbot hooks enable <name>
```
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
@@ -131,7 +124,7 @@ Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
**Example:**
```bash
clawdbot hooks internal enable session-memory
clawdbot hooks enable session-memory
```
**Output:**
@@ -148,10 +141,10 @@ clawdbot hooks internal enable session-memory
**After enabling:**
- Restart the gateway so hooks reload (menu bar app restart on macOS, or restart your gateway process in dev).
### Disable a Hook
## Disable a Hook
```bash
clawdbot hooks internal disable <name>
clawdbot hooks disable <name>
```
Disable a specific hook by updating your config.
@@ -162,7 +155,7 @@ Disable a specific hook by updating your config.
**Example:**
```bash
clawdbot hooks internal disable command-logger
clawdbot hooks disable command-logger
```
**Output:**
@@ -174,6 +167,53 @@ clawdbot hooks internal disable command-logger
**After disabling:**
- Restart the gateway so hooks reload
## Install Hooks
```bash
clawdbot hooks install <path-or-spec>
```
Install a hook pack from a local folder/archive or npm.
**What it does:**
- Copies the hook pack into `~/.clawdbot/hooks/<id>`
- Enables the installed hooks in `hooks.internal.entries.*`
- Records the install under `hooks.internal.installs`
**Options:**
- `-l, --link`: Link a local directory instead of copying (adds it to `hooks.internal.load.extraDirs`)
**Supported archives:** `.zip`, `.tgz`, `.tar.gz`, `.tar`
**Examples:**
```bash
# Local directory
clawdbot hooks install ./my-hook-pack
# Local archive
clawdbot hooks install ./my-hook-pack.zip
# NPM package
clawdbot hooks install @clawdbot/my-hook-pack
# Link a local directory without copying
clawdbot hooks install -l ./my-hook-pack
```
## Update Hooks
```bash
clawdbot hooks update <id>
clawdbot hooks update --all
```
Update installed hook packs (npm installs only).
**Options:**
- `--all`: Update all tracked hook packs
- `--dry-run`: Show what would change without writing
## Bundled Hooks
### session-memory
@@ -183,7 +223,7 @@ Saves session context to memory when you issue `/new`.
**Enable:**
```bash
clawdbot hooks internal enable session-memory
clawdbot hooks enable session-memory
```
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
@@ -197,7 +237,7 @@ Logs all command events to a centralized audit file.
**Enable:**
```bash
clawdbot hooks internal enable command-logger
clawdbot hooks enable command-logger
```
**Output:** `~/.clawdbot/logs/commands.log`
@@ -216,12 +256,3 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
```
**See:** [command-logger documentation](/internal-hooks#command-logger)
## Gmail
```bash
clawdbot hooks gmail setup --account you@example.com
clawdbot hooks gmail run
```
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
+15 -6
View File
@@ -40,6 +40,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`dns`](/cli/dns)
- [`docs`](/cli/docs)
- [`hooks`](/cli/hooks)
- [`webhooks`](/cli/webhooks)
- [`pairing`](/cli/pairing)
- [`plugins`](/cli/plugins) (plugin commands)
- [`channels`](/cli/channels)
@@ -212,6 +213,14 @@ clawdbot [--dev] [--profile <name>] <command>
console
pdf
hooks
list
info
check
enable
disable
install
update
webhooks
gmail setup|run
pairing
list
@@ -414,12 +423,12 @@ Subcommands:
- `pairing list <channel> [--json]`
- `pairing approve <channel> <code> [--notify]`
### `hooks gmail`
### `webhooks gmail`
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
Subcommands:
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
- `hooks gmail run` (runtime overrides for the same flags)
- `webhooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
- `webhooks gmail run` (runtime overrides for the same flags)
### `dns setup`
Wide-area discovery DNS helper (CoreDNS + Tailscale). See [/gateway/discovery](/gateway/discovery).
@@ -446,8 +455,8 @@ Subcommands:
- `message event <list|create>`
Examples:
- `clawdbot message send --to +15555550123 --message "Hi"`
- `clawdbot message poll --channel discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
- `clawdbot message send --target +15555550123 --message "Hi"`
- `clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
### `agent`
Run one agent turn via the Gateway (or `--local` embedded).
@@ -459,7 +468,7 @@ Options:
- `--to <dest>` (for session key and optional delivery)
- `--session-id <id>`
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `--verbose <on|off>`
- `--verbose <on|full|off>`
- `--channel <whatsapp|telegram|discord|slack|signal|imessage>`
- `--local`
- `--deliver`
+25 -29
View File
@@ -21,7 +21,7 @@ Channel selection:
- If exactly one channel is configured, it becomes the default.
- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
Target formats (`--to`):
Target formats (`--target`):
- WhatsApp: E.164 or group JID
- Telegram: chat id or `@username`
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
@@ -38,6 +38,7 @@ Name lookup:
- `--channel <name>`
- `--account <id>`
- `--target <dest>` (target channel or user for send/poll/read/etc)
- `--targets <name>` (repeat; broadcast only)
- `--json`
- `--dry-run`
@@ -49,7 +50,7 @@ Name lookup:
- `send`
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--to`, plus `--message` or `--media`
- Required: `--target`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
- Telegram only: `--thread-id` (forum topic id)
@@ -58,52 +59,47 @@ Name lookup:
- `poll`
- Channels: WhatsApp/Discord/MS Teams
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
- Required: `--target`, `--poll-question`, `--poll-option` (repeat)
- Optional: `--poll-multi`
- Discord only: `--poll-duration-hours`, `--message`
- `react`
- Channels: Discord/Slack/Telegram/WhatsApp
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
- Required: `--message-id`, `--target`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me`
- `reactions`
- Channels: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--limit`, `--channel-id`
- Required: `--message-id`, `--target`
- Optional: `--limit`
- `read`
- Channels: Discord/Slack
- Required: `--to` or `--channel-id`
- Optional: `--limit`, `--before`, `--after`, `--channel-id`
- Required: `--target`
- Optional: `--limit`, `--before`, `--after`
- Discord only: `--around`
- `edit`
- Channels: Discord/Slack
- Required: `--message-id`, `--message`, `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--message-id`, `--message`, `--target`
- `delete`
- Channels: Discord/Slack/Telegram
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--message-id`, `--target`
- `pin` / `unpin`
- Channels: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--message-id`, `--target`
- `pins` (list)
- Channels: Discord/Slack
- Required: `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--target`
- `permissions`
- Channels: Discord
- Required: `--to` or `--channel-id`
- Optional: `--channel-id`
- Required: `--target`
- `search`
- Channels: Discord
@@ -114,7 +110,7 @@ Name lookup:
- `thread create`
- Channels: Discord
- Required: `--thread-name`, `--to` (channel id) or `--channel-id`
- Required: `--thread-name`, `--target` (channel id)
- Optional: `--message-id`, `--auto-archive-min`
- `thread list`
@@ -124,7 +120,7 @@ Name lookup:
- `thread reply`
- Channels: Discord
- Required: `--to` (thread id), `--message`
- Required: `--target` (thread id), `--message`
- Optional: `--media`, `--reply-to`
### Emojis
@@ -142,7 +138,7 @@ Name lookup:
- `sticker send`
- Channels: Discord
- Required: `--to`, `--sticker-id` (repeat)
- Required: `--target`, `--sticker-id` (repeat)
- Optional: `--message`
- `sticker upload`
@@ -153,7 +149,7 @@ Name lookup:
- `role info` (Discord): `--guild-id`
- `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
- `channel info` (Discord): `--channel-id`
- `channel info` (Discord): `--target`
- `channel list` (Discord): `--guild-id`
- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
- `voice status` (Discord): `--guild-id`, `--user-id`
@@ -183,13 +179,13 @@ Name lookup:
Send a Discord reply:
```
clawdbot message send --channel discord \
--to channel:123 --message "hi" --reply-to 456
--target channel:123 --message "hi" --reply-to 456
```
Create a Discord poll:
```
clawdbot message poll --channel discord \
--to channel:123 \
--target channel:123 \
--poll-question "Snack?" \
--poll-option Pizza --poll-option Sushi \
--poll-multi --poll-duration-hours 48
@@ -198,13 +194,13 @@ clawdbot message poll --channel discord \
Send a Teams proactive message:
```
clawdbot message send --channel msteams \
--to conversation:19:abc@thread.tacv2 --message "hi"
--target conversation:19:abc@thread.tacv2 --message "hi"
```
Create a Teams poll:
```
clawdbot message poll --channel msteams \
--to conversation:19:abc@thread.tacv2 \
--target conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" \
--poll-option Pizza --poll-option Sushi
```
@@ -212,11 +208,11 @@ clawdbot message poll --channel msteams \
React in Slack:
```
clawdbot message react --channel slack \
--to C123 --message-id 456 --emoji "✅"
--target C123 --message-id 456 --emoji "✅"
```
Send Telegram inline buttons:
```
clawdbot message send --channel telegram --to @mychat --message "Choose:" \
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
```
+9 -1
View File
@@ -28,11 +28,19 @@ clawdbot plugins update --all
### Install
```bash
clawdbot plugins install <npm-spec>
clawdbot plugins install <path-or-spec>
```
Security note: treat plugin installs like running code. Prefer pinned versions.
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
```bash
clawdbot plugins install -l ./my-plugin
```
### Update
```bash
+23
View File
@@ -0,0 +1,23 @@
---
summary: "CLI reference for `clawdbot webhooks` (webhook helpers + Gmail Pub/Sub)"
read_when:
- You want to wire Gmail Pub/Sub events into Clawdbot
- You want webhook helper commands
---
# `clawdbot webhooks`
Webhook helpers and integrations (Gmail Pub/Sub, webhook helpers).
Related:
- Webhooks: [Webhook](/automation/webhook)
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
## Gmail
```bash
clawdbot webhooks gmail setup --account you@example.com
clawdbot webhooks gmail run
```
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
+4
View File
@@ -85,6 +85,10 @@ When a channel supplies history, it uses a shared wrapper:
- `[Chat messages since your last reply - for context]`
- `[Current message - respond to this]`
For **non-direct chats** (groups/channels/rooms), the **current message body** is prefixed with the
sender label (same style used for history entries). This keeps real-time and queued/history
messages consistent in the agent prompt.
History buffers are **pending-only**: they include group messages that did *not*
trigger a run (for example, mention-gated messages) and **exclude** messages
already in the session transcript.
+1
View File
@@ -48,6 +48,7 @@ Row shape (JSON):
- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun`
- `sendPolicy` (session override if set)
- `lastChannel`, `lastTo`
- `deliveryContext` (normalized `{ channel, to, accountId }` when available)
- `transcriptPath` (best-effort path derived from store dir + sessionId)
- `messages?` (only when `messageLimit > 0`)
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
- No estimated costs; only the provider-reported windows.
## Where it shows up
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
+2 -1
View File
@@ -17,7 +17,7 @@ Key parameters:
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout
- `elevated` (bool): run on host if elevated mode is enabled/allowed
- Need a real TTY? Use the tmux skill.
- Need a real TTY? Set `pty: true`.
- `workdir`, `env`
Behavior:
@@ -39,6 +39,7 @@ Config (preferred):
- `tools.exec.backgroundMs` (default 10000)
- `tools.exec.timeoutSec` (default 1800)
- `tools.exec.cleanupMs` (default 1800000)
- `tools.exec.notifyOnExit` (default true): enqueue a system event + request heartbeat when a backgrounded exec exits.
## process tool
+14 -3
View File
@@ -124,10 +124,21 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
// Tooling
tools: {
audio: {
transcription: {
args: ["--model", "base", "{{MediaPath}}"],
media: {
audio: {
enabled: true,
maxBytes: 20971520,
models: [
{ provider: "openai", model: "whisper-1" },
// Optional CLI fallback (Whisper binary):
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
],
timeoutSeconds: 120
},
video: {
enabled: true,
maxBytes: 52428800,
models: [{ provider: "google", model: "gemini-3-flash-preview" }]
}
}
},
+61 -4
View File
@@ -1745,10 +1745,10 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
- `backgroundMs`: time before auto-background (ms, default 10000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
- `notifyOnExit`: enqueue a system event + request heartbeat when backgrounded exec exits (default true)
- `applyPatch.enabled`: enable experimental `apply_patch` (OpenAI/OpenAI Codex only; default false)
- `applyPatch.allowModels`: optional allowlist of model ids (e.g. `gpt-5.2` or `openai/gpt-5.2`)
Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias).
Legacy: `tools.bash` is still accepted as an alias.
Note: `applyPatch` is only under `tools.exec`.
`tools.web` configures web search + fetch tools:
- `tools.web.search.enabled` (default: true when key is present)
@@ -1769,6 +1769,61 @@ Legacy: `tools.bash` is still accepted as an alias.
- `tools.web.fetch.firecrawl.maxAgeMs` (optional)
- `tools.web.fetch.firecrawl.timeoutSeconds` (optional)
`tools.media` configures inbound media understanding (image/audio/video):
- `tools.media.models`: shared model list (capability-tagged; used after per-cap lists).
- `tools.media.concurrency`: max concurrent capability runs (default 2).
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
- `enabled`: opt-out switch (default true when models are configured).
- `prompt`: optional prompt override (image/video append a `maxChars` hint automatically).
- `maxChars`: max output characters (default 500 for image/video; unset for audio).
- `maxBytes`: max media size to send (defaults: image 10MB, audio 20MB, video 50MB).
- `timeoutSeconds`: request timeout (defaults: image 60s, audio 60s, video 120s).
- `language`: optional audio hint.
- `attachments`: attachment policy (`mode`, `maxAttachments`, `prefer`).
- `scope`: optional gating (first match wins) with `match.channel`, `match.chatType`, or `match.keyPrefix`.
- `models`: ordered list of model entries; failures or oversize media fall back to the next entry.
- Each `models[]` entry:
- Provider entry (`type: "provider"` or omitted):
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
- `model`: model id override (required for image; defaults to `whisper-1`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
- `profile` / `preferredProfile`: auth profile selection.
- CLI entry (`type: "cli"`):
- `command`: executable to run.
- `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc).
- `capabilities`: optional list (`image`, `audio`, `video`) to gate a shared entry. Defaults when omitted: `openai`/`anthropic`/`minimax` → image, `google` → image+audio+video, `groq` → audio.
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language` can be overridden per entry.
If no models are configured (or `enabled: false`), understanding is skipped; the model still receives the original attachments.
Provider auth follows the standard model auth order (auth profiles, env vars like `OPENAI_API_KEY`/`GROQ_API_KEY`/`GEMINI_API_KEY`, or `models.providers.*.apiKey`).
Example:
```json5
{
tools: {
media: {
audio: {
enabled: true,
maxBytes: 20971520,
scope: {
default: "deny",
rules: [{ action: "allow", match: { chatType: "direct" } }]
},
models: [
{ provider: "openai", model: "whisper-1" },
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
]
},
video: {
enabled: true,
maxBytes: 52428800,
models: [{ provider: "google", model: "gemini-3-flash-preview" }]
}
}
}
}
```
`agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
@@ -2702,7 +2757,7 @@ Mapping notes:
- If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage/MS Teams).
- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set).
Gmail helper config (used by `clawdbot hooks gmail setup` / `run`):
Gmail helper config (used by `clawdbot webhooks gmail setup` / `run`):
```json5
{
@@ -2848,7 +2903,7 @@ clawdbot dns setup --apply
## Template variables
Template placeholders are expanded in `tools.audio.transcription.args` (and any future templated argument fields).
Template placeholders are expanded in `tools.media.*.models[].args` and `tools.media.models[].args` (and any future templated argument fields).
| Variable | Description |
|----------|-------------|
@@ -2864,6 +2919,8 @@ Template placeholders are expanded in `tools.audio.transcription.args` (and any
| `{{MediaPath}}` | Local media path (if downloaded) |
| `{{MediaType}}` | Media type (image/audio/document/…) |
| `{{Transcript}}` | Audio transcript (when enabled) |
| `{{Prompt}}` | Resolved media prompt for CLI entries |
| `{{MaxChars}}` | Resolved max output chars for CLI entries |
| `{{ChatType}}` | `"direct"` or `"group"` |
| `{{GroupSubject}}` | Group subject (best effort) |
| `{{GroupMembers}}` | Group members preview (best effort) |
+1 -1
View File
@@ -111,7 +111,7 @@ Current migrations:
- `routing.bindings` → top-level `bindings`
- `routing.agents`/`routing.defaultAgentId` → `agents.list` + `agents.list[].default`
- `routing.agentToAgent` → `tools.agentToAgent`
- `routing.transcribeAudio` → `tools.audio.transcription`
- `routing.transcribeAudio` → `tools.media.audio.models`
- `bindings[].match.accountID` → `bindings[].match.accountId`
- `identity` → `agents.list[].identity`
- `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents)
+1 -1
View File
@@ -281,7 +281,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
## CLI helpers
- `clawdbot gateway health|status` — request health/status over the Gateway WS.
- `clawdbot message send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot message send --target <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
+1 -1
View File
@@ -137,7 +137,7 @@ clawdbot gateway --port 19001
Send a test message (requires a running Gateway):
```bash
clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
clawdbot message send --target +15555550123 --message "Hello from Clawdbot"
```
## Configuration (optional)
+53 -27
View File
@@ -13,7 +13,7 @@ Internal hooks provide an extensible event-driven system for automating actions
Hooks are small scripts that run when something happens. There are two kinds:
- **Internal hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
- **Web-based hooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook).
- **Web-based hooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
Common uses:
- Save a memory snapshot when you reset a session
@@ -43,25 +43,25 @@ Clawdbot ships with two bundled hooks that are automatically discovered:
List available hooks:
```bash
clawdbot hooks internal list
clawdbot hooks list
```
Enable a hook:
```bash
clawdbot hooks internal enable session-memory
clawdbot hooks enable session-memory
```
Check hook status:
```bash
clawdbot hooks internal check
clawdbot hooks check
```
Get detailed information:
```bash
clawdbot hooks internal info session-memory
clawdbot hooks info session-memory
```
### Onboarding
@@ -76,6 +76,8 @@ Hooks are automatically discovered from three directories (in order of precedenc
2. **Managed hooks**: `~/.clawdbot/hooks/` (user-installed, shared across workspaces)
3. **Bundled hooks**: `<clawdbot>/dist/hooks/bundled/` (shipped with Clawdbot)
Managed hook directories can be either a **single hook** or a **hook pack** (package directory).
Each hook is a directory containing:
```
@@ -84,6 +86,30 @@ my-hook/
└── handler.ts # Handler implementation
```
## Hook Packs (npm/archives)
Hook packs are standard npm packages that export one or more hooks via `clawdbot.hooks` in
`package.json`. Install them with:
```bash
clawdbot hooks install <path-or-spec>
```
Example `package.json`:
```json
{
"name": "@acme/my-hooks",
"version": "0.1.0",
"clawdbot": {
"hooks": ["./hooks/my-hook", "./hooks/other-hook"]
}
}
```
Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`).
Hook packs can ship dependencies; they will be installed under `~/.clawdbot/hooks/<id>`.
## Hook Structure
### HOOK.md Format
@@ -252,10 +278,10 @@ export default handler;
```bash
# Verify hook is discovered
clawdbot hooks internal list
clawdbot hooks list
# Enable it
clawdbot hooks internal enable my-hook
clawdbot hooks enable my-hook
# Restart your gateway process (menu bar app restart on macOS, or restart your dev process)
@@ -349,46 +375,46 @@ The old config format still works for backwards compatibility:
```bash
# List all hooks
clawdbot hooks internal list
clawdbot hooks list
# Show only eligible hooks
clawdbot hooks internal list --eligible
clawdbot hooks list --eligible
# Verbose output (show missing requirements)
clawdbot hooks internal list --verbose
clawdbot hooks list --verbose
# JSON output
clawdbot hooks internal list --json
clawdbot hooks list --json
```
### Hook Information
```bash
# Show detailed info about a hook
clawdbot hooks internal info session-memory
clawdbot hooks info session-memory
# JSON output
clawdbot hooks internal info session-memory --json
clawdbot hooks info session-memory --json
```
### Check Eligibility
```bash
# Show eligibility summary
clawdbot hooks internal check
clawdbot hooks check
# JSON output
clawdbot hooks internal check --json
clawdbot hooks check --json
```
### Enable/Disable
```bash
# Enable a hook
clawdbot hooks internal enable session-memory
clawdbot hooks enable session-memory
# Disable a hook
clawdbot hooks internal disable command-logger
clawdbot hooks disable command-logger
```
## Bundled Hooks
@@ -427,7 +453,7 @@ Saves session context to memory when you issue `/new`.
**Enable**:
```bash
clawdbot hooks internal enable session-memory
clawdbot hooks enable session-memory
```
### command-logger
@@ -468,7 +494,7 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
**Enable**:
```bash
clawdbot hooks internal enable command-logger
clawdbot hooks enable command-logger
```
## Best Practices
@@ -550,7 +576,7 @@ Registered hook: command-logger -> command
List all discovered hooks:
```bash
clawdbot hooks internal list --verbose
clawdbot hooks list --verbose
```
### Check Registration
@@ -569,7 +595,7 @@ const handler: InternalHookHandler = async (event) => {
Check why a hook isn't eligible:
```bash
clawdbot hooks internal info my-hook
clawdbot hooks info my-hook
```
Look for missing requirements in the output.
@@ -618,7 +644,7 @@ test('my handler works', async () => {
- **`src/hooks/config.ts`**: Eligibility checking
- **`src/hooks/hooks-status.ts`**: Status reporting
- **`src/hooks/loader.ts`**: Dynamic module loader
- **`src/cli/hooks-internal-cli.ts`**: CLI commands
- **`src/cli/hooks-cli.ts`**: CLI commands
- **`src/gateway/server-startup.ts`**: Loads hooks at gateway start
- **`src/auto-reply/reply/commands-core.ts`**: Triggers command events
@@ -672,7 +698,7 @@ Session reset
3. List all discovered hooks:
```bash
clawdbot hooks internal list
clawdbot hooks list
```
### Hook Not Eligible
@@ -680,7 +706,7 @@ Session reset
Check requirements:
```bash
clawdbot hooks internal info my-hook
clawdbot hooks info my-hook
```
Look for missing:
@@ -693,7 +719,7 @@ Look for missing:
1. Verify hook is enabled:
```bash
clawdbot hooks internal list
clawdbot hooks list
# Should show ✓ next to enabled hooks
```
@@ -772,7 +798,7 @@ node -e "import('./path/to/handler.ts').then(console.log)"
4. Verify and restart your gateway process:
```bash
clawdbot hooks internal list
clawdbot hooks list
# Should show: 🎯 my-hook ✓
```
@@ -785,7 +811,7 @@ node -e "import('./path/to/handler.ts').then(console.log)"
## See Also
- [CLI Reference: hooks internal](/cli/hooks)
- [CLI Reference: hooks](/cli/hooks)
- [Bundled Hooks README](https://github.com/clawdbot/clawdbot/tree/main/src/hooks/bundled)
- [Webhook Hooks](/automation/webhook)
- [Configuration](/gateway/configuration#hooks)
+55 -19
View File
@@ -3,25 +3,59 @@ summary: "How inbound audio/voice notes are downloaded, transcribed, and injecte
read_when:
- Changing audio transcription or media handling
---
# Audio / Voice Notes — 2025-12-05
# Audio / Voice Notes — 2026-01-17
## What works
- **Optional transcription**: If `tools.audio.transcription` is set in `~/.clawdbot/clawdbot.json`, Clawdbot will:
1) Download inbound audio to a temp path when WhatsApp only provides a URL.
2) Run the configured CLI args (templated with `{{MediaPath}}`), expecting transcript on stdout.
3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both.
4) Continue through the normal auto-reply pipeline (templating, sessions, Pi command).
- **Verbose logging**: In `--verbose`, we log when transcription runs and when the transcript replaces the body.
- **Media understanding (audio)**: If `tools.media.audio` is enabled (or a shared `tools.media.models` entry supports audio), Clawdbot:
1) Locates the first audio attachment (local path or URL) and downloads it if needed.
2) Enforces `maxBytes` before sending to each model entry.
3) Runs the first eligible model entry in order (provider or CLI).
4) If it fails or skips (size/timeout), it tries the next entry.
5) On success, it replaces `Body` with an `[Audio]` block and sets `{{Transcript}}`.
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
## Config example (Whisper CLI)
Requires `whisper` CLI installed:
## Config examples
### Provider + CLI fallback (OpenAI + Whisper CLI)
```json5
{
tools: {
audio: {
transcription: {
args: ["--model", "base", "{{MediaPath}}"],
timeoutSeconds: 45
media: {
audio: {
enabled: true,
maxBytes: 20971520,
models: [
{ provider: "openai", model: "whisper-1" },
{
type: "cli",
command: "whisper",
args: ["--model", "base", "{{MediaPath}}"],
timeoutSeconds: 45
}
]
}
}
}
}
```
### Provider-only with scope gating
```json5
{
tools: {
media: {
audio: {
enabled: true,
scope: {
default: "allow",
rules: [
{ action: "deny", match: { chatType: "group" } }
]
},
models: [
{ provider: "openai", model: "whisper-1" }
]
}
}
}
@@ -29,12 +63,14 @@ Requires `whisper` CLI installed:
```
## Notes & limits
- We don’t ship a transcriber; you opt in with the Whisper CLI on your PATH.
- Size guard: inbound audio must be ≤5 MB (matches the temp media store and transcript pipeline).
- Outbound caps: web send supports audio/voice up to 16 MB (sent as a voice note with `ptt: true`).
- If transcription fails, we fall back to the original body/media note; replies still go through.
- Transcript is available to templates as `{{Transcript}}`; models get both the media path and a `Transcript:` block in the prompt when using command mode.
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
- Transcript is available to templates as `{{Transcript}}`.
- CLI stdout is capped (5MB); keep CLI output concise.
## Gotchas
- Scope rules use first-match wins. `chatType` is normalized to `direct`, `group`, or `room`.
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
- Keep timeouts reasonable (`timeoutSeconds`, default 45s) to avoid blocking the reply queue.
- Keep timeouts reasonable (`timeoutSeconds`, default 60s) to avoid blocking the reply queue.
+11 -1
View File
@@ -38,13 +38,23 @@ The WhatsApp channel runs via **Baileys Web**. This document captures the curren
- `{{MediaUrl}}` pseudo-URL for the inbound media.
- `{{MediaPath}}` local temp path written before running the command.
- When a per-session Docker sandbox is enabled, inbound media is copied into the sandbox workspace and `MediaPath`/`MediaUrl` are rewritten to a relative path like `media/inbound/<filename>`.
- Audio transcription (if configured via `tools.audio.transcription`) runs before templating and can replace `Body` with the transcript.
- Media understanding (if configured via `tools.media.*` or shared `tools.media.models`) runs before templating and can insert `[Image]`, `[Audio]`, and `[Video]` blocks into `Body`.
- Audio sets `{{Transcript}}` and uses the transcript for command parsing so slash commands still work.
- Video and image descriptions preserve any caption text for command parsing.
- By default only the first matching image/audio/video attachment is processed; set `tools.media.<cap>.attachments` to process multiple attachments.
## Limits & Errors
**Outbound send caps (WhatsApp web send)**
- Images: ~6 MB cap after recompression.
- Audio/voice/video: 16 MB cap; documents: 100 MB cap.
- Oversize or unreadable media → clear error in logs and the reply is skipped.
**Media understanding caps (transcription/description)**
- Image default: 10 MB (`tools.media.image.maxBytes`).
- Audio default: 20 MB (`tools.media.audio.maxBytes`).
- Video default: 50 MB (`tools.media.video.maxBytes`).
- Oversize media skips understanding, but replies still go through with the original body.
## Notes for Tests
- Cover send + reply flows for image/audio/document cases.
- Validate recompression for images (size bound) and voice-note flag for audio.
+266
View File
@@ -0,0 +1,266 @@
---
summary: "Inbound image/audio/video understanding (optional) with provider + CLI fallbacks"
read_when:
- Designing or refactoring media understanding
- Tuning inbound audio/video/image preprocessing
---
# Media Understanding (Inbound) — 2026-01-17
Clawdbot can optionally **summarize inbound media** (image/audio/video) before the reply pipeline runs. This is **opt-in** and separate from the base attachment flow—if understanding is off, models still receive the original files/URLs as usual.
## Goals
- Optional: pre‑digest inbound media into short text for faster routing + better command parsing.
- Preserve original media delivery to the model (always).
- Support **provider APIs** and **CLI fallbacks**.
- Allow multiple models with ordered fallback (error/size/timeout).
## High‑level behavior
1) Collect inbound attachments (`MediaPaths`, `MediaUrls`, `MediaTypes`).
2) For each enabled capability (image/audio/video), select attachments per policy (default: **first**).
3) Choose the first eligible model entry (size + capability + auth).
4) If a model fails or the media is too large, **fall back to the next entry**.
5) On success:
- `Body` becomes `[Image]`, `[Audio]`, or `[Video]` block.
- Audio sets `{{Transcript}}`; command parsing uses caption text when present,
otherwise the transcript.
- Captions are preserved as `User text:` inside the block.
If understanding fails or is disabled, **the reply flow continues** with the original body + attachments.
## Config overview
`tools.media` supports **shared models** plus per‑capability overrides:
- `tools.media.models`: shared model list (use `capabilities` to gate).
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
- optional **per‑capability `models` list** (preferred before shared models)
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
- `scope` (optional gating by channel/chatType/session key)
- `tools.media.concurrency`: max concurrent capability runs (default **2**).
```json5
{
tools: {
media: {
models: [ /* shared list */ ],
image: { /* optional overrides */ },
audio: { /* optional overrides */ },
video: { /* optional overrides */ }
}
}
}
```
### Model entries
Each `models[]` entry can be **provider** or **CLI**:
```json5
{
type: "provider", // default if omitted
provider: "openai",
model: "gpt-5.2",
prompt: "Describe the image in <= 500 chars.",
maxChars: 500,
maxBytes: 10485760,
timeoutSeconds: 60,
capabilities: ["image"], // optional, used for multi‑modal entries
profile: "vision-profile",
preferredProfile: "vision-fallback"
}
```
```json5
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
],
maxChars: 500,
maxBytes: 52428800,
timeoutSeconds: 120,
capabilities: ["video", "image"]
}
```
## Defaults and limits
Recommended defaults:
- `maxChars`: **500** for image/video (short, command‑friendly)
- `maxChars`: **unset** for audio (full transcript unless you set a limit)
- `maxBytes`:
- image: **10MB**
- audio: **20MB**
- video: **50MB**
Rules:
- If media exceeds `maxBytes`, that model is skipped and the **next model is tried**.
- If the model returns more than `maxChars`, output is trimmed.
- `prompt` defaults to simple “Describe the {media}.” plus the `maxChars` guidance (image/video only).
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability.
## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared
lists, Clawdbot can infer defaults:
- `openai`, `anthropic`, `minimax`: **image**
- `google` (Gemini API): **image + audio + video**
- `groq`: **audio**
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
If you omit `capabilities`, the entry is eligible for the list it appears in.
## Provider support matrix (Clawdbot integrations)
| Capability | Provider integration | Notes |
|------------|----------------------|-------|
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
| Audio | OpenAI, Groq | Provider transcription (Whisper). |
| Video | Google (Gemini API) | Provider video understanding. |
## Recommended providers
**Image**
- Prefer your active model if it supports images.
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
**Audio**
- `openai/whisper-1` or `groq/whisper-large-v3-turbo`.
- CLI fallback: `whisper` binary.
**Video**
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
- CLI fallback: `gemini` CLI (supports `read_file` on video/audio).
## Attachment policy
Per‑capability `attachments` controls which attachments are processed:
- `mode`: `first` (default) or `all`
- `maxAttachments`: cap the number processed (default **1**)
- `prefer`: `first`, `last`, `path`, `url`
When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
## Config examples
### 1) Shared models list + overrides
```json5
{
tools: {
media: {
models: [
{ provider: "openai", model: "gpt-5.2", capabilities: ["image"] },
{ provider: "google", model: "gemini-3-flash-preview", capabilities: ["image", "audio", "video"] },
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
],
capabilities: ["image", "video"]
}
],
audio: {
attachments: { mode: "all", maxAttachments: 2 }
},
video: {
maxChars: 500
}
}
}
}
```
### 2) Audio + Video only (image off)
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [
{ provider: "openai", model: "whisper-1" },
{
type: "cli",
command: "whisper",
args: ["--model", "base", "{{MediaPath}}"]
}
]
},
video: {
enabled: true,
maxChars: 500,
models: [
{ provider: "google", model: "gemini-3-flash-preview" },
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
]
}
]
}
}
}
}
```
### 3) Optional image understanding
```json5
{
tools: {
media: {
image: {
enabled: true,
maxBytes: 10485760,
maxChars: 500,
models: [
{ provider: "openai", model: "gpt-5.2" },
{ provider: "anthropic", model: "claude-opus-4-5" },
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
]
}
]
}
}
}
}
```
### 4) Multi‑modal single entry (explicit capabilities)
```json5
{
tools: {
media: {
image: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] },
audio: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] },
video: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] }
}
}
}
```
## Notes
- Understanding is **best‑effort**. Errors do not block replies.
- Attachments are still passed to models even when understanding is disabled.
- Use `scope` to limit where understanding runs (e.g. only DMs).
## Related docs
- [Configuration](/gateway/configuration)
- [Image & Media Support](/nodes/images)
+4 -2
View File
@@ -157,9 +157,11 @@ export default {
```bash
clawdbot plugins list
clawdbot plugins info <id>
clawdbot plugins install <path> # add a local file/dir to plugins.load.paths
clawdbot plugins install <path> # copy a local file/dir into ~/.clawdbot/extensions/<id>
clawdbot plugins install ./extensions/voice-call # relative path ok
clawdbot plugins install ./plugin.tgz # install from a local tarball
clawdbot plugins install ./plugin.tgz # install from a local tarball
clawdbot plugins install ./plugin.zip # install from a local zip
clawdbot plugins install -l ./extensions/voice-call # link (no copy) for dev
clawdbot plugins install @clawdbot/voice-call # install from npm
clawdbot plugins update <id>
clawdbot plugins update --all
+1 -1
View File
@@ -65,7 +65,7 @@ Channel config lives under `channels.zalouser` (not `plugins.entries.*`):
clawdbot channels login --channel zalouser
clawdbot channels logout --channel zalouser
clawdbot channels status --probe
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot"
clawdbot message send --channel zalouser --target <threadId> --message "Hello from Clawdbot"
clawdbot directory peers list --channel zalouser --query "name"
```
+1 -1
View File
@@ -1495,7 +1495,7 @@ Outbound attachments from the agent must include a `MEDIA:<path-or-url>` line (o
CLI sending:
```bash
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
clawdbot message send --target +15555550123 --message "Here you go" --media /path/to/file.png
```
Also check:
+1 -1
View File
@@ -174,7 +174,7 @@ In a new terminal:
```bash
clawdbot status
clawdbot health
clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
clawdbot message send --target +15555550123 --message "Hello from Clawdbot"
```
If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.
+1 -1
View File
@@ -87,7 +87,7 @@ On the first agent run, Clawdbot bootstraps a workspace (default `~/clawd`):
Gmail Pub/Sub setup is currently a manual step. Use:
```bash
clawdbot hooks gmail setup --account you@gmail.com
clawdbot webhooks gmail setup --account you@gmail.com
```
See [/automation/gmail-pubsub](/automation/gmail-pubsub) for details.
+2 -2
View File
@@ -20,7 +20,7 @@ runtime on the current machine.
- Output:
- default: prints reply text (plus `MEDIA:<url>` lines)
- `--json`: prints structured payload + metadata
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --to`).
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`).
If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
@@ -39,6 +39,6 @@ clawdbot agent --to +15555550123 --message "Summon reply" --deliver
- `--deliver`: send the reply to the chosen channel (requires `--to`)
- `--channel`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
- `--verbose <on|off>`: persist verbose level
- `--verbose <on|full|off>`: persist verbose level
- `--timeout <seconds>`: override agent timeout
- `--json`: output structured JSON
+1 -1
View File
@@ -37,7 +37,7 @@ The tool accepts a single `input` string that wraps one or more file operations:
- Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`.
- OpenAI-only (including OpenAI Codex). Optionally gate by model via
`tools.exec.applyPatch.allowModels`.
- Config is only under `tools.exec` (no `tools.bash` alias).
- Config is only under `tools.exec`.
## Example
+24 -2
View File
@@ -17,10 +17,15 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
- `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed)
- Need a real TTY? Use the tmux skill.
- Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output.
Note: `elevated` is ignored when sandboxing is off (exec already runs on the host).
## Config
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
## Examples
Foreground:
@@ -34,6 +39,23 @@ Background + poll:
{"tool":"process","action":"poll","sessionId":"<id>"}
```
Send keys (tmux-style):
```json
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["Enter"]}
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["C-c"]}
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["Up","Up","Enter"]}
```
Submit (send CR only):
```json
{"tool":"process","action":"submit","sessionId":"<id>"}
```
Paste (bracketed by default):
```json
{"tool":"process","action":"paste","sessionId":"<id>","text":"line1\nline2\n"}
```
## apply_patch (experimental)
`apply_patch` is a subtool of `exec` for structured multi-file edits.
@@ -52,4 +74,4 @@ Enable it explicitly:
Notes:
- Only available for OpenAI/OpenAI Codex models.
- Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`.
- Config lives under `tools.exec.applyPatch` (no `tools.bash` alias).
- Config lives under `tools.exec.applyPatch`.
+1 -1
View File
@@ -169,7 +169,7 @@ Core parameters:
- `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800)
- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
- Need a real TTY? Use the tmux skill.
- Need a real TTY? Set `pty: true`.
Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded.
+3 -3
View File
@@ -58,7 +58,7 @@ They run immediately, are stripped before the model sees the message, and the re
Text + native (when enabled):
- `/help`
- `/commands`
- `/status` (show current status; includes provider usage/quota when available, plus OAuth/token status block when OAuth profiles exist)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/usage` (alias: `/status`)
- `/whoami` (show your sender id; alias: `/id`)
@@ -74,7 +74,7 @@ Text + native (when enabled):
- `/send on|off|inherit` (owner-only)
- `/reset` or `/new`
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
- `/verbose on|off` (alias: `/v`)
- `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
@@ -105,7 +105,7 @@ Notes:
## Usage vs cost (what shows where)
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` when provider usage tracking is enabled.
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled.
- **Per-response tokens/cost** is controlled by `/cost on|off` (appended to normal replies).
- `/model status` is about **models/auth/endpoints**, not usage.
+3 -2
View File
@@ -33,12 +33,13 @@ read_when:
- **Embedded Pi**: the resolved level is passed to the in-process Pi agent runtime.
## Verbose directives (/verbose or /v)
- Levels: `on|full` or `off` (default).
- Levels: `on` (minimal) | `full` | `off` (default).
- Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state.
- `/verbose off` stores an explicit session override; clear it via the Sessions UI by choosing `inherit`.
- Inline directive affects only that message; session/global defaults apply otherwise.
- Send `/verbose` (or `/verbose:`) with no argument to see the current verbose level.
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool call back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command). These tool summaries are sent as soon as each tool starts (separate bubbles), not as streaming deltas.
- When verbose is `full`, tool outputs are also forwarded after completion (separate bubble, truncated to a safe length). If you toggle `/verbose on|full|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
## Reasoning visibility (/reasoning)
- Levels: `on|off|stream`.
+1 -1
View File
@@ -75,7 +75,7 @@ Core:
Session controls:
- `/think <off|minimal|low|medium|high>`
- `/verbose <on|off>`
- `/verbose <on|full|off>`
- `/reasoning <on|off|stream>`
- `/cost <on|off>`
- `/elevated <on|off>` (alias: `/elev`)
+7
View File
@@ -164,6 +164,13 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
return trimmed.includes(":");
},
targetHint: "<room|alias|user>",
},
directory: {
self: async () => null,
+33 -19
View File
@@ -8,12 +8,14 @@ import { hasControlCommand } from "../../../../../src/auto-reply/command-detecti
import { shouldHandleTextCommands } from "../../../../../src/auto-reply/commands-registry.js";
import { formatAgentEnvelope } from "../../../../../src/auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../../../../src/auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
} from "../../../../../src/auto-reply/reply/mentions.js";
import { createReplyDispatcherWithTyping } from "../../../../../src/auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../../../../src/channels/command-gating.js";
import { loadConfig } from "../../../../../src/config/config.js";
import { resolveStorePath, updateLastRoute } from "../../../../../src/config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js";
@@ -293,17 +295,26 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
text: bodyText,
mentionRegexes,
});
const commandAuthorized =
(!allowlistOnly && effectiveAllowFrom.length === 0) ||
resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: "matrix",
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
});
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) {
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`);
return;
}
const shouldRequireMention = isRoom
? roomConfigInfo.config?.autoReply === true
? false
@@ -354,13 +365,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = {
Body: body,
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
To: `room:${roomId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "room",
const ctxPayload = finalizeInboundContext({
Body: body,
RawBody: bodyText,
CommandBody: bodyText,
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
To: `room:${roomId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: envelopeFrom,
SenderName: senderName,
SenderId: senderId,
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
@@ -377,11 +391,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
OriginatingChannel: "matrix" as const,
OriginatingTo: `room:${roomId}`,
};
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
OriginatingChannel: "matrix" as const,
OriginatingTo: `room:${roomId}`,
});
if (isDirectMessage) {
const storePath = resolveStorePath(cfg.session?.store, {
-10
View File
@@ -6,16 +6,6 @@ export const matrixOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Matrix requires --to <room|alias|user>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, deps, replyToId, threadId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId =
+7
View File
@@ -133,6 +133,13 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
},
messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget,
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(conversation:|user:)/i.test(trimmed)) return true;
return trimmed.includes("@thread");
},
targetHint: "<conversationId|user:ID|conversation:ID>",
},
directory: {
self: async () => null,
@@ -5,6 +5,7 @@ import {
resolveInboundDebounceMs,
} from "../../../../src/auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
@@ -13,6 +14,7 @@ import {
type HistoryEntry,
} from "../../../../src/auto-reply/reply/history.js";
import { resolveMentionGating } from "../../../../src/channels/mention-gating.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import {
@@ -124,11 +126,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id;
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []);
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
// Check DM policy for direct messages.
const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom];
if (isDirectMessage && msteamsCfg) {
const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
const allowFrom = msteamsCfg.allowFrom ?? [];
const allowFrom = dmAllowFrom;
if (dmPolicy === "disabled") {
log.debug("dropping dm (dms disabled)");
@@ -171,13 +176,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}
}
if (!isDirectMessage && msteamsCfg) {
const groupPolicy = msteamsCfg.groupPolicy ?? "allowlist";
const groupAllowFrom =
msteamsCfg.groupAllowFrom ??
(msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : []);
const effectiveGroupAllowFrom = [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom];
const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? "allowlist") : "disabled";
const groupAllowFrom =
!isDirectMessage && msteamsCfg
? (msteamsCfg.groupAllowFrom ??
(msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : []))
: [];
const effectiveGroupAllowFrom =
!isDirectMessage && msteamsCfg
? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom]
: [];
if (!isDirectMessage && msteamsCfg) {
if (groupPolicy === "disabled") {
log.debug("dropping group message (groupPolicy: disabled)", {
conversationId,
@@ -208,6 +218,30 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}
}
const ownerAllowedForCommands = isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: effectiveDmAllowFrom,
senderId,
senderName,
});
const groupAllowedForCommands = isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
});
if (hasControlCommand(text, cfg) && !commandAuthorized) {
logVerbose(`msteams: drop control command from unauthorized sender ${senderId}`);
return;
}
// Build conversation reference for proactive replies.
const agent = activity.recipient;
const teamId = activity.channelData?.team?.id;
@@ -381,15 +415,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
});
}
const ctxPayload = {
Body: combinedBody,
RawBody: rawBody,
CommandBody: rawBody,
From: teamsFrom,
To: teamsTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : isChannel ? "room" : "group",
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
RawBody: rawBody,
CommandBody: rawBody,
From: teamsFrom,
To: teamsTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
ConversationLabel: envelopeFrom,
GroupSubject: !isDirectMessage ? conversationType : undefined,
SenderName: senderName,
SenderId: senderId,
@@ -397,12 +432,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
...mediaPayload,
};
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
...mediaPayload,
});
if (shouldLogVerbose()) {
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
-12
View File
@@ -9,18 +9,6 @@ export const msteamsOutbound: ChannelOutboundAdapter = {
chunker: chunkMarkdownText,
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to MS Teams requires --to <conversationId|user:ID|conversation:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, deps }) => {
const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text);
+2 -2
View File
@@ -26,7 +26,7 @@ export type MSTeamsProactiveContext = {
};
/**
* Parse the --to argument into a conversation reference lookup key.
* Parse the target value into a conversation reference lookup key.
* Supported formats:
* - conversation:19:abc@thread.tacv2 → lookup by conversation ID
* - user:aad-object-id → lookup by user AAD object ID
@@ -40,7 +40,7 @@ function parseRecipient(to: string): {
const finalize = (type: "conversation" | "user", id: string) => {
const normalized = id.trim();
if (!normalized) {
throw new Error(`Invalid --to value: missing ${type} id`);
throw new Error(`Invalid target value: missing ${type} id`);
}
return { type, id: normalized };
};
+7 -11
View File
@@ -150,6 +150,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
actions: zaloMessageActions,
messaging: {
normalizeTarget: normalizeZaloMessagingTarget,
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
return /^\d{3,}$/.test(trimmed);
},
targetHint: "<chatId>",
},
directory: {
self: async () => null,
@@ -185,7 +191,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
return "ZALO_BOT_TOKEN can only be used for the default account.";
}
if (!input.useEnv && !input.token && !input.tokenFile) {
return "Zalo requires --token or --token-file (or --use-env).";
return "Zalo requires token or --token-file (or --use-env).";
}
return null;
},
@@ -279,16 +285,6 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
return chunks;
},
textChunkLimit: 2000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Zalo requires --to <chatId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
+4 -2
View File
@@ -1,6 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ResolvedZaloAccount } from "./accounts.js";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import {
ZaloApiError,
deleteWebhook,
@@ -506,7 +507,7 @@ async function processMessageWithPipeline(params: {
body: rawBody,
});
const ctxPayload = {
const ctxPayload = finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
@@ -515,6 +516,7 @@ async function processMessageWithPipeline(params: {
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
Provider: "zalo",
@@ -525,7 +527,7 @@ async function processMessageWithPipeline(params: {
MediaUrl: mediaPath,
OriginatingChannel: "zalo",
OriginatingTo: `zalo:${chatId}`,
};
});
await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
+4 -4
View File
@@ -88,7 +88,7 @@ clawdbot channels login --channel zalouser
### Send a Message
```bash
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot!"
clawdbot message send --channel zalouser --target <threadId> --message "Hello from Clawdbot!"
```
## Configuration
@@ -152,10 +152,10 @@ zca account label <profile> "Work Account"
```bash
# Text
clawdbot message send --channel zalouser --to <threadId> --message "message"
clawdbot message send --channel zalouser --target <threadId> --message "message"
# Media (URL)
clawdbot message send --channel zalouser --to <threadId> --message "caption" --media-url "https://example.com/img.jpg"
clawdbot message send --channel zalouser --target <threadId> --message "caption" --media-url "https://example.com/img.jpg"
```
### Listener
@@ -188,7 +188,7 @@ Use `--profile` or `-p` to work with multiple accounts:
```bash
clawdbot channels login --channel zalouser --account work
clawdbot message send --channel zalouser --account work --to <id> --message "Hello"
clawdbot message send --channel zalouser --account work --target <id> --message "Hello"
ZCA_PROFILE=work zca listen
```
+6 -10
View File
@@ -218,6 +218,12 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
if (!trimmed) return undefined;
return trimmed.replace(/^(zalouser|zlu):/i, "");
},
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
return /^\d{3,}$/.test(trimmed);
},
targetHint: "<threadId>",
},
directory: {
self: async ({ cfg, accountId, runtime }) => {
@@ -373,16 +379,6 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
return chunks;
},
textChunkLimit: 2000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Zalouser requires --to <threadId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await sendMessageZalouser(to, text, { profile: account.profile });
+4 -2
View File
@@ -1,6 +1,7 @@
import type { ChildProcess } from "node:child_process";
import type { RuntimeEnv } from "../../../src/runtime.js";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
import { sendMessageZalouser } from "./send.js";
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
@@ -181,7 +182,7 @@ async function processMessage(
body: rawBody,
});
const ctxPayload = {
const ctxPayload = finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
@@ -190,6 +191,7 @@ async function processMessage(
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
Provider: "zalouser",
@@ -197,7 +199,7 @@ async function processMessage(
MessageSid: message.msgId ?? `${timestamp}`,
OriginatingChannel: "zalouser",
OriginatingTo: `zalouser:${chatId}`,
};
});
await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
+2 -1
View File
@@ -138,6 +138,7 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.46.0",
"@mariozechner/pi-ai": "0.46.0",
"@mariozechner/pi-coding-agent": "^0.46.0",
@@ -163,6 +164,7 @@
"hono": "4.11.4",
"jiti": "^2.6.1",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"linkedom": "^0.18.12",
"long": "5.3.2",
"markdown-it": "^14.1.0",
@@ -194,7 +196,6 @@
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.16",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lit": "^3.3.2",
"lucide": "^0.562.0",
"ollama": "^0.6.3",
+66 -3
View File
@@ -28,6 +28,9 @@ importers:
'@homebridge/ciao':
specifier: ^1.3.4
version: 1.3.4
'@lydell/node-pty':
specifier: 1.2.0-beta.3
version: 1.2.0-beta.3
'@mariozechner/pi-agent-core':
specifier: 0.46.0
version: 0.46.0(ws@8.19.0)(zod@4.3.5)
@@ -103,6 +106,9 @@ importers:
json5:
specifier: ^2.2.3
version: 2.2.3
jszip:
specifier: ^3.10.1
version: 3.10.1
linkedom:
specifier: ^0.18.12
version: 0.18.12
@@ -182,9 +188,6 @@ importers:
docx-preview:
specifier: ^0.3.7
version: 0.3.7
jszip:
specifier: ^3.10.1
version: 3.10.1
lit:
specifier: ^3.3.2
version: 3.3.2
@@ -935,6 +938,39 @@ packages:
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@lydell/node-pty-darwin-arm64@1.2.0-beta.3':
resolution: {integrity: sha512-owcv+e1/OSu3bf9ZBdUQqJsQF888KyuSIiPYFNn0fLhgkhm9F3Pvha76Kj5mCPnodf7hh3suDe7upw7GPRXftQ==}
cpu: [arm64]
os: [darwin]
'@lydell/node-pty-darwin-x64@1.2.0-beta.3':
resolution: {integrity: sha512-k38O+UviWrWdxtqZBBc/D8NJU11Rey8Y2YMwSWNxLv3eXZZdF5IVpbBkI/2RmLsV5nCcciqLPbukxeZnEfPlwA==}
cpu: [x64]
os: [darwin]
'@lydell/node-pty-linux-arm64@1.2.0-beta.3':
resolution: {integrity: sha512-HUwRpGu3O+4sv9DAQFKnyW5LYhyYu2SDUa/bdFO/t4dIFCM4uDJEq47wfRM7+aYtJTi1b3lakN8SlWeuFQqJQQ==}
cpu: [arm64]
os: [linux]
'@lydell/node-pty-linux-x64@1.2.0-beta.3':
resolution: {integrity: sha512-+RRY0PoCUeQaCvPR7/UnkGbxulwbFtoTWJfe+o4T1RcNtngrgaI55I9nl8CD8uqhGrB3smKuyvPM5UtwGhASUw==}
cpu: [x64]
os: [linux]
'@lydell/node-pty-win32-arm64@1.2.0-beta.3':
resolution: {integrity: sha512-UEDd9ASp2M3iIYpIzfmfBlpyn4+K1G4CAjYcHWStptCkefoSVXWTiUBIa1KjBjZi3/xmsHIDpBEYTkGWuvLt2Q==}
cpu: [arm64]
os: [win32]
'@lydell/node-pty-win32-x64@1.2.0-beta.3':
resolution: {integrity: sha512-TpdqSFYx7/Rj+68tuP6F/lkRYrHCYAIJgaS1bx3SctTkb5QAQCFwOKHd4xlsivmEOMT2LdhkJggPxwX9PAO5pQ==}
cpu: [x64]
os: [win32]
'@lydell/node-pty@1.2.0-beta.3':
resolution: {integrity: sha512-ngGAItlRhmJXrhspxt8kX13n1dVFqzETOq0m/+gqSkO8NJBvNMwP7FZckMwps2UFySdr4yxCXNGu/bumg5at6A==}
'@mariozechner/clipboard-darwin-arm64@0.3.0':
resolution: {integrity: sha512-7i4bitLzRSij0fj6q6tPmmf+JrwHqfBsBmf8mOcLVv0LVexD+4gEsyMait4i92exKYmCfna6uHKVS84G4nqehg==}
engines: {node: '>= 10'}
@@ -5142,6 +5178,33 @@ snapshots:
dependencies:
'@lit-labs/ssr-dom-shim': 1.5.1
'@lydell/node-pty-darwin-arm64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-darwin-x64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-linux-arm64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-linux-x64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-win32-arm64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-win32-x64@1.2.0-beta.3':
optional: true
'@lydell/node-pty@1.2.0-beta.3':
optionalDependencies:
'@lydell/node-pty-darwin-arm64': 1.2.0-beta.3
'@lydell/node-pty-darwin-x64': 1.2.0-beta.3
'@lydell/node-pty-linux-arm64': 1.2.0-beta.3
'@lydell/node-pty-linux-x64': 1.2.0-beta.3
'@lydell/node-pty-win32-arm64': 1.2.0-beta.3
'@lydell/node-pty-win32-x64': 1.2.0-beta.3
'@mariozechner/clipboard-darwin-arm64@0.3.0':
optional: true
+1
View File
@@ -5,6 +5,7 @@ packages:
onlyBuiltDependencies:
- '@whiskeysockets/baileys'
- '@lydell/node-pty'
- authenticate-pam
- esbuild
- protobufjs
+19
View File
@@ -1,4 +1,5 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { createSessionSlug as createSessionSlugId } from "./session-slug.js";
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
@@ -13,11 +14,21 @@ let jobTtlMs = clampTtl(Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10
export type ProcessStatus = "running" | "completed" | "failed" | "killed";
export type SessionStdin = {
write: (data: string, cb?: (err?: Error | null) => void) => void;
end: () => void;
destroyed?: boolean;
};
export interface ProcessSession {
id: string;
command: string;
scopeKey?: string;
sessionKey?: string;
notifyOnExit?: boolean;
exitNotified?: boolean;
child?: ChildProcessWithoutNullStreams;
stdin?: SessionStdin;
pid?: number;
startedAt: number;
cwd?: string;
@@ -55,6 +66,14 @@ const finishedSessions = new Map<string, FinishedSession>();
let sweeper: NodeJS.Timeout | null = null;
function isSessionIdTaken(id: string) {
return runningSessions.has(id) || finishedSessions.has(id);
}
export function createSessionSlug(): string {
return createSessionSlugId(isSessionIdTaken);
}
export function addSession(session: ProcessSession) {
runningSessions.set(session.id, session);
startSweeper();
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, expect, test } from "vitest";
import { createExecTool } from "./bash-tools.exec";
import { resetProcessRegistryForTests } from "./bash-process-registry";
afterEach(() => {
resetProcessRegistryForTests();
});
test("exec supports pty output", async () => {
const tool = createExecTool({ allowBackground: false });
const result = await tool.execute("toolcall", {
command: "node -e 'process.stdout.write(\"ok\")'",
pty: true,
});
expect(result.details.status).toBe("completed");
const text = result.content?.[0]?.text ?? "";
expect(text).toContain("ok");
});
+208 -44
View File
@@ -1,10 +1,20 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo } from "../logger.js";
import { addSession, appendOutput, markBackgrounded, markExited } from "./bash-process-registry.js";
import {
type ProcessSession,
type SessionStdin,
addSession,
appendOutput,
createSessionSlug,
markBackgrounded,
markExited,
tail,
} from "./bash-process-registry.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
import {
buildDockerExecArgs,
@@ -19,6 +29,7 @@ import {
truncateMiddle,
} from "./bash-tools.shared.js";
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
@@ -28,6 +39,27 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
);
const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const DEFAULT_NOTIFY_TAIL_CHARS = 400;
type PtyExitEvent = { exitCode: number; signal?: number };
type PtyListener<T> = (event: T) => void;
type PtyHandle = {
pid: number;
write: (data: string | Buffer) => void;
onData: (listener: PtyListener<string>) => void;
onExit: (listener: PtyListener<PtyExitEvent>) => void;
};
type PtySpawn = (
file: string,
args: string[] | string,
options: {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: Record<string, string>;
},
) => PtyHandle;
export type ExecToolDefaults = {
backgroundMs?: number;
@@ -36,6 +68,8 @@ export type ExecToolDefaults = {
elevated?: ExecElevatedDefaults;
allowBackground?: boolean;
scopeKey?: string;
sessionKey?: string;
notifyOnExit?: boolean;
cwd?: string;
};
@@ -62,6 +96,12 @@ const execSchema = Type.Object({
description: "Timeout in seconds (optional, kills process on expiry)",
}),
),
pty: Type.Optional(
Type.Boolean({
description:
"Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)",
}),
),
elevated: Type.Optional(
Type.Boolean({
description: "Run on the host with elevated permissions (if allowed)",
@@ -86,6 +126,28 @@ export type ExecToolDetails =
cwd?: string;
};
function normalizeNotifyOutput(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") {
if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return;
const sessionKey = session.sessionKey?.trim();
if (!sessionKey) return;
session.exitNotified = true;
const exitLabel = session.exitSignal
? `signal ${session.exitSignal}`
: `code ${session.exitCode ?? 0}`;
const output = normalizeNotifyOutput(
tail(session.tail || session.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
);
const summary = output
? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}`
: `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`;
enqueueSystemEvent(summary, { sessionKey });
requestHeartbeatNow({ reason: `exec:${session.id}:exit` });
}
export function createExecTool(
defaults?: ExecToolDefaults,
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
@@ -101,12 +163,14 @@ export function createExecTool(
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
? defaults.timeoutSec
: 1800;
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
return {
name: "exec",
label: "exec",
description:
"Execute shell commands with background continuation. Use yieldMs/background to continue later via process tool. For real TTY mode, use the tmux skill.",
"Execute shell commands with background continuation. Use yieldMs/background to continue later via process tool. Use pty=true for TTY-required commands (terminal UIs, coding agents).",
parameters: execSchema,
execute: async (_toolCallId, args, signal, onUpdate) => {
const params = args as {
@@ -116,6 +180,7 @@ export function createExecTool(
yieldMs?: number;
background?: boolean;
timeout?: number;
pty?: boolean;
elevated?: boolean;
};
@@ -125,7 +190,7 @@ export function createExecTool(
const maxOutput = DEFAULT_MAX_OUTPUT;
const startedAt = Date.now();
const sessionId = randomUUID();
const sessionId = createSessionSlug();
const warnings: string[] = [];
const backgroundRequested = params.background === true;
const yieldRequested = typeof params.yieldMs === "number";
@@ -202,38 +267,86 @@ export function createExecTool(
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: mergedEnv;
const child = sandbox
? spawn(
"docker",
buildDockerExecArgs({
containerName: sandbox.containerName,
command: params.command,
workdir: containerWorkdir ?? sandbox.containerWorkdir,
env,
tty: false,
}),
{
cwd: workdir,
env: process.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
)
: spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
const usePty = params.pty === true && !sandbox;
let child: ChildProcessWithoutNullStreams | null = null;
let pty: PtyHandle | null = null;
let stdin: SessionStdin | undefined;
if (sandbox) {
child = spawn(
"docker",
buildDockerExecArgs({
containerName: sandbox.containerName,
command: params.command,
workdir: containerWorkdir ?? sandbox.containerWorkdir,
env,
tty: params.pty === true,
}),
{
cwd: workdir,
env: process.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
},
) as ChildProcessWithoutNullStreams;
stdin = child.stdin;
} else if (usePty) {
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
spawn?: PtySpawn;
default?: { spawn?: PtySpawn };
};
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
if (!spawnPty) {
throw new Error("PTY support is unavailable (node-pty spawn not found).");
}
pty = spawnPty(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
name: process.env.TERM ?? "xterm-256color",
cols: 120,
rows: 30,
});
stdin = {
destroyed: false,
write: (data, cb) => {
try {
pty?.write(data);
cb?.(null);
} catch (err) {
cb?.(err as Error);
}
},
end: () => {
try {
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty?.write(eof);
} catch {
// ignore EOF errors
}
},
};
} else {
child = spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
}) as ChildProcessWithoutNullStreams;
stdin = child.stdin;
}
const session = {
id: sessionId,
command: params.command,
scopeKey: defaults?.scopeKey,
child,
pid: child?.pid,
sessionKey: notifySessionKey,
notifyOnExit,
exitNotified: false,
child: child ?? undefined,
stdin,
pid: child?.pid ?? pty?.pid,
startedAt,
cwd: workdir,
maxOutputChars: maxOutput,
@@ -254,7 +367,10 @@ export function createExecTool(
let yielded = false;
let yieldTimer: NodeJS.Timeout | null = null;
let timeoutTimer: NodeJS.Timeout | null = null;
let timeoutFinalizeTimer: NodeJS.Timeout | null = null;
let timedOut = false;
const timeoutFinalizeMs = 1000;
let rejectFn: ((err: Error) => void) | null = null;
const settle = (fn: () => void) => {
if (settled) return;
@@ -262,6 +378,19 @@ export function createExecTool(
fn();
};
const effectiveTimeout =
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
const finalizeTimeout = () => {
if (session.exited) return;
markExited(session, null, "SIGKILL", "failed");
maybeNotifyOnExit(session, "failed");
if (settled || !rejectFn) return;
const aggregated = session.aggregated.trim();
const reason = `Command timed out after ${effectiveTimeout} seconds`;
const message = aggregated ? `${aggregated}\n\n${reason}` : reason;
settle(() => rejectFn?.(new Error(message)));
};
// Tool-call abort should not kill backgrounded sessions; timeouts still must.
const onAbortSignal = () => {
if (yielded || session.backgrounded) return;
@@ -272,15 +401,17 @@ export function createExecTool(
const onTimeout = () => {
timedOut = true;
killSession(session);
if (!timeoutFinalizeTimer) {
timeoutFinalizeTimer = setTimeout(() => {
finalizeTimeout();
}, timeoutFinalizeMs);
}
};
if (signal?.aborted) onAbortSignal();
else if (signal) {
signal.addEventListener("abort", onAbortSignal, { once: true });
}
const effectiveTimeout =
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
if (effectiveTimeout > 0) {
timeoutTimer = setTimeout(() => {
onTimeout();
@@ -304,23 +435,41 @@ export function createExecTool(
});
};
child.stdout.on("data", (data) => {
const handleStdout = (data: string) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
};
child.stderr.on("data", (data) => {
const handleStderr = (data: string) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk);
emitUpdate();
}
});
};
if (pty) {
const cursorResponse = buildCursorPositionResponse();
pty.onData((data) => {
const raw = data.toString();
const { cleaned, requests } = stripDsrRequests(raw);
if (requests > 0) {
for (let i = 0; i < requests; i += 1) {
pty.write(cursorResponse);
}
}
handleStdout(cleaned);
});
} else if (child) {
child.stdout.on("data", handleStdout);
child.stderr.on("data", handleStderr);
}
return new Promise<AgentToolResult<ExecToolDetails>>((resolve, reject) => {
rejectFn = reject;
const resolveRunning = () => {
settle(() =>
resolve({
@@ -369,11 +518,16 @@ export function createExecTool(
const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer);
const durationMs = Date.now() - startedAt;
const wasSignal = exitSignal != null;
const isSuccess = code === 0 && !wasSignal && !signal?.aborted && !timedOut;
const status: "completed" | "failed" = isSuccess ? "completed" : "failed";
markExited(session, code, exitSignal, status);
maybeNotifyOnExit(session, status);
if (!session.child && session.stdin) {
session.stdin.destroyed = true;
}
if (yielded || session.backgrounded) return;
@@ -414,16 +568,26 @@ export function createExecTool(
// `exit` can fire before stdio fully flushes (notably on Windows).
// `close` waits for streams to close, so aggregated output is complete.
child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal);
});
if (pty) {
pty.onExit((event) => {
const rawSignal = event.signal ?? null;
const normalizedSignal = rawSignal === 0 ? null : rawSignal;
handleExit(event.exitCode ?? null, normalizedSignal);
});
} else if (child) {
child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal);
});
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer);
markExited(session, null, null, "failed");
maybeNotifyOnExit(session, "failed");
settle(() => reject(err));
});
}
});
},
};
@@ -0,0 +1,79 @@
import { afterEach, expect, test } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry";
import { createExecTool } from "./bash-tools.exec";
import { createProcessTool } from "./bash-tools.process";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
afterEach(() => {
resetProcessRegistryForTests();
});
test("process send-keys encodes Enter for pty sessions", async () => {
const execTool = createExecTool();
const processTool = createProcessTool();
const result = await execTool.execute("toolcall", {
command:
"node -e \"process.stdin.on('data', d => { process.stdout.write(d); if (d.includes(10) || d.includes(13)) process.exit(0); });\"",
pty: true,
background: true,
});
expect(result.details.status).toBe("running");
const sessionId = result.details.sessionId;
expect(sessionId).toBeTruthy();
await processTool.execute("toolcall", {
action: "send-keys",
sessionId,
keys: ["h", "i", "Enter"],
});
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
while (Date.now() < deadline) {
await wait(50);
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
const details = poll.details as { status?: string; aggregated?: string };
if (details.status !== "running") {
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("hi");
return;
}
}
throw new Error("PTY session did not exit after send-keys");
});
test("process submit sends CR for pty sessions", async () => {
const execTool = createExecTool();
const processTool = createProcessTool();
const result = await execTool.execute("toolcall", {
command:
"node -e \"process.stdin.on('data', d => { if (d.includes(13)) { process.stdout.write('submitted'); process.exit(0); } });\"",
pty: true,
background: true,
});
expect(result.details.status).toBe("running");
const sessionId = result.details.sessionId;
expect(sessionId).toBeTruthy();
await processTool.execute("toolcall", {
action: "submit",
sessionId,
});
for (let i = 0; i < 10; i += 1) {
await wait(50);
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
const details = poll.details as { status?: string; aggregated?: string };
if (details.status !== "running") {
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("submitted");
return;
}
}
throw new Error("PTY session did not exit after submit");
});
+231 -6
View File
@@ -19,6 +19,7 @@ import {
sliceLogLines,
truncateMiddle,
} from "./bash-tools.shared.js";
import { encodeKeySequence, encodePaste } from "./pty-keys.js";
export type ProcessToolDefaults = {
cleanupMs?: number;
@@ -29,6 +30,15 @@ const processSchema = Type.Object({
action: Type.String({ description: "Process action" }),
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
data: Type.Optional(Type.String({ description: "Data to write for write" })),
keys: Type.Optional(
Type.Array(Type.String(), { description: "Key tokens to send for send-keys" }),
),
hex: Type.Optional(
Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" }),
),
literal: Type.Optional(Type.String({ description: "Literal string for send-keys" })),
text: Type.Optional(Type.String({ description: "Text to paste for paste" })),
bracketed: Type.Optional(Type.Boolean({ description: "Wrap paste in bracketed mode" })),
eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })),
offset: Type.Optional(Type.Number({ description: "Log offset" })),
limit: Type.Optional(Type.Number({ description: "Log length" })),
@@ -48,13 +58,29 @@ export function createProcessTool(
return {
name: "process",
label: "process",
description: "Manage running exec sessions: list, poll, log, write, kill.",
description:
"Manage running exec sessions: list, poll, log, write, send-keys, submit, paste, kill.",
parameters: processSchema,
execute: async (_toolCallId, args) => {
const params = args as {
action: "list" | "poll" | "log" | "write" | "kill" | "clear" | "remove";
action:
| "list"
| "poll"
| "log"
| "write"
| "send-keys"
| "submit"
| "paste"
| "kill"
| "clear"
| "remove";
sessionId?: string;
data?: string;
keys?: string[];
hex?: string[];
literal?: string;
text?: string;
bracketed?: boolean;
eof?: boolean;
offset?: number;
limit?: number;
@@ -95,7 +121,7 @@ export function createProcessTool(
.sort((a, b) => b.startedAt - a.startedAt)
.map((s) => {
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
return `${s.sessionId.slice(0, 8)} ${pad(
return `${s.sessionId} ${pad(
s.status,
9,
)} ${formatDuration(s.runtimeMs)} :: ${label}`;
@@ -302,7 +328,8 @@ export function createProcessTool(
details: { status: "failed" },
};
}
if (!scopedSession.child?.stdin || scopedSession.child.stdin.destroyed) {
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
content: [
{
@@ -314,13 +341,13 @@ export function createProcessTool(
};
}
await new Promise<void>((resolve, reject) => {
scopedSession.child?.stdin.write(params.data ?? "", (err) => {
stdin.write(params.data ?? "", (err) => {
if (err) reject(err);
else resolve();
});
});
if (params.eof) {
scopedSession.child.stdin.end();
stdin.end();
}
return {
content: [
@@ -339,6 +366,204 @@ export function createProcessTool(
};
}
case "send-keys": {
if (!scopedSession) {
return {
content: [
{
type: "text",
text: `No active session found for ${params.sessionId}`,
},
],
details: { status: "failed" },
};
}
if (!scopedSession.backgrounded) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} is not backgrounded.`,
},
],
details: { status: "failed" },
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
const { data, warnings } = encodeKeySequence({
keys: params.keys,
hex: params.hex,
literal: params.literal,
});
if (!data) {
return {
content: [
{
type: "text",
text: "No key data provided.",
},
],
details: { status: "failed" },
};
}
await new Promise<void>((resolve, reject) => {
stdin.write(data, (err) => {
if (err) reject(err);
else resolve();
});
});
return {
content: [
{
type: "text",
text:
`Sent ${data.length} bytes to session ${params.sessionId}.` +
(warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""),
},
],
details: {
status: "running",
sessionId: params.sessionId,
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
},
};
}
case "submit": {
if (!scopedSession) {
return {
content: [
{
type: "text",
text: `No active session found for ${params.sessionId}`,
},
],
details: { status: "failed" },
};
}
if (!scopedSession.backgrounded) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} is not backgrounded.`,
},
],
details: { status: "failed" },
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
await new Promise<void>((resolve, reject) => {
stdin.write("\r", (err) => {
if (err) reject(err);
else resolve();
});
});
return {
content: [
{
type: "text",
text: `Submitted session ${params.sessionId} (sent CR).`,
},
],
details: {
status: "running",
sessionId: params.sessionId,
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
},
};
}
case "paste": {
if (!scopedSession) {
return {
content: [
{
type: "text",
text: `No active session found for ${params.sessionId}`,
},
],
details: { status: "failed" },
};
}
if (!scopedSession.backgrounded) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} is not backgrounded.`,
},
],
details: { status: "failed" },
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
const payload = encodePaste(params.text ?? "", params.bracketed !== false);
if (!payload) {
return {
content: [
{
type: "text",
text: "No paste text provided.",
},
],
details: { status: "failed" },
};
}
await new Promise<void>((resolve, reject) => {
stdin.write(payload, (err) => {
if (err) reject(err);
else resolve();
});
});
return {
content: [
{
type: "text",
text: `Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`,
},
],
details: {
status: "running",
sessionId: params.sessionId,
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
},
};
}
case "kill": {
if (!scopedSession) {
return {
+33 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js";
import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js";
import { buildDockerExecArgs } from "./bash-tools.shared.js";
import { sanitizeBinaryOutput } from "./shell-utils.js";
@@ -42,6 +43,7 @@ async function waitForCompletion(sessionId: string) {
beforeEach(() => {
resetProcessRegistryForTests();
resetSystemEventsForTest();
});
describe("exec tool backgrounding", () => {
@@ -241,6 +243,36 @@ describe("exec tool backgrounding", () => {
});
});
describe("exec notifyOnExit", () => {
it("enqueues a system event when a backgrounded exec exits", async () => {
const tool = createExecTool({
allowBackground: true,
backgroundMs: 0,
notifyOnExit: true,
sessionKey: "agent:main:main",
});
const result = await tool.execute("call1", {
command: echoAfterDelay("notify"),
background: true,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + (isWin ? 8000 : 2000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
expect(finished).toBeTruthy();
const events = peekSystemEvents("agent:main:main");
expect(events.some((event) => event.includes(sessionId.slice(0, 8)))).toBe(true);
});
});
describe("buildDockerExecArgs", () => {
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
const args = buildDockerExecArgs({
-9
View File
@@ -31,12 +31,3 @@ export function isMessagingToolSendAction(
if (!plugin?.actions?.extractToolSend) return false;
return Boolean(plugin.actions.extractToolSend({ args })?.to);
}
export function normalizeTargetForProvider(provider: string, raw?: string): string | undefined {
if (!raw) return undefined;
const providerId = normalizeChannelId(provider);
const plugin = providerId ? getChannelPlugin(providerId) : undefined;
const normalized =
plugin?.messaging?.normalizeTarget?.(raw) ?? (raw.trim().toLowerCase() || undefined);
return normalized || undefined;
}
+1
View File
@@ -213,6 +213,7 @@ export async function runEmbeddedPiAgent(
runId: params.runId,
abortSignal: params.abortSignal,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
onPartialReply: params.onPartialReply,
onAssistantMessageStart: params.onAssistantMessageStart,
onBlockReply: params.onBlockReply,
@@ -366,6 +366,7 @@ export async function runEmbeddedAttempt(
verboseLevel: params.verboseLevel,
reasoningMode: params.reasoningLevel ?? "off",
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
onToolResult: params.onToolResult,
onReasoningStream: params.onReasoningStream,
onBlockReply: params.onBlockReply,
@@ -38,6 +38,7 @@ export type RunEmbeddedPiAgentParams = {
runId: string;
abortSignal?: AbortSignal;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onBlockReply?: (payload: {
@@ -68,7 +68,7 @@ export function buildEmbeddedRunPayloads(params: {
if (errorText) replyItems.push({ text: errorText, isError: true });
const inlineToolResults =
params.inlineToolResultsAllowed && params.verboseLevel === "on" && params.toolMetas.length > 0;
params.inlineToolResultsAllowed && params.verboseLevel !== "off" && params.toolMetas.length > 0;
if (inlineToolResults) {
for (const { toolName, meta } of params.toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
@@ -43,6 +43,7 @@ export type EmbeddedRunAttemptParams = {
runId: string;
abortSignal?: AbortSignal;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onBlockReply?: (payload: {
+2 -4
View File
@@ -11,10 +11,8 @@ export function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
export function resolveExecToolDefaults(config?: ClawdbotConfig): ExecToolDefaults | undefined {
const tools = config?.tools;
if (!tools) return undefined;
if (!tools.exec) return tools.bash;
if (!tools.bash) return tools.exec;
return { ...tools.bash, ...tools.exec };
if (!tools?.exec) return undefined;
return tools.exec;
}
export function describeUnknownError(error: unknown): string {
@@ -5,12 +5,26 @@ import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
import {
extractToolResultText,
extractMessagingToolSend,
isToolResultError,
sanitizeToolResult,
} from "./pi-embedded-subscribe.tools.js";
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
const normalized = toolName.trim().toLowerCase();
if (normalized !== "exec" && normalized !== "bash") return meta;
if (!args || typeof args !== "object") return meta;
const record = args as Record<string, unknown>;
const flags: string[] = [];
if (record.pty === true) flags.push("pty");
if (record.elevated === true) flags.push("elevated");
if (flags.length === 0) return meta;
const suffix = flags.join(" · ");
return meta ? `${meta} · ${suffix}` : suffix;
}
export async function handleToolExecutionStart(
ctx: EmbeddedPiSubscribeContext,
evt: AgentEvent & { toolName: string; toolCallId: string; args: unknown },
@@ -36,7 +50,7 @@ export async function handleToolExecutionStart(
}
}
const meta = inferToolMetaFromArgs(toolName, args);
const meta = extendExecMeta(toolName, args, inferToolMetaFromArgs(toolName, args));
ctx.state.toolMetaById.set(toolCallId, meta);
ctx.log.debug(
`embedded run tool start: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
@@ -53,8 +67,8 @@ export async function handleToolExecutionStart(
args: args as Record<string, unknown>,
},
});
// Await onAgentEvent to ensure typing indicator starts before tool summaries are emitted.
await ctx.params.onAgentEvent?.({
// Best-effort typing signal; do not block tool summaries on slow emitters.
void ctx.params.onAgentEvent?.({
stream: "tool",
data: { phase: "start", name: toolName, toolCallId },
});
@@ -185,4 +199,11 @@ export function handleToolExecutionEnd(
ctx.log.debug(
`embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
);
if (ctx.params.onToolResult && ctx.shouldEmitToolOutput()) {
const outputText = extractToolResultText(sanitizedResult);
if (outputText) {
ctx.emitToolOutput(toolName, meta, outputText);
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function createEmbeddedPiSessionEventHandler(ctx: EmbeddedPiSubscribeCont
handleMessageEnd(ctx, evt as never);
return;
case "tool_execution_start":
// Async handler - awaits typing indicator before emitting tool summaries.
// Async handler - best-effort typing indicator, avoids blocking tool summaries.
// Catch rejections to avoid unhandled promise rejection crashes.
handleToolExecutionStart(ctx, evt as never).catch((err) => {
ctx.log.debug(`tool_execution_start handler failed: ${String(err)}`);
@@ -56,7 +56,9 @@ export type EmbeddedPiSubscribeContext = {
blockChunker: EmbeddedBlockChunker | null;
shouldEmitToolResult: () => boolean;
shouldEmitToolOutput: () => boolean;
emitToolSummary: (toolName?: string, meta?: string) => void;
emitToolOutput: (toolName?: string, meta?: string, output?: string) => void;
stripBlockTags: (
text: string,
state: { thinking: boolean; final: boolean; inlineCode?: InlineCodeState },
@@ -131,4 +131,66 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payload.text).toContain("snapshot");
expect(payload.text).toContain("https://example.com");
});
it("emits exec output in full verbose mode and includes PTY indicator", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-exec-full",
verboseLevel: "full",
onToolResult,
});
handler?.({
type: "tool_execution_start",
toolName: "exec",
toolCallId: "tool-exec-1",
args: { command: "claude", pty: true },
});
await Promise.resolve();
expect(onToolResult).toHaveBeenCalledTimes(1);
const summary = onToolResult.mock.calls[0][0];
expect(summary.text).toContain("exec");
expect(summary.text).toContain("pty");
handler?.({
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-1",
isError: false,
result: { content: [{ type: "text", text: "hello\nworld" }] },
});
await Promise.resolve();
expect(onToolResult).toHaveBeenCalledTimes(2);
const output = onToolResult.mock.calls[1][0];
expect(output.text).toContain("hello");
expect(output.text).toContain("```txt");
handler?.({
type: "tool_execution_end",
toolName: "read",
toolCallId: "tool-read-1",
isError: false,
result: { content: [{ type: "text", text: "file data" }] },
});
await Promise.resolve();
expect(onToolResult).toHaveBeenCalledTimes(3);
const readOutput = onToolResult.mock.calls[2][0];
expect(readOutput.text).toContain("file data");
});
});
+20 -1
View File
@@ -1,6 +1,7 @@
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import { truncateUtf16Safe } from "../utils.js";
import { type MessagingToolSend, normalizeTargetForProvider } from "./pi-embedded-messaging.js";
import { type MessagingToolSend } from "./pi-embedded-messaging.js";
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
const TOOL_RESULT_MAX_CHARS = 8000;
@@ -33,6 +34,24 @@ export function sanitizeToolResult(result: unknown): unknown {
return { ...record, content: sanitized };
}
export function extractToolResultText(result: unknown): string | undefined {
if (!result || typeof result !== "object") return undefined;
const record = result as Record<string, unknown>;
const content = Array.isArray(record.content) ? record.content : null;
if (!content) return undefined;
const texts = content
.map((item) => {
if (!item || typeof item !== "object") return undefined;
const entry = item as Record<string, unknown>;
if (entry.type !== "text" || typeof entry.text !== "string") return undefined;
const trimmed = entry.text.trim();
return trimmed ? trimmed : undefined;
})
.filter((value): value is string => Boolean(value));
if (texts.length === 0) return undefined;
return texts.join("\n");
}
export function isToolResultError(result: unknown): boolean {
if (!result || typeof result !== "object") return false;
const record = result as { details?: unknown };
+27 -1
View File
@@ -172,7 +172,16 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
const shouldEmitToolResult = () =>
typeof params.shouldEmitToolResult === "function"
? params.shouldEmitToolResult()
: params.verboseLevel === "on";
: params.verboseLevel === "on" || params.verboseLevel === "full";
const shouldEmitToolOutput = () =>
typeof params.shouldEmitToolOutput === "function"
? params.shouldEmitToolOutput()
: params.verboseLevel === "full";
const formatToolOutputBlock = (text: string) => {
const trimmed = text.trim();
if (!trimmed) return "(no output)";
return `\`\`\`txt\n${trimmed}\n\`\`\``;
};
const emitToolSummary = (toolName?: string, meta?: string) => {
if (!params.onToolResult) return;
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
@@ -187,6 +196,21 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
// ignore tool result delivery failures
}
};
const emitToolOutput = (toolName?: string, meta?: string, output?: string) => {
if (!params.onToolResult || !output) return;
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
const message = `${agg}\n${formatToolOutputBlock(output)}`;
const { text: cleanedText, mediaUrls } = parseReplyDirectives(message);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
try {
void params.onToolResult({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
} catch {
// ignore tool result delivery failures
}
};
const stripBlockTags = (
text: string,
@@ -363,7 +387,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
blockChunking,
blockChunker,
shouldEmitToolResult,
shouldEmitToolOutput,
emitToolSummary,
emitToolOutput,
stripBlockTags,
emitBlockChunk,
flushBlockReplyBuffer,
+3 -2
View File
@@ -1,14 +1,15 @@
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel } from "../auto-reply/thinking.js";
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
export type SubscribeEmbeddedPiSessionParams = {
session: AgentSession;
runId: string;
verboseLevel?: "off" | "on";
verboseLevel?: VerboseLevel;
reasoningMode?: ReasoningLevel;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onBlockReply?: (payload: {
+1
View File
@@ -181,6 +181,7 @@ export function createClawdbotCodingTools(options?: {
cwd: options?.workspaceDir,
allowBackground,
scopeKey,
sessionKey: options?.sessionKey,
sandbox: sandbox
? {
containerName: sandbox.containerName,
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "vitest";
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
test("stripDsrRequests removes cursor queries and counts them", () => {
const input = "hi\x1b[6nthere\x1b[?6n";
const { cleaned, requests } = stripDsrRequests(input);
expect(cleaned).toBe("hithere");
expect(requests).toBe(2);
});
test("buildCursorPositionResponse returns CPR sequence", () => {
expect(buildCursorPositionResponse()).toBe("\x1b[1;1R");
expect(buildCursorPositionResponse(12, 34)).toBe("\x1b[12;34R");
});
+15
View File
@@ -0,0 +1,15 @@
const ESC = String.fromCharCode(0x1b);
const DSR_PATTERN = new RegExp(`${ESC}\\[\\??6n`, "g");
export function stripDsrRequests(input: string): { cleaned: string; requests: number } {
let requests = 0;
const cleaned = input.replace(DSR_PATTERN, () => {
requests += 1;
return "";
});
return { cleaned, requests };
}
export function buildCursorPositionResponse(row = 1, col = 1): string {
return `\x1b[${row};${col}R`;
}
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from "vitest";
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, encodeKeySequence, encodePaste } from "./pty-keys.js";
test("encodeKeySequence maps common keys and modifiers", () => {
const enter = encodeKeySequence({ keys: ["Enter"] });
expect(enter.data).toBe("\r");
const ctrlC = encodeKeySequence({ keys: ["C-c"] });
expect(ctrlC.data).toBe("\x03");
const altX = encodeKeySequence({ keys: ["M-x"] });
expect(altX.data).toBe("\x1bx");
const shiftTab = encodeKeySequence({ keys: ["S-Tab"] });
expect(shiftTab.data).toBe("\x1b[Z");
const kpEnter = encodeKeySequence({ keys: ["KPEnter"] });
expect(kpEnter.data).toBe("\x1bOM");
});
test("encodeKeySequence supports hex + literal with warnings", () => {
const result = encodeKeySequence({
literal: "hi",
hex: ["0d", "0x0a", "zz"],
keys: ["Enter"],
});
expect(result.data).toBe("hi\r\n\r");
expect(result.warnings.length).toBe(1);
});
test("encodePaste wraps bracketed sequences by default", () => {
const payload = encodePaste("line1\nline2\n");
expect(payload.startsWith(BRACKETED_PASTE_START)).toBe(true);
expect(payload.endsWith(BRACKETED_PASTE_END)).toBe(true);
});
+266
View File
@@ -0,0 +1,266 @@
const ESC = "\x1b";
const CR = "\r";
const TAB = "\t";
const BACKSPACE = "\x7f";
export const BRACKETED_PASTE_START = `${ESC}[200~`;
export const BRACKETED_PASTE_END = `${ESC}[201~`;
type Modifiers = {
ctrl: boolean;
alt: boolean;
shift: boolean;
};
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const namedKeyMap = new Map<string, string>([
["enter", CR],
["return", CR],
["tab", TAB],
["escape", ESC],
["esc", ESC],
["space", " "],
["bspace", BACKSPACE],
["backspace", BACKSPACE],
["up", `${ESC}[A`],
["down", `${ESC}[B`],
["right", `${ESC}[C`],
["left", `${ESC}[D`],
["home", `${ESC}[1~`],
["end", `${ESC}[4~`],
["pageup", `${ESC}[5~`],
["pgup", `${ESC}[5~`],
["ppage", `${ESC}[5~`],
["pagedown", `${ESC}[6~`],
["pgdn", `${ESC}[6~`],
["npage", `${ESC}[6~`],
["insert", `${ESC}[2~`],
["ic", `${ESC}[2~`],
["delete", `${ESC}[3~`],
["del", `${ESC}[3~`],
["dc", `${ESC}[3~`],
["btab", `${ESC}[Z`],
["f1", `${ESC}OP`],
["f2", `${ESC}OQ`],
["f3", `${ESC}OR`],
["f4", `${ESC}OS`],
["f5", `${ESC}[15~`],
["f6", `${ESC}[17~`],
["f7", `${ESC}[18~`],
["f8", `${ESC}[19~`],
["f9", `${ESC}[20~`],
["f10", `${ESC}[21~`],
["f11", `${ESC}[23~`],
["f12", `${ESC}[24~`],
["kp/", `${ESC}Oo`],
["kp*", `${ESC}Oj`],
["kp-", `${ESC}Om`],
["kp+", `${ESC}Ok`],
["kp7", `${ESC}Ow`],
["kp8", `${ESC}Ox`],
["kp9", `${ESC}Oy`],
["kp4", `${ESC}Ot`],
["kp5", `${ESC}Ou`],
["kp6", `${ESC}Ov`],
["kp1", `${ESC}Oq`],
["kp2", `${ESC}Or`],
["kp3", `${ESC}Os`],
["kp0", `${ESC}Op`],
["kp.", `${ESC}On`],
["kpenter", `${ESC}OM`],
]);
const modifiableNamedKeys = new Set([
"up",
"down",
"left",
"right",
"home",
"end",
"pageup",
"pgup",
"ppage",
"pagedown",
"pgdn",
"npage",
"insert",
"ic",
"delete",
"del",
"dc",
]);
export type KeyEncodingRequest = {
keys?: string[];
hex?: string[];
literal?: string;
};
export type KeyEncodingResult = {
data: string;
warnings: string[];
};
export function encodeKeySequence(request: KeyEncodingRequest): KeyEncodingResult {
const warnings: string[] = [];
let data = "";
if (request.literal) {
data += request.literal;
}
if (request.hex?.length) {
for (const raw of request.hex) {
const byte = parseHexByte(raw);
if (byte === null) {
warnings.push(`Invalid hex byte: ${raw}`);
continue;
}
data += String.fromCharCode(byte);
}
}
if (request.keys?.length) {
for (const token of request.keys) {
data += encodeKeyToken(token, warnings);
}
}
return { data, warnings };
}
export function encodePaste(text: string, bracketed = true): string {
if (!bracketed) return text;
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
}
function encodeKeyToken(raw: string, warnings: string[]): string {
const token = raw.trim();
if (!token) return "";
if (token.length === 2 && token.startsWith("^")) {
const ctrl = toCtrlChar(token[1]);
if (ctrl) return ctrl;
}
const parsed = parseModifiers(token);
const base = parsed.base;
const baseLower = base.toLowerCase();
if (baseLower === "tab" && parsed.mods.shift) {
return `${ESC}[Z`;
}
const baseSeq = namedKeyMap.get(baseLower);
if (baseSeq) {
let seq = baseSeq;
if (modifiableNamedKeys.has(baseLower) && hasAnyModifier(parsed.mods)) {
const mod = xtermModifier(parsed.mods);
if (mod > 1) {
const modified = applyXtermModifier(seq, mod);
if (modified) {
seq = modified;
return seq;
}
}
}
if (parsed.mods.alt) {
return `${ESC}${seq}`;
}
return seq;
}
if (base.length === 1) {
return applyCharModifiers(base, parsed.mods);
}
if (parsed.hasModifiers) {
warnings.push(`Unknown key "${base}" for modifiers; sending literal.`);
}
return base;
}
function parseModifiers(token: string) {
const mods: Modifiers = { ctrl: false, alt: false, shift: false };
let rest = token;
let sawModifiers = false;
while (rest.length > 2 && rest[1] === "-") {
const mod = rest[0].toLowerCase();
if (mod === "c") mods.ctrl = true;
else if (mod === "m") mods.alt = true;
else if (mod === "s") mods.shift = true;
else break;
sawModifiers = true;
rest = rest.slice(2);
}
return { mods, base: rest, hasModifiers: sawModifiers };
}
function applyCharModifiers(char: string, mods: Modifiers): string {
let value = char;
if (mods.shift && value.length === 1 && /[a-z]/.test(value)) {
value = value.toUpperCase();
}
if (mods.ctrl) {
const ctrl = toCtrlChar(value);
if (ctrl) value = ctrl;
}
if (mods.alt) {
value = `${ESC}${value}`;
}
return value;
}
function toCtrlChar(char: string): string | null {
if (char.length !== 1) return null;
if (char === "?") return "\x7f";
const code = char.toUpperCase().charCodeAt(0);
if (code >= 64 && code <= 95) {
return String.fromCharCode(code & 0x1f);
}
return null;
}
function xtermModifier(mods: Modifiers): number {
let mod = 1;
if (mods.shift) mod += 1;
if (mods.alt) mod += 2;
if (mods.ctrl) mod += 4;
return mod;
}
function applyXtermModifier(sequence: string, modifier: number): string | null {
const escPattern = escapeRegExp(ESC);
const csiNumber = new RegExp(`^${escPattern}\\[(\\d+)([~A-Z])$`);
const csiArrow = new RegExp(`^${escPattern}\\[(A|B|C|D|H|F)$`);
const numberMatch = sequence.match(csiNumber);
if (numberMatch) {
return `${ESC}[${numberMatch[1]};${modifier}${numberMatch[2]}`;
}
const arrowMatch = sequence.match(csiArrow);
if (arrowMatch) {
return `${ESC}[1;${modifier}${arrowMatch[1]}`;
}
return null;
}
function hasAnyModifier(mods: Modifiers): boolean {
return mods.ctrl || mods.alt || mods.shift;
}
function parseHexByte(raw: string): number | null {
const trimmed = raw.trim().toLowerCase();
const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
if (!/^[0-9a-f]{1,2}$/.test(normalized)) return null;
const value = Number.parseInt(normalized, 16);
if (Number.isNaN(value) || value < 0 || value > 0xff) return null;
return value;
}
+3 -1
View File
@@ -37,5 +37,7 @@ export function channelTargetSchema(options?: { description?: string }) {
}
export function channelTargetsSchema(options?: { description?: string }) {
return Type.Array(channelTargetSchema({ description: options?.description ?? CHANNEL_TARGETS_DESCRIPTION }));
return Type.Array(
channelTargetSchema({ description: options?.description ?? CHANNEL_TARGETS_DESCRIPTION }),
);
}
+26
View File
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSessionSlug } from "./session-slug.js";
describe("session slug", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("generates a two-word slug by default", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
const slug = createSessionSlug();
expect(slug).toBe("amber-atlas");
});
it("adds a numeric suffix when the base slug is taken", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
const slug = createSessionSlug((id) => id === "amber-atlas");
expect(slug).toBe("amber-atlas-2");
});
it("falls back to three words when collisions persist", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
const slug = createSessionSlug((id) => /^amber-atlas(-\d+)?$/.test(id));
expect(slug).toBe("amber-atlas-atlas");
});
});
+133
View File
@@ -0,0 +1,133 @@
const SLUG_ADJECTIVES = [
"amber",
"briny",
"brisk",
"calm",
"clear",
"cool",
"crisp",
"dawn",
"delta",
"ember",
"faint",
"fast",
"fresh",
"gentle",
"glow",
"good",
"grand",
"keen",
"kind",
"lucky",
"marine",
"mellow",
"mild",
"neat",
"nimble",
"nova",
"oceanic",
"plaid",
"quick",
"quiet",
"rapid",
"salty",
"sharp",
"swift",
"tender",
"tidal",
"tidy",
"tide",
"vivid",
"warm",
"wild",
"young",
];
const SLUG_NOUNS = [
"atlas",
"basil",
"bison",
"bloom",
"breeze",
"canyon",
"cedar",
"claw",
"cloud",
"comet",
"coral",
"cove",
"crest",
"crustacean",
"daisy",
"dune",
"ember",
"falcon",
"fjord",
"forest",
"glade",
"gulf",
"harbor",
"haven",
"kelp",
"lagoon",
"lobster",
"meadow",
"mist",
"nudibranch",
"nexus",
"ocean",
"orbit",
"otter",
"pine",
"prairie",
"reef",
"ridge",
"river",
"rook",
"sable",
"sage",
"seaslug",
"shell",
"shoal",
"shore",
"slug",
"summit",
"tidepool",
"trail",
"valley",
"wharf",
"willow",
"zephyr",
];
function randomChoice(values: string[], fallback: string) {
return values[Math.floor(Math.random() * values.length)] ?? fallback;
}
function createSlugBase(words = 2) {
const parts = [randomChoice(SLUG_ADJECTIVES, "steady"), randomChoice(SLUG_NOUNS, "harbor")];
if (words > 2) parts.push(randomChoice(SLUG_NOUNS, "reef"));
return parts.join("-");
}
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
const isIdTaken = isTaken ?? (() => false);
for (let attempt = 0; attempt < 12; attempt += 1) {
const base = createSlugBase(2);
if (!isIdTaken(base)) return base;
for (let i = 2; i <= 12; i += 1) {
const candidate = `${base}-${i}`;
if (!isIdTaken(candidate)) return candidate;
}
}
for (let attempt = 0; attempt < 12; attempt += 1) {
const base = createSlugBase(3);
if (!isIdTaken(base)) return base;
for (let i = 2; i <= 12; i += 1) {
const candidate = `${base}-${i}`;
if (!isIdTaken(candidate)) return candidate;
}
}
const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`;
return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback;
}
+175
View File
@@ -0,0 +1,175 @@
import { type QueueDropPolicy, type QueueMode } from "../auto-reply/reply/queue.js";
import { defaultRuntime } from "../runtime.js";
import {
type DeliveryContext,
deliveryContextKey,
normalizeDeliveryContext,
} from "../utils/delivery-context.js";
import {
applyQueueDropPolicy,
buildCollectPrompt,
buildQueueSummaryPrompt,
hasCrossChannelItems,
waitForQueueDebounce,
} from "../utils/queue-helpers.js";
export type AnnounceQueueItem = {
prompt: string;
summaryLine?: string;
enqueuedAt: number;
sessionKey: string;
origin?: DeliveryContext;
originKey?: string;
};
export type AnnounceQueueSettings = {
mode: QueueMode;
debounceMs?: number;
cap?: number;
dropPolicy?: QueueDropPolicy;
};
type AnnounceQueueState = {
items: AnnounceQueueItem[];
draining: boolean;
lastEnqueuedAt: number;
mode: QueueMode;
debounceMs: number;
cap: number;
dropPolicy: QueueDropPolicy;
droppedCount: number;
summaryLines: string[];
send: (item: AnnounceQueueItem) => Promise<void>;
};
const ANNOUNCE_QUEUES = new Map<string, AnnounceQueueState>();
function getAnnounceQueue(
key: string,
settings: AnnounceQueueSettings,
send: (item: AnnounceQueueItem) => Promise<void>,
) {
const existing = ANNOUNCE_QUEUES.get(key);
if (existing) {
existing.mode = settings.mode;
existing.debounceMs =
typeof settings.debounceMs === "number"
? Math.max(0, settings.debounceMs)
: existing.debounceMs;
existing.cap =
typeof settings.cap === "number" && settings.cap > 0
? Math.floor(settings.cap)
: existing.cap;
existing.dropPolicy = settings.dropPolicy ?? existing.dropPolicy;
existing.send = send;
return existing;
}
const created: AnnounceQueueState = {
items: [],
draining: false,
lastEnqueuedAt: 0,
mode: settings.mode,
debounceMs: typeof settings.debounceMs === "number" ? Math.max(0, settings.debounceMs) : 1000,
cap: typeof settings.cap === "number" && settings.cap > 0 ? Math.floor(settings.cap) : 20,
dropPolicy: settings.dropPolicy ?? "summarize",
droppedCount: 0,
summaryLines: [],
send,
};
ANNOUNCE_QUEUES.set(key, created);
return created;
}
function scheduleAnnounceDrain(key: string) {
const queue = ANNOUNCE_QUEUES.get(key);
if (!queue || queue.draining) return;
queue.draining = true;
void (async () => {
try {
let forceIndividualCollect = false;
while (queue.items.length > 0 || queue.droppedCount > 0) {
await waitForQueueDebounce(queue);
if (queue.mode === "collect") {
if (forceIndividualCollect) {
const next = queue.items.shift();
if (!next) break;
await queue.send(next);
continue;
}
const isCrossChannel = hasCrossChannelItems(queue.items, (item) => {
if (!item.origin) return {};
if (!item.originKey) return { cross: true };
return { key: item.originKey };
});
if (isCrossChannel) {
forceIndividualCollect = true;
const next = queue.items.shift();
if (!next) break;
await queue.send(next);
continue;
}
const items = queue.items.splice(0, queue.items.length);
const summary = buildQueueSummaryPrompt({ state: queue, noun: "announce" });
const prompt = buildCollectPrompt({
title: "[Queued announce messages while agent was busy]",
items,
summary,
renderItem: (item, idx) => `---\nQueued #${idx + 1}\n${item.prompt}`.trim(),
});
const last = items.at(-1);
if (!last) break;
await queue.send({ ...last, prompt });
continue;
}
const summaryPrompt = buildQueueSummaryPrompt({ state: queue, noun: "announce" });
if (summaryPrompt) {
const next = queue.items.shift();
if (!next) break;
await queue.send({ ...next, prompt: summaryPrompt });
continue;
}
const next = queue.items.shift();
if (!next) break;
await queue.send(next);
}
} catch (err) {
defaultRuntime.error?.(`announce queue drain failed for ${key}: ${String(err)}`);
} finally {
queue.draining = false;
if (queue.items.length === 0 && queue.droppedCount === 0) {
ANNOUNCE_QUEUES.delete(key);
} else {
scheduleAnnounceDrain(key);
}
}
})();
}
export function enqueueAnnounce(params: {
key: string;
item: AnnounceQueueItem;
settings: AnnounceQueueSettings;
send: (item: AnnounceQueueItem) => Promise<void>;
}): boolean {
const queue = getAnnounceQueue(params.key, params.settings, params.send);
queue.lastEnqueuedAt = Date.now();
const shouldEnqueue = applyQueueDropPolicy({
queue,
summarize: (item) => item.summaryLine?.trim() || item.prompt.trim(),
});
if (!shouldEnqueue) {
if (queue.dropPolicy === "new") {
scheduleAnnounceDrain(params.key);
}
return false;
}
const origin = normalizeDeliveryContext(params.item.origin);
const originKey = deliveryContextKey(origin);
queue.items.push({ ...params.item, origin, originKey });
scheduleAnnounceDrain(params.key);
return true;
}
+83 -5
View File
@@ -192,7 +192,60 @@ describe("subagent announce formatting", () => {
expect(call?.params?.accountId).toBe("kev");
});
it("uses requester accountId for direct announce when not queued", async () => {
it("splits collect-mode queues when accountId differs", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
sessionStore = {
"agent:main:main": {
sessionId: "session-acc-split",
lastChannel: "whatsapp",
lastTo: "+1555",
queueMode: "collect",
queueDebounceMs: 80,
},
};
await Promise.all([
runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test-a",
childRunId: "run-a",
requesterSessionKey: "main",
requesterDisplayKey: "main",
requesterOrigin: { accountId: "acct-a" },
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
}),
runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test-b",
childRunId: "run-b",
requesterSessionKey: "main",
requesterDisplayKey: "main",
requesterOrigin: { accountId: "acct-b" },
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
}),
]);
await new Promise((r) => setTimeout(r, 120));
expect(agentSpy).toHaveBeenCalledTimes(2);
const accountIds = agentSpy.mock.calls.map(
(call) => (call?.[0] as { params?: { accountId?: string } })?.params?.accountId,
);
expect(accountIds).toEqual(expect.arrayContaining(["acct-a", "acct-b"]));
});
it("uses requester origin for direct announce when not queued", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
@@ -201,8 +254,7 @@ describe("subagent announce formatting", () => {
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct",
requesterSessionKey: "agent:main:main",
requesterChannel: "whatsapp",
requesterAccountId: "acct-123",
requesterOrigin: { channel: "whatsapp", accountId: "acct-123" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
@@ -219,6 +271,32 @@ describe("subagent announce formatting", () => {
expect(call?.params?.accountId).toBe("acct-123");
});
it("normalizes requesterOrigin for direct announce delivery", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct-origin",
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: " whatsapp ", accountId: " acct-987 " },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("whatsapp");
expect(call?.params?.accountId).toBe("acct-987");
});
it("splits collect-mode announces when accountId differs", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
@@ -237,7 +315,7 @@ describe("subagent announce formatting", () => {
childSessionKey: "agent:main:subagent:test",
childRunId: "run-a",
requesterSessionKey: "main",
requesterAccountId: "acct-a",
requesterOrigin: { accountId: "acct-a" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
@@ -252,7 +330,7 @@ describe("subagent announce formatting", () => {
childSessionKey: "agent:main:subagent:test",
childRunId: "run-b",
requesterSessionKey: "main",
requesterAccountId: "acct-b",
requesterOrigin: { accountId: "acct-b" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
+31 -257
View File
@@ -9,18 +9,17 @@ import {
resolveStorePath,
} from "../config/sessions.js";
import { normalizeMainKey } from "../routing/session-key.js";
import {
resolveQueueSettings,
type QueueDropPolicy,
type QueueMode,
} from "../auto-reply/reply/queue.js";
import { resolveQueueSettings } from "../auto-reply/reply/queue.js";
import { callGateway } from "../gateway/call.js";
import { defaultRuntime } from "../runtime.js";
import {
type DeliveryContext,
deliveryContextFromSession,
mergeDeliveryContext,
normalizeDeliveryContext,
} from "../utils/delivery-context.js";
import { isEmbeddedPiRunActive, queueEmbeddedPiMessage } from "./pi-embedded.js";
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
import { readLatestAssistantReply } from "./tools/agent-step.js";
function formatDurationShort(valueMs?: number) {
@@ -88,219 +87,13 @@ async function waitForSessionUsage(params: { sessionKey: string }) {
return { entry, storePath };
}
type AnnounceQueueItem = {
prompt: string;
summaryLine?: string;
enqueuedAt: number;
sessionKey: string;
origin?: DeliveryContext;
};
type DeliveryContextSource = Parameters<typeof deliveryContextFromSession>[0];
type AnnounceQueueState = {
items: AnnounceQueueItem[];
draining: boolean;
lastEnqueuedAt: number;
mode: QueueMode;
debounceMs: number;
cap: number;
dropPolicy: QueueDropPolicy;
droppedCount: number;
summaryLines: string[];
};
const ANNOUNCE_QUEUES = new Map<string, AnnounceQueueState>();
function getAnnounceQueue(
key: string,
settings: { mode: QueueMode; debounceMs?: number; cap?: number; dropPolicy?: QueueDropPolicy },
) {
const existing = ANNOUNCE_QUEUES.get(key);
if (existing) {
existing.mode = settings.mode;
existing.debounceMs =
typeof settings.debounceMs === "number"
? Math.max(0, settings.debounceMs)
: existing.debounceMs;
existing.cap =
typeof settings.cap === "number" && settings.cap > 0
? Math.floor(settings.cap)
: existing.cap;
existing.dropPolicy = settings.dropPolicy ?? existing.dropPolicy;
return existing;
}
const created: AnnounceQueueState = {
items: [],
draining: false,
lastEnqueuedAt: 0,
mode: settings.mode,
debounceMs: typeof settings.debounceMs === "number" ? Math.max(0, settings.debounceMs) : 1000,
cap: typeof settings.cap === "number" && settings.cap > 0 ? Math.floor(settings.cap) : 20,
dropPolicy: settings.dropPolicy ?? "summarize",
droppedCount: 0,
summaryLines: [],
};
ANNOUNCE_QUEUES.set(key, created);
return created;
}
function elideText(text: string, limit = 140): string {
if (text.length <= limit) return text;
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
function buildQueueSummaryLine(item: AnnounceQueueItem): string {
const base = item.summaryLine?.trim() || item.prompt.trim();
const cleaned = base.replace(/\s+/g, " ").trim();
return elideText(cleaned, 160);
}
function enqueueAnnounce(
key: string,
item: AnnounceQueueItem,
settings: { mode: QueueMode; debounceMs?: number; cap?: number; dropPolicy?: QueueDropPolicy },
): boolean {
const queue = getAnnounceQueue(key, settings);
queue.lastEnqueuedAt = Date.now();
const cap = queue.cap;
if (cap > 0 && queue.items.length >= cap) {
if (queue.dropPolicy === "new") {
return false;
}
const dropCount = queue.items.length - cap + 1;
const dropped = queue.items.splice(0, dropCount);
if (queue.dropPolicy === "summarize") {
for (const droppedItem of dropped) {
queue.droppedCount += 1;
queue.summaryLines.push(buildQueueSummaryLine(droppedItem));
}
while (queue.summaryLines.length > cap) queue.summaryLines.shift();
}
}
queue.items.push(item);
return true;
}
async function waitForQueueDebounce(queue: { debounceMs: number; lastEnqueuedAt: number }) {
const debounceMs = Math.max(0, queue.debounceMs);
if (debounceMs <= 0) return;
while (true) {
const since = Date.now() - queue.lastEnqueuedAt;
if (since >= debounceMs) return;
await new Promise((resolve) => setTimeout(resolve, debounceMs - since));
}
}
function buildSummaryPrompt(queue: {
dropPolicy: QueueDropPolicy;
droppedCount: number;
summaryLines: string[];
}): string | undefined {
if (queue.dropPolicy !== "summarize" || queue.droppedCount <= 0) {
return undefined;
}
const lines = [
`[Queue overflow] Dropped ${queue.droppedCount} announce${queue.droppedCount === 1 ? "" : "s"} due to cap.`,
];
if (queue.summaryLines.length > 0) {
lines.push("Summary:");
for (const line of queue.summaryLines) {
lines.push(`- ${line}`);
}
}
queue.droppedCount = 0;
queue.summaryLines = [];
return lines.join("\n");
}
function buildCollectPrompt(items: AnnounceQueueItem[], summary?: string): string {
const blocks: string[] = ["[Queued announce messages while agent was busy]"];
if (summary) blocks.push(summary);
items.forEach((item, idx) => {
blocks.push(`---\nQueued #${idx + 1}\n${item.prompt}`.trim());
});
return blocks.join("\n\n");
}
function hasCrossChannelItems(items: AnnounceQueueItem[]): boolean {
const keys = new Set<string>();
let hasUnkeyed = false;
for (const item of items) {
const origin = item.origin;
const channel = origin?.channel;
const to = origin?.to;
const accountId = origin?.accountId;
if (!channel && !to && !accountId) {
hasUnkeyed = true;
continue;
}
if (!channel || !to) {
return true;
}
keys.add([channel, to, accountId || ""].join("|"));
}
if (keys.size === 0) return false;
if (hasUnkeyed) return true;
return keys.size > 1;
}
function scheduleAnnounceDrain(key: string) {
const queue = ANNOUNCE_QUEUES.get(key);
if (!queue || queue.draining) return;
queue.draining = true;
void (async () => {
try {
let forceIndividualCollect = false;
while (queue.items.length > 0 || queue.droppedCount > 0) {
await waitForQueueDebounce(queue);
if (queue.mode === "collect") {
if (forceIndividualCollect) {
const next = queue.items.shift();
if (!next) break;
await sendAnnounce(next);
continue;
}
const isCrossChannel = hasCrossChannelItems(queue.items);
if (isCrossChannel) {
forceIndividualCollect = true;
const next = queue.items.shift();
if (!next) break;
await sendAnnounce(next);
continue;
}
const items = queue.items.splice(0, queue.items.length);
const summary = buildSummaryPrompt(queue);
const prompt = buildCollectPrompt(items, summary);
const last = items.at(-1);
if (!last) break;
await sendAnnounce({ ...last, prompt });
continue;
}
const summaryPrompt = buildSummaryPrompt(queue);
if (summaryPrompt) {
const next = queue.items.shift();
if (!next) break;
await sendAnnounce({ ...next, prompt: summaryPrompt });
continue;
}
const next = queue.items.shift();
if (!next) break;
await sendAnnounce(next);
}
} catch (err) {
defaultRuntime.error?.(`announce queue drain failed for ${key}: ${String(err)}`);
} finally {
queue.draining = false;
if (queue.items.length === 0 && queue.droppedCount === 0) {
ANNOUNCE_QUEUES.delete(key);
} else {
scheduleAnnounceDrain(key);
}
}
})();
function resolveAnnounceOrigin(
entry?: DeliveryContextSource,
requesterOrigin?: DeliveryContext,
): DeliveryContext | undefined {
return mergeDeliveryContext(deliveryContextFromSession(entry), requesterOrigin);
}
async function sendAnnounce(item: AnnounceQueueItem) {
@@ -343,32 +136,15 @@ function loadRequesterSessionEntry(requesterSessionKey: string) {
const agentId = resolveAgentIdFromSessionKey(canonicalKey);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const legacyKey = canonicalKey.startsWith("agent:")
? canonicalKey.split(":").slice(2).join(":")
: undefined;
const entry =
store[canonicalKey] ?? store[requesterSessionKey] ?? (legacyKey ? store[legacyKey] : undefined);
const entry = store[canonicalKey];
return { cfg, entry, canonicalKey };
}
function resolveAnnounceOrigin(params: {
channel?: string;
to?: string;
accountId?: string;
fallbackAccountId?: string;
}) {
return normalizeDeliveryContext({
channel: params.channel,
to: params.to,
accountId: params.accountId ?? params.fallbackAccountId,
});
}
async function maybeQueueSubagentAnnounce(params: {
requesterSessionKey: string;
triggerMessage: string;
summaryLine?: string;
requesterAccountId?: string;
requesterOrigin?: DeliveryContext;
}): Promise<"steered" | "queued" | "none"> {
const { cfg, entry } = loadRequesterSessionEntry(params.requesterSessionKey);
const canonicalKey = resolveRequesterStoreKey(cfg, params.requesterSessionKey);
@@ -394,24 +170,19 @@ async function maybeQueueSubagentAnnounce(params: {
queueSettings.mode === "steer-backlog" ||
queueSettings.mode === "interrupt";
if (isActive && (shouldFollowup || queueSettings.mode === "steer")) {
const origin = resolveAnnounceOrigin({
channel: entry?.lastChannel,
to: entry?.lastTo,
accountId: entry?.lastAccountId,
fallbackAccountId: params.requesterAccountId,
});
enqueueAnnounce(
canonicalKey,
{
const origin = resolveAnnounceOrigin(entry, params.requesterOrigin);
enqueueAnnounce({
key: canonicalKey,
item: {
prompt: params.triggerMessage,
summaryLine: params.summaryLine,
enqueuedAt: Date.now(),
sessionKey: canonicalKey,
origin,
},
queueSettings,
);
scheduleAnnounceDrain(canonicalKey);
settings: queueSettings,
send: sendAnnounce,
});
return "queued";
}
@@ -472,7 +243,7 @@ async function buildSubagentStatsLine(params: {
export function buildSubagentSystemPrompt(params: {
requesterSessionKey?: string;
requesterChannel?: string;
requesterOrigin?: DeliveryContext;
childSessionKey: string;
label?: string;
task?: string;
@@ -513,7 +284,9 @@ export function buildSubagentSystemPrompt(params: {
"## Session Context",
params.label ? `- Label: ${params.label}` : undefined,
params.requesterSessionKey ? `- Requester session: ${params.requesterSessionKey}.` : undefined,
params.requesterChannel ? `- Requester channel: ${params.requesterChannel}.` : undefined,
params.requesterOrigin?.channel
? `- Requester channel: ${params.requesterOrigin.channel}.`
: undefined,
`- Your session: ${params.childSessionKey}.`,
"",
].filter((line): line is string => line !== undefined);
@@ -529,8 +302,7 @@ export async function runSubagentAnnounceFlow(params: {
childSessionKey: string;
childRunId: string;
requesterSessionKey: string;
requesterChannel?: string;
requesterAccountId?: string;
requesterOrigin?: DeliveryContext;
requesterDisplayKey: string;
task: string;
timeoutMs: number;
@@ -544,6 +316,7 @@ export async function runSubagentAnnounceFlow(params: {
}): Promise<boolean> {
let didAnnounce = false;
try {
const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
let reply = params.roundOneReply;
let outcome: SubagentRunOutcome | undefined = params.outcome;
if (!reply && params.waitForCompletion !== false) {
@@ -626,7 +399,7 @@ export async function runSubagentAnnounceFlow(params: {
requesterSessionKey: params.requesterSessionKey,
triggerMessage,
summaryLine: taskLabel,
requesterAccountId: params.requesterAccountId,
requesterOrigin,
});
if (queued === "steered") {
didAnnounce = true;
@@ -638,10 +411,11 @@ export async function runSubagentAnnounceFlow(params: {
}
// Send to main agent - it will respond in its own voice
const directOrigin = resolveAnnounceOrigin({
channel: params.requesterChannel,
accountId: params.requesterAccountId,
});
let directOrigin = requesterOrigin;
if (!directOrigin) {
const { entry } = loadRequesterSessionEntry(params.requesterSessionKey);
directOrigin = deliveryContextFromSession(entry);
}
await callGateway({
method: "agent",
params: {
@@ -52,7 +52,7 @@ describe("subagent registry persistence", () => {
runId: "run-1",
childSessionKey: "agent:main:subagent:test",
requesterSessionKey: "agent:main:main",
requesterAccountId: "acct-main",
requesterOrigin: { channel: " whatsapp ", accountId: " acct-main " },
requesterDisplayKey: "main",
task: "do the thing",
cleanup: "keep",
@@ -62,8 +62,18 @@ describe("subagent registry persistence", () => {
const raw = await fs.readFile(registryPath, "utf8");
const parsed = JSON.parse(raw) as { runs?: Record<string, unknown> };
expect(parsed.runs && Object.keys(parsed.runs)).toContain("run-1");
const run = parsed.runs?.["run-1"] as { requesterAccountId?: string } | undefined;
expect(run?.requesterAccountId).toBe("acct-main");
const run = parsed.runs?.["run-1"] as
| {
requesterOrigin?: { channel?: string; accountId?: string };
}
| undefined;
expect(run).toBeDefined();
if (run) {
expect("requesterAccountId" in run).toBe(false);
expect("requesterChannel" in run).toBe(false);
}
expect(run?.requesterOrigin?.channel).toBe("whatsapp");
expect(run?.requesterOrigin?.accountId).toBe("acct-main");
// Simulate a process restart: module re-import should load persisted runs
// and trigger the announce flow once the run resolves.
@@ -80,23 +90,24 @@ describe("subagent registry persistence", () => {
childSessionKey: string;
childRunId: string;
requesterSessionKey: string;
requesterAccountId?: string;
requesterOrigin?: { channel?: string; accountId?: string };
task: string;
cleanup: string;
label?: string;
};
const first = announceSpy.mock.calls[0]?.[0] as unknown as AnnounceParams;
expect(first.childSessionKey).toBe("agent:main:subagent:test");
expect(first.requesterAccountId).toBe("acct-main");
expect(first.requesterOrigin?.channel).toBe("whatsapp");
expect(first.requesterOrigin?.accountId).toBe("acct-main");
});
it("skips cleanup when cleanupHandled/announceHandled was persisted", async () => {
it("skips cleanup when cleanupHandled was persisted", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 1,
version: 2,
runs: {
"run-2": {
runId: "run-2",
@@ -130,13 +141,54 @@ describe("subagent registry persistence", () => {
expect(match).toBeFalsy();
});
it("retries cleanup announce after a failed announce", async () => {
it("maps legacy announce fields into cleanup state", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 1,
runs: {
"run-legacy": {
runId: "run-legacy",
childSessionKey: "agent:main:subagent:legacy",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "legacy announce",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
announceCompletedAt: 9,
announceHandled: true,
requesterChannel: "whatsapp",
requesterAccountId: "legacy-account",
},
},
};
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
vi.resetModules();
const { loadSubagentRegistryFromDisk } = await import("./subagent-registry.store.js");
const runs = loadSubagentRegistryFromDisk();
const entry = runs.get("run-legacy");
expect(entry?.cleanupHandled).toBe(true);
expect(entry?.cleanupCompletedAt).toBe(9);
expect(entry?.requesterOrigin?.channel).toBe("whatsapp");
expect(entry?.requesterOrigin?.accountId).toBe("legacy-account");
const after = JSON.parse(await fs.readFile(registryPath, "utf8")) as { version?: number };
expect(after.version).toBe(2);
});
it("retries cleanup announce after a failed announce", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 2,
runs: {
"run-3": {
runId: "run-3",
+55 -21
View File
@@ -2,19 +2,32 @@ import path from "node:path";
import { STATE_DIR_CLAWDBOT } from "../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import type { SubagentRunRecord } from "./subagent-registry.js";
export type PersistedSubagentRegistryVersion = 1;
export type PersistedSubagentRegistryVersion = 1 | 2;
type PersistedSubagentRegistry = {
type PersistedSubagentRegistryV1 = {
version: 1;
runs: Record<string, LegacySubagentRunRecord>;
};
type PersistedSubagentRegistryV2 = {
version: 2;
runs: Record<string, PersistedSubagentRunRecord>;
};
const REGISTRY_VERSION = 1 as const;
type PersistedSubagentRegistry = PersistedSubagentRegistryV1 | PersistedSubagentRegistryV2;
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled"> & {
announceHandled?: boolean;
const REGISTRY_VERSION = 2 as const;
type PersistedSubagentRunRecord = SubagentRunRecord;
type LegacySubagentRunRecord = PersistedSubagentRunRecord & {
announceCompletedAt?: unknown;
announceHandled?: unknown;
requesterChannel?: unknown;
requesterAccountId?: unknown;
};
export function resolveSubagentRegistryPath(): string {
@@ -26,34 +39,56 @@ export function loadSubagentRegistryFromDisk(): Map<string, SubagentRunRecord> {
const raw = loadJsonFile(pathname);
if (!raw || typeof raw !== "object") return new Map();
const record = raw as Partial<PersistedSubagentRegistry>;
if (record.version !== REGISTRY_VERSION) return new Map();
if (record.version !== 1 && record.version !== 2) return new Map();
const runsRaw = record.runs;
if (!runsRaw || typeof runsRaw !== "object") return new Map();
const out = new Map<string, SubagentRunRecord>();
const isLegacy = record.version === 1;
let migrated = false;
for (const [runId, entry] of Object.entries(runsRaw)) {
if (!entry || typeof entry !== "object") continue;
const typed = entry as PersistedSubagentRunRecord;
const typed = entry as LegacySubagentRunRecord;
if (!typed.runId || typeof typed.runId !== "string") continue;
// Back-compat: map legacy announce fields into cleanup fields.
const announceCompletedAt =
typeof typed.announceCompletedAt === "number" ? typed.announceCompletedAt : undefined;
const legacyCompletedAt =
isLegacy && typeof typed.announceCompletedAt === "number"
? typed.announceCompletedAt
: undefined;
const cleanupCompletedAt =
typeof typed.cleanupCompletedAt === "number" ? typed.cleanupCompletedAt : announceCompletedAt;
typeof typed.cleanupCompletedAt === "number" ? typed.cleanupCompletedAt : legacyCompletedAt;
const cleanupHandled =
typeof typed.cleanupHandled === "boolean"
? typed.cleanupHandled
: Boolean(typed.announceHandled ?? announceCompletedAt ?? cleanupCompletedAt);
const announceHandled =
typeof typed.announceHandled === "boolean"
? typed.announceHandled
: Boolean(announceCompletedAt);
: isLegacy
? Boolean(typed.announceHandled ?? cleanupCompletedAt)
: undefined;
const requesterOrigin = normalizeDeliveryContext(
typed.requesterOrigin ?? {
channel: typeof typed.requesterChannel === "string" ? typed.requesterChannel : undefined,
accountId:
typeof typed.requesterAccountId === "string" ? typed.requesterAccountId : undefined,
},
);
const {
announceCompletedAt: _announceCompletedAt,
announceHandled: _announceHandled,
requesterChannel: _channel,
requesterAccountId: _accountId,
...rest
} = typed;
out.set(runId, {
...typed,
announceCompletedAt,
announceHandled,
...rest,
requesterOrigin,
cleanupCompletedAt,
cleanupHandled,
});
if (isLegacy) migrated = true;
}
if (migrated) {
try {
saveSubagentRegistryToDisk(out);
} catch {
// ignore migration write failures
}
}
return out;
}
@@ -62,8 +97,7 @@ export function saveSubagentRegistryToDisk(runs: Map<string, SubagentRunRecord>)
const pathname = resolveSubagentRegistryPath();
const serialized: Record<string, PersistedSubagentRunRecord> = {};
for (const [runId, entry] of runs.entries()) {
const { announceHandled: _ignored, ...persisted } = entry;
serialized[runId] = persisted;
serialized[runId] = entry;
}
const out: PersistedSubagentRegistry = {
version: REGISTRY_VERSION,
+13 -19
View File
@@ -1,6 +1,7 @@
import { loadConfig } from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import { runSubagentAnnounceFlow, type SubagentRunOutcome } from "./subagent-announce.js";
import {
loadSubagentRegistryFromDisk,
@@ -12,8 +13,7 @@ export type SubagentRunRecord = {
runId: string;
childSessionKey: string;
requesterSessionKey: string;
requesterChannel?: string;
requesterAccountId?: string;
requesterOrigin?: DeliveryContext;
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
@@ -23,10 +23,6 @@ export type SubagentRunRecord = {
endedAt?: number;
outcome?: SubagentRunOutcome;
archiveAtMs?: number;
/** @deprecated Use cleanupCompletedAt instead */
announceCompletedAt?: number;
/** @deprecated Use cleanupHandled instead */
announceHandled?: boolean;
cleanupCompletedAt?: number;
cleanupHandled?: boolean;
};
@@ -55,12 +51,12 @@ function resumeSubagentRun(runId: string) {
if (typeof entry.endedAt === "number" && entry.endedAt > 0) {
if (!beginSubagentCleanup(runId)) return;
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterChannel: entry.requesterChannel,
requesterAccountId: entry.requesterAccountId,
requesterOrigin,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: 30_000,
@@ -196,12 +192,12 @@ function ensureListener() {
if (!beginSubagentCleanup(evt.runId)) {
return;
}
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterChannel: entry.requesterChannel,
requesterAccountId: entry.requesterAccountId,
requesterOrigin,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: 30_000,
@@ -238,9 +234,8 @@ function finalizeSubagentCleanup(runId: string, cleanup: "delete" | "keep", didA
function beginSubagentCleanup(runId: string) {
const entry = subagentRuns.get(runId);
if (!entry) return false;
// Support legacy field names for backward compatibility
if (entry.cleanupCompletedAt || entry.announceCompletedAt) return false;
if (entry.cleanupHandled || entry.announceHandled) return false;
if (entry.cleanupCompletedAt) return false;
if (entry.cleanupHandled) return false;
entry.cleanupHandled = true;
persistSubagentRuns();
return true;
@@ -250,8 +245,7 @@ export function registerSubagentRun(params: {
runId: string;
childSessionKey: string;
requesterSessionKey: string;
requesterChannel?: string;
requesterAccountId?: string;
requesterOrigin?: DeliveryContext;
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
@@ -263,12 +257,12 @@ export function registerSubagentRun(params: {
const archiveAfterMs = resolveArchiveAfterMs(cfg);
const archiveAtMs = archiveAfterMs ? now + archiveAfterMs : undefined;
const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, params.runTimeoutSeconds);
const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
subagentRuns.set(params.runId, {
runId: params.runId,
childSessionKey: params.childSessionKey,
requesterSessionKey: params.requesterSessionKey,
requesterChannel: params.requesterChannel,
requesterAccountId: params.requesterAccountId,
requesterOrigin,
requesterDisplayKey: params.requesterDisplayKey,
task: params.task,
cleanup: params.cleanup,
@@ -318,12 +312,12 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
mutated = true;
if (mutated) persistSubagentRuns();
if (!beginSubagentCleanup(runId)) return;
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterChannel: entry.requesterChannel,
requesterAccountId: entry.requesterAccountId,
requesterOrigin,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: 30_000,
+1 -1
View File
@@ -166,7 +166,7 @@ export function buildAgentSystemPrompt(params: {
grep: "Search file contents for patterns",
find: "Find files by glob pattern",
ls: "List directory contents",
exec: "Run shell commands",
exec: "Run shell commands (pty available for TTY-required CLIs)",
process: "Manage background exec sessions",
web_search: "Search the web (Brave API)",
web_fetch: "Fetch and extract readable content from a URL",
+6
View File
@@ -30,6 +30,12 @@
"title": "Exec",
"detailKeys": ["command"]
},
"bash": {
"emoji": "🛠️",
"title": "Exec",
"label": "exec",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
+11 -5
View File
@@ -8,9 +8,15 @@ const mocks = vi.hoisted(() => ({
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
}));
vi.mock("../../infra/outbound/message-action-runner.js", () => ({
runMessageAction: mocks.runMessageAction,
}));
vi.mock("../../infra/outbound/message-action-runner.js", async () => {
const actual = await vi.importActual<
typeof import("../../infra/outbound/message-action-runner.js")
>("../../infra/outbound/message-action-runner.js");
return {
...actual,
runMessageAction: mocks.runMessageAction,
};
});
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
@@ -41,7 +47,7 @@ describe("message tool mirroring", () => {
await tool.execute("1", {
action: "send",
to: "telegram:123",
target: "telegram:123",
message: "",
media: "https://example.com/files/report.pdf?sig=1",
});
@@ -69,7 +75,7 @@ describe("message tool mirroring", () => {
await tool.execute("1", {
action: "send",
to: "telegram:123",
target: "telegram:123",
message: "hi",
});
+163 -84
View File
@@ -14,7 +14,7 @@ import {
resolveMirroredTranscriptText,
} from "../../config/sessions.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
@@ -23,96 +23,174 @@ import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
const MessageToolCommonSchema = {
channel: Type.Optional(Type.String()),
to: Type.Optional(channelTargetSchema()),
targets: Type.Optional(channelTargetsSchema()),
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()),
buttons: Type.Optional(
Type.Array(
function buildRoutingSchema() {
return {
channel: Type.Optional(Type.String()),
target: Type.Optional(channelTargetSchema({ description: "Target channel/user id or name." })),
targets: Type.Optional(channelTargetsSchema()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
};
}
function buildSendSchema(options: { includeButtons: boolean }) {
const props: Record<string, unknown> = {
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional(
Type.Array(
Type.Object({
text: Type.String(),
callback_data: Type.String(),
}),
Type.Array(
Type.Object({
text: Type.String(),
callback_data: Type.String(),
}),
),
{
description: "Telegram inline keyboard buttons (array of button rows)",
},
),
{
description: "Telegram inline keyboard buttons (array of button rows)",
},
),
),
messageId: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
channelId: Type.Optional(channelTargetSchema()),
channelIds: Type.Optional(channelTargetsSchema()),
guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
roleId: Type.Optional(Type.String()),
roleIds: Type.Optional(Type.Array(Type.String())),
emojiName: Type.Optional(Type.String()),
stickerId: Type.Optional(Type.Array(Type.String())),
stickerName: Type.Optional(Type.String()),
stickerDesc: Type.Optional(Type.String()),
stickerTags: Type.Optional(Type.String()),
threadName: Type.Optional(Type.String()),
autoArchiveMin: Type.Optional(Type.Number()),
query: Type.Optional(Type.String()),
eventName: Type.Optional(Type.String()),
eventType: Type.Optional(Type.String()),
startTime: Type.Optional(Type.String()),
endTime: Type.Optional(Type.String()),
desc: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
durationMin: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
deleteDays: Type.Optional(Type.Number()),
includeArchived: Type.Optional(Type.Boolean()),
participant: Type.Optional(Type.String()),
fromMe: Type.Optional(Type.Boolean()),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
name: Type.Optional(Type.String()),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()),
categoryId: Type.Optional(Type.String()),
clearParent: Type.Optional(
Type.Boolean({
description: "Clear the parent/category when supported by the provider.",
}),
),
};
};
if (!options.includeButtons) delete props.buttons;
return props;
}
function buildReactionSchema() {
return {
messageId: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
};
}
function buildFetchSchema() {
return {
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
fromMe: Type.Optional(Type.Boolean()),
includeArchived: Type.Optional(Type.Boolean()),
};
}
function buildPollSchema() {
return {
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
};
}
function buildChannelTargetSchema() {
return {
channelId: Type.Optional(
Type.String({ description: "Channel id filter (search/thread list/event create)." }),
),
channelIds: Type.Optional(
Type.Array(Type.String({ description: "Channel id filter (repeatable)." })),
),
guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
roleId: Type.Optional(Type.String()),
roleIds: Type.Optional(Type.Array(Type.String())),
participant: Type.Optional(Type.String()),
};
}
function buildStickerSchema() {
return {
emojiName: Type.Optional(Type.String()),
stickerId: Type.Optional(Type.Array(Type.String())),
stickerName: Type.Optional(Type.String()),
stickerDesc: Type.Optional(Type.String()),
stickerTags: Type.Optional(Type.String()),
};
}
function buildThreadSchema() {
return {
threadName: Type.Optional(Type.String()),
autoArchiveMin: Type.Optional(Type.Number()),
};
}
function buildEventSchema() {
return {
query: Type.Optional(Type.String()),
eventName: Type.Optional(Type.String()),
eventType: Type.Optional(Type.String()),
startTime: Type.Optional(Type.String()),
endTime: Type.Optional(Type.String()),
desc: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
durationMin: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
};
}
function buildModerationSchema() {
return {
reason: Type.Optional(Type.String()),
deleteDays: Type.Optional(Type.Number()),
};
}
function buildGatewaySchema() {
return {
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
};
}
function buildChannelManagementSchema() {
return {
name: Type.Optional(Type.String()),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()),
categoryId: Type.Optional(Type.String()),
clearParent: Type.Optional(
Type.Boolean({
description: "Clear the parent/category when supported by the provider.",
}),
),
};
}
function buildMessageToolSchemaProps(options: { includeButtons: boolean }) {
return {
...buildRoutingSchema(),
...buildSendSchema(options),
...buildReactionSchema(),
...buildFetchSchema(),
...buildPollSchema(),
...buildChannelTargetSchema(),
...buildStickerSchema(),
...buildThreadSchema(),
...buildEventSchema(),
...buildModerationSchema(),
...buildGatewaySchema(),
...buildChannelManagementSchema(),
};
}
function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: { includeButtons: boolean },
) {
const props: Record<string, unknown> = { ...MessageToolCommonSchema };
if (!options.includeButtons) delete props.buttons;
const props = buildMessageToolSchemaProps(options);
return Type.Object({
action: stringEnum(actions),
...props,
@@ -227,7 +305,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}
}
if ("toolResult" in result && result.toolResult) return result.toolResult;
const toolResult = getToolResult(result);
if (toolResult) return toolResult;
return jsonResult(result.payload);
},
};
+1 -1
View File
@@ -308,7 +308,7 @@ export function createSessionStatusTool(opts?: {
const isGroup =
resolved.entry.chatType === "group" ||
resolved.entry.chatType === "room" ||
resolved.entry.chatType === "channel" ||
resolved.key.startsWith("group:") ||
resolved.key.includes(":group:") ||
resolved.key.includes(":channel:");
@@ -26,9 +26,11 @@ describe("resolveAnnounceTarget", () => {
sessions: [
{
key: "agent:main:whatsapp:group:123@g.us",
lastChannel: "whatsapp",
lastTo: "123@g.us",
lastAccountId: "work",
deliveryContext: {
channel: "whatsapp",
to: "123@g.us",
accountId: "work",
},
},
],
});
+13 -3
View File
@@ -33,9 +33,19 @@ export async function resolveAnnounceTarget(params: {
sessions.find((entry) => entry?.key === params.sessionKey) ??
sessions.find((entry) => entry?.key === params.displayKey);
const channel = typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
const to = typeof match?.lastTo === "string" ? match.lastTo : undefined;
const accountId = typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined;
const deliveryContext =
match?.deliveryContext && typeof match.deliveryContext === "object"
? (match.deliveryContext as Record<string, unknown>)
: undefined;
const channel =
(typeof deliveryContext?.channel === "string" ? deliveryContext.channel : undefined) ??
(typeof match?.lastChannel === "string" ? match.lastChannel : undefined);
const to =
(typeof deliveryContext?.to === "string" ? deliveryContext.to : undefined) ??
(typeof match?.lastTo === "string" ? match.lastTo : undefined);
const accountId =
(typeof deliveryContext?.accountId === "string" ? deliveryContext.accountId : undefined) ??
(typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined);
if (channel && to) return { channel, to, accountId };
} catch {
// ignore
+30
View File
@@ -3,6 +3,36 @@ import { normalizeMainKey } from "../../routing/session-key.js";
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
export type SessionListDeliveryContext = {
channel?: string;
to?: string;
accountId?: string;
};
export type SessionListRow = {
key: string;
kind: SessionKind;
channel: string;
label?: string;
displayName?: string;
deliveryContext?: SessionListDeliveryContext;
updatedAt?: number | null;
sessionId?: string;
model?: string;
contextTokens?: number | null;
totalTokens?: number | null;
thinkingLevel?: string;
verboseLevel?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
sendPolicy?: string;
lastChannel?: string;
lastTo?: string;
lastAccountId?: string;
transcriptPath?: string;
messages?: unknown[];
};
function normalizeKey(value?: string) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
+24 -27
View File
@@ -17,33 +17,10 @@ import {
resolveDisplaySessionKey,
resolveInternalSessionKey,
resolveMainSessionAlias,
type SessionKind,
type SessionListRow,
stripToolMessages,
} from "./sessions-helpers.js";
type SessionListRow = {
key: string;
kind: SessionKind;
channel: string;
label?: string;
displayName?: string;
updatedAt?: number | null;
sessionId?: string;
model?: string;
contextTokens?: number | null;
totalTokens?: number | null;
thinkingLevel?: string;
verboseLevel?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
sendPolicy?: string;
lastChannel?: string;
lastTo?: string;
lastAccountId?: string;
transcriptPath?: string;
messages?: unknown[];
};
const SessionsListToolSchema = Type.Object({
kinds: Type.Optional(Type.Array(Type.String())),
limit: Type.Optional(Type.Number({ minimum: 1 })),
@@ -167,9 +144,21 @@ export function createSessionsListTool(opts?: {
});
const entryChannel = typeof entry.channel === "string" ? entry.channel : undefined;
const lastChannel = typeof entry.lastChannel === "string" ? entry.lastChannel : undefined;
const deliveryContext =
entry.deliveryContext && typeof entry.deliveryContext === "object"
? (entry.deliveryContext as Record<string, unknown>)
: undefined;
const deliveryChannel =
typeof deliveryContext?.channel === "string" ? deliveryContext.channel : undefined;
const deliveryTo =
typeof deliveryContext?.to === "string" ? deliveryContext.to : undefined;
const deliveryAccountId =
typeof deliveryContext?.accountId === "string" ? deliveryContext.accountId : undefined;
const lastChannel =
deliveryChannel ?? (typeof entry.lastChannel === "string" ? entry.lastChannel : undefined);
const lastAccountId =
typeof entry.lastAccountId === "string" ? entry.lastAccountId : undefined;
deliveryAccountId ??
(typeof entry.lastAccountId === "string" ? entry.lastAccountId : undefined);
const derivedChannel = deriveChannel({
key,
kind,
@@ -189,6 +178,14 @@ export function createSessionsListTool(opts?: {
channel: derivedChannel,
label: typeof entry.label === "string" ? entry.label : undefined,
displayName: typeof entry.displayName === "string" ? entry.displayName : undefined,
deliveryContext:
deliveryChannel || deliveryTo || deliveryAccountId
? {
channel: deliveryChannel,
to: deliveryTo,
accountId: deliveryAccountId,
}
: undefined,
updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : undefined,
sessionId,
model: typeof entry.model === "string" ? entry.model : undefined,
@@ -201,7 +198,7 @@ export function createSessionsListTool(opts?: {
typeof entry.abortedLastRun === "boolean" ? entry.abortedLastRun : undefined,
sendPolicy: typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
lastChannel,
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
lastTo: deliveryTo ?? (typeof entry.lastTo === "string" ? entry.lastTo : undefined),
lastAccountId,
transcriptPath,
};
+8 -4
View File
@@ -9,6 +9,7 @@ import {
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { normalizeDeliveryContext } from "../../utils/delivery-context.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { resolveAgentConfig } from "../agent-scope.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
@@ -67,6 +68,10 @@ export function createSessionsSpawnTool(opts?: {
params.cleanup === "keep" || params.cleanup === "delete"
? (params.cleanup as "keep" | "delete")
: "keep";
const requesterOrigin = normalizeDeliveryContext({
channel: opts?.agentChannel,
accountId: opts?.agentAccountId,
});
const runTimeoutSeconds = (() => {
const explicit =
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
@@ -163,7 +168,7 @@ export function createSessionsSpawnTool(opts?: {
}
const childSystemPrompt = buildSubagentSystemPrompt({
requesterSessionKey,
requesterChannel: opts?.agentChannel,
requesterOrigin,
childSessionKey,
label: label || undefined,
task,
@@ -177,7 +182,7 @@ export function createSessionsSpawnTool(opts?: {
params: {
message: task,
sessionKey: childSessionKey,
channel: opts?.agentChannel,
channel: requesterOrigin?.channel,
idempotencyKey: childIdem,
deliver: false,
lane: AGENT_LANE_SUBAGENT,
@@ -206,8 +211,7 @@ export function createSessionsSpawnTool(opts?: {
runId: childRunId,
childSessionKey,
requesterSessionKey: requesterInternalKey,
requesterChannel: opts?.agentChannel,
requesterAccountId: opts?.agentAccountId,
requesterOrigin,
requesterDisplayKey,
task,
cleanup,
+5 -5
View File
@@ -1,7 +1,7 @@
import type { ChannelDock } from "../channels/dock.js";
import { getChannelDock, listChannelDocks } from "../channels/dock.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
import { normalizeAnyChannelId } from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { MsgContext } from "./templating.js";
@@ -16,15 +16,15 @@ export type CommandAuthorization = {
function resolveProviderFromContext(ctx: MsgContext, cfg: ClawdbotConfig): ChannelId | undefined {
const direct =
normalizeChannelId(ctx.Provider) ??
normalizeChannelId(ctx.Surface) ??
normalizeChannelId(ctx.OriginatingChannel);
normalizeAnyChannelId(ctx.Provider) ??
normalizeAnyChannelId(ctx.Surface) ??
normalizeAnyChannelId(ctx.OriginatingChannel);
if (direct) return direct;
const candidates = [ctx.From, ctx.To]
.filter((value): value is string => Boolean(value?.trim()))
.flatMap((value) => value.split(":").map((part) => part.trim()));
for (const candidate of candidates) {
const normalized = normalizeChannelId(candidate);
const normalized = normalizeAnyChannelId(candidate);
if (normalized) return normalized;
}
const configured = listChannelDocks()
Loaded 100 of 328 files, more files were not shown because too many files have changed in this diff. Show more