Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ab4c3a3c4 | ||
|
|
be6536a635 | ||
|
|
c2e10710f4 | ||
|
|
86a46874da | ||
|
|
3a6ee5ee00 | ||
|
|
5dc87a2ed4 | ||
|
|
a85ddf258c | ||
|
|
1f3a09b43b | ||
|
|
7b31b280f8 | ||
|
|
6a3ed5c850 | ||
|
|
7ed55682b7 | ||
|
|
37a2eee837 | ||
|
|
353d778988 | ||
|
|
5a1ff5b9e7 | ||
|
|
3dc4a96330 | ||
|
|
65a8a93854 | ||
|
|
eb8a0510e0 | ||
|
|
a4178e4062 | ||
|
|
5ebfc0738f | ||
|
|
bd32cc40e6 | ||
|
|
b31d8d3b10 | ||
|
|
331141ad77 | ||
|
|
c7ae5100fa | ||
|
|
285ed8bac3 | ||
|
|
e59d8c5436 | ||
|
|
8b42902cee | ||
|
|
07a3db153d | ||
|
|
68d35be383 | ||
|
|
99dd428862 | ||
|
|
4d314db750 | ||
|
|
ccea3a0615 | ||
|
|
f4f20c6762 | ||
|
|
a624878973 | ||
|
|
c8b826ea8c | ||
|
|
f7089cde54 | ||
|
|
572e04d5fb | ||
|
|
bc49c20434 | ||
|
|
4b085f23e0 | ||
|
|
c4ea25a509 | ||
|
|
312cb75c50 | ||
|
|
ee738e6578 | ||
|
|
fcb7c9ff65 | ||
|
|
49ecbd8fea | ||
|
|
19ee6699d2 | ||
|
|
5fcc9b3244 | ||
|
|
3efc5e54fa | ||
|
|
780c811146 | ||
|
|
4f37f66264 | ||
|
|
8ebfa2950d | ||
|
|
9a60d431c5 | ||
|
|
6e4d86f426 | ||
|
|
87cecd0268 | ||
|
|
388b2bce01 | ||
|
|
a2b5b1f0cb | ||
|
|
9f4b7a1683 | ||
|
|
dd68faef23 | ||
|
|
97cfa0846c | ||
|
|
1b973f7506 | ||
|
|
4b749f1b8f | ||
|
|
7f1f9473a0 | ||
|
|
a32e5d040c | ||
|
|
a82217a5f3 | ||
|
|
02184dd055 |
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+66
-3
@@ -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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ packages:
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@whiskeysockets/baileys'
|
||||
- '@lydell/node-pty'
|
||||
- authenticate-pam
|
||||
- esbuild
|
||||
- protobufjs
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
+62
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -181,6 +181,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
cwd: options?.workspaceDir,
|
||||
allowBackground,
|
||||
scopeKey,
|
||||
sessionKey: options?.sessionKey,
|
||||
sandbox: sandbox
|
||||
? {
|
||||
containerName: sandbox.containerName,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
"title": "Exec",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"bash": {
|
||||
"emoji": "🛠️",
|
||||
"title": "Exec",
|
||||
"label": "exec",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"process": {
|
||||
"emoji": "🧰",
|
||||
"title": "Process",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in new issue
Block a user