Compare commits
91 Commits
fix/telegr
...
fix/tui-to
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504f2b0606 | ||
|
|
e10e81f579 | ||
|
|
c5239f6a8e | ||
|
|
cccd7c7b8e | ||
|
|
8f1132e8ec | ||
|
|
e6477363e9 | ||
|
|
1a4fc8dea6 | ||
|
|
a3daf3d115 | ||
|
|
f3f80509e3 | ||
|
|
7cebe7a506 | ||
|
|
5986175268 | ||
|
|
7630c6dccb | ||
|
|
d63cc1e8a7 | ||
|
|
80bb6b712c | ||
|
|
410b8f223e | ||
|
|
693f152895 | ||
|
|
78a4441ac2 | ||
|
|
c92265a51b | ||
|
|
d5fdda8e28 | ||
|
|
6d969fe58e | ||
|
|
68c7d577a4 | ||
|
|
1ea8917e2b | ||
|
|
07c93dfd30 | ||
|
|
cf0ea6c756 | ||
|
|
8c9e32c4a3 | ||
|
|
34d59d7913 | ||
|
|
0c0d9e1d22 | ||
|
|
2ee45d50a4 | ||
|
|
0c0e1e4226 | ||
|
|
86a46874da | ||
|
|
3a6ee5ee00 | ||
|
|
5dc87a2ed4 | ||
|
|
a85ddf258c | ||
|
|
1f3a09b43b | ||
|
|
7b31b280f8 | ||
|
|
6a3ed5c850 | ||
|
|
7ed55682b7 | ||
|
|
37a2eee837 | ||
|
|
353d778988 | ||
|
|
5a1ff5b9e7 | ||
|
|
3dc4a96330 | ||
|
|
65a8a93854 | ||
|
|
eb8a0510e0 | ||
|
|
a4178e4062 | ||
|
|
e7953d8164 | ||
|
|
5ebfc0738f | ||
|
|
bd32cc40e6 | ||
|
|
b31d8d3b10 | ||
|
|
331141ad77 | ||
|
|
c7ae5100fa | ||
|
|
285ed8bac3 | ||
|
|
e59d8c5436 | ||
|
|
8b42902cee | ||
|
|
07a3db153d | ||
|
|
68d35be383 | ||
|
|
99dd428862 | ||
|
|
4d314db750 | ||
|
|
ccea3a0615 | ||
|
|
f4f20c6762 | ||
|
|
a624878973 | ||
|
|
c8b826ea8c | ||
|
|
f7089cde54 | ||
|
|
f42b12646d | ||
|
|
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 |
2
.npmrc
2
.npmrc
@@ -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
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -7,36 +7,61 @@
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
|
||||
- Hooks: add internal hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
|
||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
|
||||
|
||||
### 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`; 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.
|
||||
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
|
||||
- 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: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
|
||||
- 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)
|
||||
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
|
||||
- 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.
|
||||
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
|
||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
||||
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
|
||||
@@ -49,6 +74,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
|
||||
|
||||
@@ -89,6 +116,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)
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot hooks` (internal hooks + Gmail Pub/Sub + webhook helpers)"
|
||||
summary: "CLI reference for `clawdbot hooks` (agent 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 manage agent hooks
|
||||
- You want to install or update hooks
|
||||
---
|
||||
|
||||
# `clawdbot hooks`
|
||||
|
||||
Webhook helpers and hook-based integrations.
|
||||
Manage 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)
|
||||
- Hooks: [Hooks](/hooks)
|
||||
|
||||
## 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.
|
||||
List all discovered hooks from workspace, managed, and bundled directories.
|
||||
|
||||
**Options:**
|
||||
- `--eligible`: Show only eligible hooks (requirements met)
|
||||
@@ -35,7 +28,7 @@ List all discovered internal hooks from workspace, managed, and bundled director
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Internal Hooks (2/2 ready)
|
||||
Hooks (2/2 ready)
|
||||
|
||||
Ready:
|
||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||
@@ -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:**
|
||||
@@ -89,17 +82,17 @@ Details:
|
||||
Source: clawdbot-bundled
|
||||
Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md
|
||||
Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts
|
||||
Homepage: https://docs.clawd.bot/internal-hooks#session-memory
|
||||
Homepage: https://docs.clawd.bot/hooks#session-memory
|
||||
Events: command:new
|
||||
|
||||
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).
|
||||
@@ -110,17 +103,17 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Internal Hooks Status
|
||||
Hooks Status
|
||||
|
||||
Total hooks: 2
|
||||
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,12 +223,12 @@ 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`
|
||||
|
||||
**See:** [session-memory documentation](/internal-hooks#session-memory)
|
||||
**See:** [session-memory documentation](/hooks#session-memory)
|
||||
|
||||
### command-logger
|
||||
|
||||
@@ -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`
|
||||
@@ -215,13 +255,4 @@ cat ~/.clawdbot/logs/commands.log | jq .
|
||||
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.
|
||||
**See:** [command-logger documentation](/hooks#command-logger)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"}] ]'
|
||||
```
|
||||
|
||||
@@ -28,11 +28,19 @@ clawdbot plugins update --all
|
||||
### Install
|
||||
|
||||
```bash
|
||||
clawdbot plugins install <npm-spec>
|
||||
clawdbot plugins install <path-or-spec>
|
||||
```
|
||||
|
||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
```bash
|
||||
clawdbot plugins install -l ./my-plugin
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
|
||||
23
docs/cli/webhooks.md
Normal file
23
docs/cli/webhooks.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot webhooks` (webhook helpers + Gmail Pub/Sub)"
|
||||
read_when:
|
||||
- You want to wire Gmail Pub/Sub events into Clawdbot
|
||||
- You want webhook helper commands
|
||||
---
|
||||
|
||||
# `clawdbot webhooks`
|
||||
|
||||
Webhook helpers and integrations (Gmail Pub/Sub, webhook helpers).
|
||||
|
||||
Related:
|
||||
- Webhooks: [Webhook](/automation/webhook)
|
||||
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
|
||||
|
||||
## Gmail
|
||||
|
||||
```bash
|
||||
clawdbot webhooks gmail setup --account you@example.com
|
||||
clawdbot webhooks gmail run
|
||||
```
|
||||
|
||||
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
|
||||
@@ -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,19 +1,19 @@
|
||||
---
|
||||
summary: "Internal agent hooks: event-driven automation for commands and lifecycle events"
|
||||
summary: "Hooks: event-driven automation for commands and lifecycle events"
|
||||
read_when:
|
||||
- You want event-driven automation for /new, /reset, /stop, and agent lifecycle events
|
||||
- You want to build, install, or debug internal hooks
|
||||
- You want to build, install, or debug hooks
|
||||
---
|
||||
# Internal Agent Hooks
|
||||
# Hooks
|
||||
|
||||
Internal hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
|
||||
Hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
|
||||
|
||||
## Getting Oriented
|
||||
|
||||
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).
|
||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Webhooks**: 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
|
||||
@@ -21,11 +21,11 @@ Common uses:
|
||||
- Trigger follow-up automation when a session starts or ends
|
||||
- Write files into the agent workspace or call external APIs when events fire
|
||||
|
||||
If you can write a small TypeScript function, you can write an internal hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
|
||||
If you can write a small TypeScript function, you can write a hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
The internal hooks system allows you to:
|
||||
The hooks system allows you to:
|
||||
- Save session context to memory when `/new` is issued
|
||||
- Log all commands for auditing
|
||||
- Trigger custom automations on agent lifecycle events
|
||||
@@ -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
|
||||
@@ -94,7 +120,7 @@ The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documenta
|
||||
---
|
||||
name: my-hook
|
||||
description: "Short description of what this hook does"
|
||||
homepage: https://docs.clawd.bot/internal-hooks#my-hook
|
||||
homepage: https://docs.clawd.bot/hooks#my-hook
|
||||
metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}}
|
||||
---
|
||||
|
||||
@@ -136,12 +162,12 @@ The `metadata.clawdbot` object supports:
|
||||
|
||||
### Handler Implementation
|
||||
|
||||
The `handler.ts` file exports an `InternalHookHandler` function:
|
||||
The `handler.ts` file exports a `HookHandler` function:
|
||||
|
||||
```typescript
|
||||
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
|
||||
import type { HookHandler } from '../../src/hooks/hooks.js';
|
||||
|
||||
const myHandler: InternalHookHandler = async (event) => {
|
||||
const myHandler: HookHandler = async (event) => {
|
||||
// Only trigger on 'new' command
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
@@ -234,9 +260,9 @@ This hook does something useful when you issue `/new`.
|
||||
### 4. Create handler.ts
|
||||
|
||||
```typescript
|
||||
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
|
||||
import type { HookHandler } from '../../src/hooks/hooks.js';
|
||||
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
}
|
||||
@@ -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
|
||||
@@ -479,12 +505,12 @@ Hooks run during command processing. Keep them lightweight:
|
||||
|
||||
```typescript
|
||||
// ✓ Good - async work, returns immediately
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
void processInBackground(event); // Fire and forget
|
||||
};
|
||||
|
||||
// ✗ Bad - blocks command processing
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
await slowDatabaseQuery(event);
|
||||
await evenSlowerAPICall(event);
|
||||
};
|
||||
@@ -495,7 +521,7 @@ const handler: InternalHookHandler = async (event) => {
|
||||
Always wrap risky operations:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
try {
|
||||
await riskyOperation(event);
|
||||
} catch (err) {
|
||||
@@ -510,7 +536,7 @@ const handler: InternalHookHandler = async (event) => {
|
||||
Return early if the event isn't relevant:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
// Only handle 'new' commands
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
@@ -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
|
||||
@@ -558,7 +584,7 @@ clawdbot hooks internal list --verbose
|
||||
In your handler, log when it's called:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
console.log('[my-handler] Triggered:', event.type, event.action);
|
||||
// Your logic
|
||||
};
|
||||
@@ -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.
|
||||
@@ -594,11 +620,11 @@ Test your handlers in isolation:
|
||||
|
||||
```typescript
|
||||
import { test } from 'vitest';
|
||||
import { createInternalHookEvent } from './src/hooks/internal-hooks.js';
|
||||
import { createHookEvent } from './src/hooks/hooks.js';
|
||||
import myHandler from './hooks/my-hook/handler.js';
|
||||
|
||||
test('my handler works', async () => {
|
||||
const event = createInternalHookEvent('command', 'new', 'test-session', {
|
||||
const event = createHookEvent('command', 'new', 'test-session', {
|
||||
foo: 'bar'
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
275
docs/nodes/media-understanding.md
Normal file
275
docs/nodes/media-understanding.md
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
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"] }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Status output
|
||||
When media understanding runs, `/status` includes a short summary line:
|
||||
|
||||
```
|
||||
📎 Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)
|
||||
```
|
||||
|
||||
This shows per‑capability outcomes and the chosen provider/model when applicable.
|
||||
|
||||
## 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)
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,15 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
|
||||
return trimmed.includes(":");
|
||||
},
|
||||
hint: "<room|alias|user>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
|
||||
@@ -16,14 +16,14 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupRoom = params.groupRoom?.trim() ?? "";
|
||||
const aliases = groupRoom ? [groupRoom] : [];
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupRoom || undefined,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
if (resolved) {
|
||||
if (resolved.autoReply === true) return false;
|
||||
|
||||
@@ -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,18 +365,21 @@ 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(/^@/, ""),
|
||||
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
||||
GroupRoom: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
|
||||
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
|
||||
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
||||
Provider: "matrix" as const,
|
||||
Surface: "matrix" as const,
|
||||
@@ -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,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMSTeamsMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(conversation:|user:)/i.test(trimmed)) return true;
|
||||
return trimmed.includes("@thread");
|
||||
},
|
||||
hint: "<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,14 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
actions: zaloMessageActions,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeZaloMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<chatId>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
@@ -185,7 +193,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 +287,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,14 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/^(zalouser|zlu):/i, "");
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<threadId>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async ({ cfg, accountId, runtime }) => {
|
||||
@@ -373,16 +381,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
@@ -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();
|
||||
|
||||
20
src/agents/bash-tools.exec.pty.test.ts
Normal file
20
src/agents/bash-tools.exec.pty.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { createExecTool } from "./bash-tools.exec";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
});
|
||||
|
||||
test("exec supports pty output", async () => {
|
||||
const tool = createExecTool({ allowBackground: false });
|
||||
const result = await tool.execute("toolcall", {
|
||||
command: 'node -e "process.stdout.write(String.fromCharCode(111,107))"',
|
||||
pty: true,
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
const text = result.content?.[0]?.text ?? "";
|
||||
expect(text).toContain("ok");
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
80
src/agents/bash-tools.process.send-keys.test.ts
Normal file
80
src/agents/bash-tools.process.send-keys.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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 "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,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 Enter for pty sessions", async () => {
|
||||
const execTool = createExecTool();
|
||||
const processTool = createProcessTool();
|
||||
const result = await execTool.execute("toolcall", {
|
||||
command:
|
||||
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||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,
|
||||
});
|
||||
|
||||
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("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,13 @@ 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 +56,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,10 +119,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(
|
||||
s.status,
|
||||
9,
|
||||
)} ${formatDuration(s.runtimeMs)} :: ${label}`;
|
||||
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
@@ -302,7 +323,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 +336,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 +361,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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
src/agents/pty-dsr.test.ts
Normal file
15
src/agents/pty-dsr.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
|
||||
test("stripDsrRequests removes cursor queries and counts them", () => {
|
||||
const input = "hi\x1b[6nthere\x1b[?6n";
|
||||
const { cleaned, requests } = stripDsrRequests(input);
|
||||
expect(cleaned).toBe("hithere");
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
test("buildCursorPositionResponse returns CPR sequence", () => {
|
||||
expect(buildCursorPositionResponse()).toBe("\x1b[1;1R");
|
||||
expect(buildCursorPositionResponse(12, 34)).toBe("\x1b[12;34R");
|
||||
});
|
||||
15
src/agents/pty-dsr.ts
Normal file
15
src/agents/pty-dsr.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const ESC = String.fromCharCode(0x1b);
|
||||
const DSR_PATTERN = new RegExp(`${ESC}\\[\\??6n`, "g");
|
||||
|
||||
export function stripDsrRequests(input: string): { cleaned: string; requests: number } {
|
||||
let requests = 0;
|
||||
const cleaned = input.replace(DSR_PATTERN, () => {
|
||||
requests += 1;
|
||||
return "";
|
||||
});
|
||||
return { cleaned, requests };
|
||||
}
|
||||
|
||||
export function buildCursorPositionResponse(row = 1, col = 1): string {
|
||||
return `\x1b[${row};${col}R`;
|
||||
}
|
||||
41
src/agents/pty-keys.test.ts
Normal file
41
src/agents/pty-keys.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
BRACKETED_PASTE_END,
|
||||
BRACKETED_PASTE_START,
|
||||
encodeKeySequence,
|
||||
encodePaste,
|
||||
} from "./pty-keys.js";
|
||||
|
||||
test("encodeKeySequence maps common keys and modifiers", () => {
|
||||
const enter = encodeKeySequence({ keys: ["Enter"] });
|
||||
expect(enter.data).toBe("\r");
|
||||
|
||||
const ctrlC = encodeKeySequence({ keys: ["C-c"] });
|
||||
expect(ctrlC.data).toBe("\x03");
|
||||
|
||||
const altX = encodeKeySequence({ keys: ["M-x"] });
|
||||
expect(altX.data).toBe("\x1bx");
|
||||
|
||||
const shiftTab = encodeKeySequence({ keys: ["S-Tab"] });
|
||||
expect(shiftTab.data).toBe("\x1b[Z");
|
||||
|
||||
const kpEnter = encodeKeySequence({ keys: ["KPEnter"] });
|
||||
expect(kpEnter.data).toBe("\x1bOM");
|
||||
});
|
||||
|
||||
test("encodeKeySequence supports hex + literal with warnings", () => {
|
||||
const result = encodeKeySequence({
|
||||
literal: "hi",
|
||||
hex: ["0d", "0x0a", "zz"],
|
||||
keys: ["Enter"],
|
||||
});
|
||||
expect(result.data).toBe("hi\r\n\r");
|
||||
expect(result.warnings.length).toBe(1);
|
||||
});
|
||||
|
||||
test("encodePaste wraps bracketed sequences by default", () => {
|
||||
const payload = encodePaste("line1\nline2\n");
|
||||
expect(payload.startsWith(BRACKETED_PASTE_START)).toBe(true);
|
||||
expect(payload.endsWith(BRACKETED_PASTE_END)).toBe(true);
|
||||
});
|
||||
266
src/agents/pty-keys.ts
Normal file
266
src/agents/pty-keys.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
const ESC = "\x1b";
|
||||
const CR = "\r";
|
||||
const TAB = "\t";
|
||||
const BACKSPACE = "\x7f";
|
||||
|
||||
export const BRACKETED_PASTE_START = `${ESC}[200~`;
|
||||
export const BRACKETED_PASTE_END = `${ESC}[201~`;
|
||||
|
||||
type Modifiers = {
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
};
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const namedKeyMap = new Map<string, string>([
|
||||
["enter", CR],
|
||||
["return", CR],
|
||||
["tab", TAB],
|
||||
["escape", ESC],
|
||||
["esc", ESC],
|
||||
["space", " "],
|
||||
["bspace", BACKSPACE],
|
||||
["backspace", BACKSPACE],
|
||||
["up", `${ESC}[A`],
|
||||
["down", `${ESC}[B`],
|
||||
["right", `${ESC}[C`],
|
||||
["left", `${ESC}[D`],
|
||||
["home", `${ESC}[1~`],
|
||||
["end", `${ESC}[4~`],
|
||||
["pageup", `${ESC}[5~`],
|
||||
["pgup", `${ESC}[5~`],
|
||||
["ppage", `${ESC}[5~`],
|
||||
["pagedown", `${ESC}[6~`],
|
||||
["pgdn", `${ESC}[6~`],
|
||||
["npage", `${ESC}[6~`],
|
||||
["insert", `${ESC}[2~`],
|
||||
["ic", `${ESC}[2~`],
|
||||
["delete", `${ESC}[3~`],
|
||||
["del", `${ESC}[3~`],
|
||||
["dc", `${ESC}[3~`],
|
||||
["btab", `${ESC}[Z`],
|
||||
["f1", `${ESC}OP`],
|
||||
["f2", `${ESC}OQ`],
|
||||
["f3", `${ESC}OR`],
|
||||
["f4", `${ESC}OS`],
|
||||
["f5", `${ESC}[15~`],
|
||||
["f6", `${ESC}[17~`],
|
||||
["f7", `${ESC}[18~`],
|
||||
["f8", `${ESC}[19~`],
|
||||
["f9", `${ESC}[20~`],
|
||||
["f10", `${ESC}[21~`],
|
||||
["f11", `${ESC}[23~`],
|
||||
["f12", `${ESC}[24~`],
|
||||
["kp/", `${ESC}Oo`],
|
||||
["kp*", `${ESC}Oj`],
|
||||
["kp-", `${ESC}Om`],
|
||||
["kp+", `${ESC}Ok`],
|
||||
["kp7", `${ESC}Ow`],
|
||||
["kp8", `${ESC}Ox`],
|
||||
["kp9", `${ESC}Oy`],
|
||||
["kp4", `${ESC}Ot`],
|
||||
["kp5", `${ESC}Ou`],
|
||||
["kp6", `${ESC}Ov`],
|
||||
["kp1", `${ESC}Oq`],
|
||||
["kp2", `${ESC}Or`],
|
||||
["kp3", `${ESC}Os`],
|
||||
["kp0", `${ESC}Op`],
|
||||
["kp.", `${ESC}On`],
|
||||
["kpenter", `${ESC}OM`],
|
||||
]);
|
||||
|
||||
const modifiableNamedKeys = new Set([
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
"home",
|
||||
"end",
|
||||
"pageup",
|
||||
"pgup",
|
||||
"ppage",
|
||||
"pagedown",
|
||||
"pgdn",
|
||||
"npage",
|
||||
"insert",
|
||||
"ic",
|
||||
"delete",
|
||||
"del",
|
||||
"dc",
|
||||
]);
|
||||
|
||||
export type KeyEncodingRequest = {
|
||||
keys?: string[];
|
||||
hex?: string[];
|
||||
literal?: string;
|
||||
};
|
||||
|
||||
export type KeyEncodingResult = {
|
||||
data: string;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export function encodeKeySequence(request: KeyEncodingRequest): KeyEncodingResult {
|
||||
const warnings: string[] = [];
|
||||
let data = "";
|
||||
|
||||
if (request.literal) {
|
||||
data += request.literal;
|
||||
}
|
||||
|
||||
if (request.hex?.length) {
|
||||
for (const raw of request.hex) {
|
||||
const byte = parseHexByte(raw);
|
||||
if (byte === null) {
|
||||
warnings.push(`Invalid hex byte: ${raw}`);
|
||||
continue;
|
||||
}
|
||||
data += String.fromCharCode(byte);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.keys?.length) {
|
||||
for (const token of request.keys) {
|
||||
data += encodeKeyToken(token, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
return { data, warnings };
|
||||
}
|
||||
|
||||
export function encodePaste(text: string, bracketed = true): string {
|
||||
if (!bracketed) return text;
|
||||
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
|
||||
}
|
||||
|
||||
function encodeKeyToken(raw: string, warnings: string[]): string {
|
||||
const token = raw.trim();
|
||||
if (!token) return "";
|
||||
|
||||
if (token.length === 2 && token.startsWith("^")) {
|
||||
const ctrl = toCtrlChar(token[1]);
|
||||
if (ctrl) return ctrl;
|
||||
}
|
||||
|
||||
const parsed = parseModifiers(token);
|
||||
const base = parsed.base;
|
||||
const baseLower = base.toLowerCase();
|
||||
|
||||
if (baseLower === "tab" && parsed.mods.shift) {
|
||||
return `${ESC}[Z`;
|
||||
}
|
||||
|
||||
const baseSeq = namedKeyMap.get(baseLower);
|
||||
if (baseSeq) {
|
||||
let seq = baseSeq;
|
||||
if (modifiableNamedKeys.has(baseLower) && hasAnyModifier(parsed.mods)) {
|
||||
const mod = xtermModifier(parsed.mods);
|
||||
if (mod > 1) {
|
||||
const modified = applyXtermModifier(seq, mod);
|
||||
if (modified) {
|
||||
seq = modified;
|
||||
return seq;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parsed.mods.alt) {
|
||||
return `${ESC}${seq}`;
|
||||
}
|
||||
return seq;
|
||||
}
|
||||
|
||||
if (base.length === 1) {
|
||||
return applyCharModifiers(base, parsed.mods);
|
||||
}
|
||||
|
||||
if (parsed.hasModifiers) {
|
||||
warnings.push(`Unknown key "${base}" for modifiers; sending literal.`);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function parseModifiers(token: string) {
|
||||
const mods: Modifiers = { ctrl: false, alt: false, shift: false };
|
||||
let rest = token;
|
||||
let sawModifiers = false;
|
||||
|
||||
while (rest.length > 2 && rest[1] === "-") {
|
||||
const mod = rest[0].toLowerCase();
|
||||
if (mod === "c") mods.ctrl = true;
|
||||
else if (mod === "m") mods.alt = true;
|
||||
else if (mod === "s") mods.shift = true;
|
||||
else break;
|
||||
sawModifiers = true;
|
||||
rest = rest.slice(2);
|
||||
}
|
||||
|
||||
return { mods, base: rest, hasModifiers: sawModifiers };
|
||||
}
|
||||
|
||||
function applyCharModifiers(char: string, mods: Modifiers): string {
|
||||
let value = char;
|
||||
if (mods.shift && value.length === 1 && /[a-z]/.test(value)) {
|
||||
value = value.toUpperCase();
|
||||
}
|
||||
if (mods.ctrl) {
|
||||
const ctrl = toCtrlChar(value);
|
||||
if (ctrl) value = ctrl;
|
||||
}
|
||||
if (mods.alt) {
|
||||
value = `${ESC}${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function toCtrlChar(char: string): string | null {
|
||||
if (char.length !== 1) return null;
|
||||
if (char === "?") return "\x7f";
|
||||
const code = char.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) {
|
||||
return String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function xtermModifier(mods: Modifiers): number {
|
||||
let mod = 1;
|
||||
if (mods.shift) mod += 1;
|
||||
if (mods.alt) mod += 2;
|
||||
if (mods.ctrl) mod += 4;
|
||||
return mod;
|
||||
}
|
||||
|
||||
function applyXtermModifier(sequence: string, modifier: number): string | null {
|
||||
const escPattern = escapeRegExp(ESC);
|
||||
const csiNumber = new RegExp(`^${escPattern}\\[(\\d+)([~A-Z])$`);
|
||||
const csiArrow = new RegExp(`^${escPattern}\\[(A|B|C|D|H|F)$`);
|
||||
|
||||
const numberMatch = sequence.match(csiNumber);
|
||||
if (numberMatch) {
|
||||
return `${ESC}[${numberMatch[1]};${modifier}${numberMatch[2]}`;
|
||||
}
|
||||
|
||||
const arrowMatch = sequence.match(csiArrow);
|
||||
if (arrowMatch) {
|
||||
return `${ESC}[1;${modifier}${arrowMatch[1]}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAnyModifier(mods: Modifiers): boolean {
|
||||
return mods.ctrl || mods.alt || mods.shift;
|
||||
}
|
||||
|
||||
function parseHexByte(raw: string): number | null {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
|
||||
if (!/^[0-9a-f]{1,2}$/.test(normalized)) return null;
|
||||
const value = Number.parseInt(normalized, 16);
|
||||
if (Number.isNaN(value) || value < 0 || value > 0xff) return null;
|
||||
return value;
|
||||
}
|
||||
@@ -37,5 +37,7 @@ export function channelTargetSchema(options?: { description?: string }) {
|
||||
}
|
||||
|
||||
export function channelTargetsSchema(options?: { description?: string }) {
|
||||
return Type.Array(channelTargetSchema({ description: options?.description ?? CHANNEL_TARGETS_DESCRIPTION }));
|
||||
return Type.Array(
|
||||
channelTargetSchema({ description: options?.description ?? CHANNEL_TARGETS_DESCRIPTION }),
|
||||
);
|
||||
}
|
||||
|
||||
26
src/agents/session-slug.test.ts
Normal file
26
src/agents/session-slug.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSessionSlug } from "./session-slug.js";
|
||||
|
||||
describe("session slug", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("generates a two-word slug by default", () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
const slug = createSessionSlug();
|
||||
expect(slug).toBe("amber-atlas");
|
||||
});
|
||||
|
||||
it("adds a numeric suffix when the base slug is taken", () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
const slug = createSessionSlug((id) => id === "amber-atlas");
|
||||
expect(slug).toBe("amber-atlas-2");
|
||||
});
|
||||
|
||||
it("falls back to three words when collisions persist", () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
const slug = createSessionSlug((id) => /^amber-atlas(-\d+)?$/.test(id));
|
||||
expect(slug).toBe("amber-atlas-atlas");
|
||||
});
|
||||
});
|
||||
133
src/agents/session-slug.ts
Normal file
133
src/agents/session-slug.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
const SLUG_ADJECTIVES = [
|
||||
"amber",
|
||||
"briny",
|
||||
"brisk",
|
||||
"calm",
|
||||
"clear",
|
||||
"cool",
|
||||
"crisp",
|
||||
"dawn",
|
||||
"delta",
|
||||
"ember",
|
||||
"faint",
|
||||
"fast",
|
||||
"fresh",
|
||||
"gentle",
|
||||
"glow",
|
||||
"good",
|
||||
"grand",
|
||||
"keen",
|
||||
"kind",
|
||||
"lucky",
|
||||
"marine",
|
||||
"mellow",
|
||||
"mild",
|
||||
"neat",
|
||||
"nimble",
|
||||
"nova",
|
||||
"oceanic",
|
||||
"plaid",
|
||||
"quick",
|
||||
"quiet",
|
||||
"rapid",
|
||||
"salty",
|
||||
"sharp",
|
||||
"swift",
|
||||
"tender",
|
||||
"tidal",
|
||||
"tidy",
|
||||
"tide",
|
||||
"vivid",
|
||||
"warm",
|
||||
"wild",
|
||||
"young",
|
||||
];
|
||||
|
||||
const SLUG_NOUNS = [
|
||||
"atlas",
|
||||
"basil",
|
||||
"bison",
|
||||
"bloom",
|
||||
"breeze",
|
||||
"canyon",
|
||||
"cedar",
|
||||
"claw",
|
||||
"cloud",
|
||||
"comet",
|
||||
"coral",
|
||||
"cove",
|
||||
"crest",
|
||||
"crustacean",
|
||||
"daisy",
|
||||
"dune",
|
||||
"ember",
|
||||
"falcon",
|
||||
"fjord",
|
||||
"forest",
|
||||
"glade",
|
||||
"gulf",
|
||||
"harbor",
|
||||
"haven",
|
||||
"kelp",
|
||||
"lagoon",
|
||||
"lobster",
|
||||
"meadow",
|
||||
"mist",
|
||||
"nudibranch",
|
||||
"nexus",
|
||||
"ocean",
|
||||
"orbit",
|
||||
"otter",
|
||||
"pine",
|
||||
"prairie",
|
||||
"reef",
|
||||
"ridge",
|
||||
"river",
|
||||
"rook",
|
||||
"sable",
|
||||
"sage",
|
||||
"seaslug",
|
||||
"shell",
|
||||
"shoal",
|
||||
"shore",
|
||||
"slug",
|
||||
"summit",
|
||||
"tidepool",
|
||||
"trail",
|
||||
"valley",
|
||||
"wharf",
|
||||
"willow",
|
||||
"zephyr",
|
||||
];
|
||||
|
||||
function randomChoice(values: string[], fallback: string) {
|
||||
return values[Math.floor(Math.random() * values.length)] ?? fallback;
|
||||
}
|
||||
|
||||
function createSlugBase(words = 2) {
|
||||
const parts = [randomChoice(SLUG_ADJECTIVES, "steady"), randomChoice(SLUG_NOUNS, "harbor")];
|
||||
if (words > 2) parts.push(randomChoice(SLUG_NOUNS, "reef"));
|
||||
return parts.join("-");
|
||||
}
|
||||
|
||||
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
|
||||
const isIdTaken = isTaken ?? (() => false);
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const base = createSlugBase(2);
|
||||
if (!isIdTaken(base)) return base;
|
||||
for (let i = 2; i <= 12; i += 1) {
|
||||
const candidate = `${base}-${i}`;
|
||||
if (!isIdTaken(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const base = createSlugBase(3);
|
||||
if (!isIdTaken(base)) return base;
|
||||
for (let i = 2; i <= 12; i += 1) {
|
||||
const candidate = `${base}-${i}`;
|
||||
if (!isIdTaken(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`;
|
||||
return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback;
|
||||
}
|
||||
31
src/agents/skills/refresh.test.ts
Normal file
31
src/agents/skills/refresh.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const watchMock = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
close: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("chokidar", () => {
|
||||
return {
|
||||
default: { watch: watchMock },
|
||||
};
|
||||
});
|
||||
|
||||
describe("ensureSkillsWatcher", () => {
|
||||
it("ignores node_modules, dist, and .git by default", async () => {
|
||||
const mod = await import("./refresh.js");
|
||||
mod.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
|
||||
|
||||
expect(watchMock).toHaveBeenCalledTimes(1);
|
||||
const opts = watchMock.mock.calls[0]?.[1] as { ignored?: unknown };
|
||||
|
||||
expect(opts.ignored).toBe(mod.DEFAULT_SKILLS_WATCH_IGNORED);
|
||||
const ignored = mod.DEFAULT_SKILLS_WATCH_IGNORED;
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/dist/index.js"))).toBe(true);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.git/config"))).toBe(true);
|
||||
expect(ignored.some((re) => re.test("/tmp/.hidden/skills/index.md"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,12 @@ const workspaceVersions = new Map<string, number>();
|
||||
const watchers = new Map<string, SkillsWatchState>();
|
||||
let globalVersion = 0;
|
||||
|
||||
export const DEFAULT_SKILLS_WATCH_IGNORED: RegExp[] = [
|
||||
/(^|[\\/])\.git([\\/]|$)/,
|
||||
/(^|[\\/])node_modules([\\/]|$)/,
|
||||
/(^|[\\/])dist([\\/]|$)/,
|
||||
];
|
||||
|
||||
function bumpVersion(current: number): number {
|
||||
const now = Date.now();
|
||||
return now <= current ? current + 1 : now;
|
||||
@@ -125,6 +131,9 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
|
||||
stabilityThreshold: debounceMs,
|
||||
pollInterval: 100,
|
||||
},
|
||||
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
|
||||
// This watcher only needs to react to skill changes.
|
||||
ignored: DEFAULT_SKILLS_WATCH_IGNORED,
|
||||
});
|
||||
|
||||
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
|
||||
|
||||
175
src/agents/subagent-announce-queue.ts
Normal file
175
src/agents/subagent-announce-queue.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { type QueueDropPolicy, type QueueMode } from "../auto-reply/reply/queue.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
type DeliveryContext,
|
||||
deliveryContextKey,
|
||||
normalizeDeliveryContext,
|
||||
} from "../utils/delivery-context.js";
|
||||
import {
|
||||
applyQueueDropPolicy,
|
||||
buildCollectPrompt,
|
||||
buildQueueSummaryPrompt,
|
||||
hasCrossChannelItems,
|
||||
waitForQueueDebounce,
|
||||
} from "../utils/queue-helpers.js";
|
||||
|
||||
export type AnnounceQueueItem = {
|
||||
prompt: string;
|
||||
summaryLine?: string;
|
||||
enqueuedAt: number;
|
||||
sessionKey: string;
|
||||
origin?: DeliveryContext;
|
||||
originKey?: string;
|
||||
};
|
||||
|
||||
export type AnnounceQueueSettings = {
|
||||
mode: QueueMode;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: QueueDropPolicy;
|
||||
};
|
||||
|
||||
type AnnounceQueueState = {
|
||||
items: AnnounceQueueItem[];
|
||||
draining: boolean;
|
||||
lastEnqueuedAt: number;
|
||||
mode: QueueMode;
|
||||
debounceMs: number;
|
||||
cap: number;
|
||||
dropPolicy: QueueDropPolicy;
|
||||
droppedCount: number;
|
||||
summaryLines: string[];
|
||||
send: (item: AnnounceQueueItem) => Promise<void>;
|
||||
};
|
||||
|
||||
const ANNOUNCE_QUEUES = new Map<string, AnnounceQueueState>();
|
||||
|
||||
function getAnnounceQueue(
|
||||
key: string,
|
||||
settings: AnnounceQueueSettings,
|
||||
send: (item: AnnounceQueueItem) => Promise<void>,
|
||||
) {
|
||||
const existing = ANNOUNCE_QUEUES.get(key);
|
||||
if (existing) {
|
||||
existing.mode = settings.mode;
|
||||
existing.debounceMs =
|
||||
typeof settings.debounceMs === "number"
|
||||
? Math.max(0, settings.debounceMs)
|
||||
: existing.debounceMs;
|
||||
existing.cap =
|
||||
typeof settings.cap === "number" && settings.cap > 0
|
||||
? Math.floor(settings.cap)
|
||||
: existing.cap;
|
||||
existing.dropPolicy = settings.dropPolicy ?? existing.dropPolicy;
|
||||
existing.send = send;
|
||||
return existing;
|
||||
}
|
||||
const created: AnnounceQueueState = {
|
||||
items: [],
|
||||
draining: false,
|
||||
lastEnqueuedAt: 0,
|
||||
mode: settings.mode,
|
||||
debounceMs: typeof settings.debounceMs === "number" ? Math.max(0, settings.debounceMs) : 1000,
|
||||
cap: typeof settings.cap === "number" && settings.cap > 0 ? Math.floor(settings.cap) : 20,
|
||||
dropPolicy: settings.dropPolicy ?? "summarize",
|
||||
droppedCount: 0,
|
||||
summaryLines: [],
|
||||
send,
|
||||
};
|
||||
ANNOUNCE_QUEUES.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function scheduleAnnounceDrain(key: string) {
|
||||
const queue = ANNOUNCE_QUEUES.get(key);
|
||||
if (!queue || queue.draining) return;
|
||||
queue.draining = true;
|
||||
void (async () => {
|
||||
try {
|
||||
let forceIndividualCollect = false;
|
||||
while (queue.items.length > 0 || queue.droppedCount > 0) {
|
||||
await waitForQueueDebounce(queue);
|
||||
if (queue.mode === "collect") {
|
||||
if (forceIndividualCollect) {
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await queue.send(next);
|
||||
continue;
|
||||
}
|
||||
const isCrossChannel = hasCrossChannelItems(queue.items, (item) => {
|
||||
if (!item.origin) return {};
|
||||
if (!item.originKey) return { cross: true };
|
||||
return { key: item.originKey };
|
||||
});
|
||||
if (isCrossChannel) {
|
||||
forceIndividualCollect = true;
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await queue.send(next);
|
||||
continue;
|
||||
}
|
||||
const items = queue.items.splice(0, queue.items.length);
|
||||
const summary = buildQueueSummaryPrompt({ state: queue, noun: "announce" });
|
||||
const prompt = buildCollectPrompt({
|
||||
title: "[Queued announce messages while agent was busy]",
|
||||
items,
|
||||
summary,
|
||||
renderItem: (item, idx) => `---\nQueued #${idx + 1}\n${item.prompt}`.trim(),
|
||||
});
|
||||
const last = items.at(-1);
|
||||
if (!last) break;
|
||||
await queue.send({ ...last, prompt });
|
||||
continue;
|
||||
}
|
||||
|
||||
const summaryPrompt = buildQueueSummaryPrompt({ state: queue, noun: "announce" });
|
||||
if (summaryPrompt) {
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await queue.send({ ...next, prompt: summaryPrompt });
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await queue.send(next);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error?.(`announce queue drain failed for ${key}: ${String(err)}`);
|
||||
} finally {
|
||||
queue.draining = false;
|
||||
if (queue.items.length === 0 && queue.droppedCount === 0) {
|
||||
ANNOUNCE_QUEUES.delete(key);
|
||||
} else {
|
||||
scheduleAnnounceDrain(key);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function enqueueAnnounce(params: {
|
||||
key: string;
|
||||
item: AnnounceQueueItem;
|
||||
settings: AnnounceQueueSettings;
|
||||
send: (item: AnnounceQueueItem) => Promise<void>;
|
||||
}): boolean {
|
||||
const queue = getAnnounceQueue(params.key, params.settings, params.send);
|
||||
queue.lastEnqueuedAt = Date.now();
|
||||
|
||||
const shouldEnqueue = applyQueueDropPolicy({
|
||||
queue,
|
||||
summarize: (item) => item.summaryLine?.trim() || item.prompt.trim(),
|
||||
});
|
||||
if (!shouldEnqueue) {
|
||||
if (queue.dropPolicy === "new") {
|
||||
scheduleAnnounceDrain(params.key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin = normalizeDeliveryContext(params.item.origin);
|
||||
const originKey = deliveryContextKey(origin);
|
||||
queue.items.push({ ...params.item, origin, originKey });
|
||||
scheduleAnnounceDrain(params.key);
|
||||
return true;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user