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