Compare commits

..
Author SHA1 Message Date
Peter Steinberger c457d8d70c fix: handle Telegram General topic thread params (#848) (thanks @azade-c) 2026-01-16 00:07:45 +00:00
Azade 69fe974d80 fix(telegram): separate thread params for typing vs messages
Telegram General topic (id=1) has inconsistent API behavior:
- sendMessage: rejects explicit message_thread_id=1
- sendChatAction: requires message_thread_id=1 for typing to show

Split into two helper functions:
- buildTelegramThreadParams: excludes General topic for messages
- buildTypingThreadParams: includes General topic for typing
2026-01-16 00:01:39 +00:00
Peter SteinbergerandTyler Yust 10eb1beccf fix: tighten session entry updates
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-15 23:44:32 +00:00
Peter SteinbergerandTyler Yust a4b347b454 feat: refine subagents + add chat.inject
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-15 23:44:31 +00:00
Peter SteinbergerandTyler Yust 688a0ce439 refactor: harden session store updates
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-15 23:41:34 +00:00
Peter Steinberger 35492f8513 docs: add inbound debounce config example 2026-01-15 23:20:14 +00:00
Peter Steinberger db9be87d94 refactor: centralize daemon path resolution 2026-01-15 23:19:52 +00:00
juanpablodlcandPeter Steinberger 4a99b9b651 feat(whatsapp): add debounceMs for batching rapid messages (#971)
* feat(whatsapp): add debounceMs for batching rapid messages

Add a `debounceMs` configuration option to WhatsApp channel settings
that batches rapid consecutive messages from the same sender into a
single response. This prevents triggering separate agent runs for
each message when a user sends multiple short messages in quick
succession (e.g., "Hey!", "how are you?", "I was wondering...").

Changes:
- Add `debounceMs` config to WhatsAppConfig and WhatsAppAccountConfig
- Implement message buffering in `monitorWebInbox` with:
  - Map-based buffer keyed by sender (DM) or chat ID (groups)
  - Debounce timer that resets on each new message
  - Message combination with newline separator
  - Single message optimization (no modification if only one message)
- Wire `debounceMs` through account resolution and monitor tuning
- Add UI hints and schema documentation

Usage example:
{
  "channels": {
    "whatsapp": {
      "debounceMs": 5000  // 5 second window
    }
  }
}

Default behavior: `debounceMs: 0` (disabled by default)

Verified: All existing tests pass (3204 tests), TypeScript compilation
succeeds with no errors.

Implemented with assistance from AI coding tools.

Closes #967

* chore: wip inbound debounce

* fix: debounce inbound messages across channels (#971) (thanks @juanpablodlc)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 23:07:19 +00:00
Peter Steinberger 7dea403302 chore: purge DS_Store files 2026-01-15 22:59:16 +00:00
Peter Steinberger 10731dfee3 docs: add sync up shorthand 2026-01-15 22:58:34 +00:00
Peter Steinberger d00f2d9c0c test: cover model picker ordering and openrouter index 2026-01-15 22:56:11 +00:00
Peter Steinberger 8b89980a89 feat(date-time): standardize time context and tool timestamps 2026-01-15 22:27:06 +00:00
634a429c50 fix(model-picker): list each provider/model combo separately (#970)
* fix(model-picker): list each provider/model combo separately

Previously, /model grouped models by name and showed all providers
that offer the same model (e.g. 'claude-sonnet-4-5 — anthropic, google-antigravity').
This was confusing because:
1. Users couldn't tell which provider would be used when selecting by number
2. The display implied choice between providers but selection was automatic

Now each provider/model combination is listed separately so users
can explicitly select the exact provider they want.

- Remove model grouping in buildModelPickerItems
- Display format changed from 'model — providers' to 'provider/model'
- pickProviderForModel now returns the single provider directly
- Updated tests to reflect new behavior

* fix: simplify model picker entries (#970) (thanks @mcinteerj)

---------

Co-authored-by: Keith the Silly Goose <keith@42bolton.macnet.nz>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 22:20:11 +00:00
Peter Steinberger bf90815b9e Merge pull request #969 from bjesuiter/fix/launchd-label-resolution
fix: unify daemon service label resolution with env parameter
2026-01-15 22:12:12 +00:00
Peter Steinberger 2b113c4d6c chore: update changelog (#969) (thanks @bjesuiter) 2026-01-15 22:10:27 +00:00
Benjamin Jesuiter 7f6a288bd3 docs: clarify multi-gateway rescue bot guidance 2026-01-15 22:10:27 +00:00
Benjamin Jesuiter daf471c450 fix: unify daemon service label resolution with env 2026-01-15 22:10:27 +00:00
Peter Steinberger cb78fa46a1 fix: make node-llama-cpp optional 2026-01-15 18:37:02 +00:00
Shadow 316e8b2eb2 Discord: fix allowlist gating. Closes #961 2026-01-15 11:53:13 -06:00
Peter Steinberger 4de81ed6c4 docs(changelog): move browser relay fixes to 2026.1.15 2026-01-15 17:26:54 +00:00
Peter Steinberger b6fb24f6d2 Merge pull request #964 from bohdanpodvirnyi/feat/telegram-reactions
feat(telegram): add bidirectional emoji reactions support
2026-01-15 17:21:51 +00:00
Peter Steinberger 2b1c26f900 fix: refine telegram reactions (#964) (thanks @bohdanpodvirnyi) 2026-01-15 17:20:17 +00:00
Peter Steinberger 47ea9356d1 Merge pull request #959 from rdev/rdev/finally-fix-antigravity-claude
Fix antigravity claude
2026-01-15 17:10:04 +00:00
Bohdan Podvirnyi f12c1b391f fix: lint errors 2026-01-15 17:08:31 +00:00
Peter Steinberger 05658b6609 fix: tighten command arg value types 2026-01-15 17:08:09 +00:00
Bohdan Podvirnyi dfb6630de1 fix: remove dup definitions + add reaction config 2026-01-15 17:07:38 +00:00
Bohdan Podvirnyi eb7656d68c fix: lint errors 2026-01-15 17:07:38 +00:00
Bohdan Podvirnyi 0e1dcf9cb4 feat: added capability for clawdbot to react 2026-01-15 17:07:38 +00:00
Bohdan Podvirnyi d05c3d0659 feat: make telegram reactions visible to clawdbot 2026-01-15 17:07:38 +00:00
Peter Steinberger 36292d3fbb fix: preserve Antigravity Claude signatures (#959) (thanks @rdev) 2026-01-15 17:06:39 +00:00
Max 1ae344d8a6 Fix antigravity claude 2026-01-15 17:06:39 +00:00
Peter Steinberger 01c43b0b0c fix: avoid base string coercion in auto-reply formatting 2026-01-15 17:03:34 +00:00
Peter Steinberger fc4aa9a683 Merge pull request #954 from roshanasingh4/fix/946-openai-completions-trim
Fix model fallback crash on undefined provider/model (#946)
2026-01-15 16:59:19 +00:00
Peter Steinberger c043e9767f fix: default model fallback when provider/model missing (#954) (thanks @roshanasingh4) 2026-01-15 16:58:41 +00:00
Peter Steinberger 8b48299d8f Merge pull request #953 from roshanasingh4/fix/cli-quick-reference-system-prompt
Fix system prompt: prevent invented CLI commands
2026-01-15 16:55:52 +00:00
Roshan Singh 0f27cff247 Fix model fallback crash on undefined provider/model (#946) 2026-01-15 16:50:46 +00:00
Roshan Singh d0455f2683 fix(system-prompt): add CLI quick reference to prevent invented commands 2026-01-15 11:43:22 +00:00
182 changed files with 6875 additions and 1545 deletions

No files matched your search

+3
View File
@@ -60,6 +60,9 @@
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list. - When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README. - After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
## Shorthand Commands
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
### PR Workflow (Review vs Land) ### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code. - **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this! - **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
+50 -115
View File
@@ -2,7 +2,24 @@
## 2026.1.15 (unreleased) ## 2026.1.15 (unreleased)
- Agents: add CLI quick reference to the system prompt to avoid invented commands. (#953) — thanks @roshanasingh4. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- 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.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
## 2026.1.14-1 ## 2026.1.14-1
@@ -13,144 +30,62 @@
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy. - Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
### Changes ### Changes
#### Web Tools
- Tools: add `web_search`/`web_fetch` (Brave API), including helpful setup hints when the key is missing.
- Tools: enable `web_fetch` by default (unless explicitly disabled in config).
- CLI/Docs: add `clawdbot configure --section web` for storing Brave API keys and update onboarding tips.
#### Browser / Control UI
- Browser: add Chrome extension relay takeover mode (toolbar button) + `clawdbot browser serve` remote control + `browser.controlToken`.
- Browser: ship a built-in `chrome` profile for extension relay and start the relay automatically when running locally.
- Browser: default `browser.defaultProfile` to `chrome` (existing Chrome takeover mode).
- Browser: add `clawdbot browser extension install/path` and copy extension path to clipboard.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Control UI: show raw any-map entries in config views; move Docs link into the left nav.
#### Plugins
- Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.
- Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.
#### Security
- Security: expand `clawdbot security audit` checks and publish a `SECURITY.md` reporting policy.
- Security: extend `clawdbot security audit --fix` to tighten more sensitive state paths.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
#### Onboarding / Daemon
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
#### Auth / Usage / Config
- Usage: add MiniMax coding plan usage tracking.
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Auth: add dynamic template variables to `messages.responsePrefix`. (#928) — thanks @sebslight.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
#### Channels
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz.
#### Docs
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. - Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. - Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist. - Docs: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. - Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) - Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`. - Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- Docs: add multi-gateway guide (sidebar + nav). - CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
### Fixes ### Fixes
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
#### Gateway / Daemon / Sessions
- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.
- Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare `main` alias).
- Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.
- Browser: add tests for snapshot labels/efficient query params and labeled image responses. - Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Browser: persist role snapshot refs per CDP target so `snapshot` → `act` clicks work even if Playwright returns a different Page instance.
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. - Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. - Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. - Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. - Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman. - Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani. - Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani. - Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani. - Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). - Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
#### CLI / Onboarding ## 2026.1.14
- Onboarding: show web search setup at the end (not the beginning).
- Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.
- Health: colorize “not configured” provider lines for easier scanning.
#### Control UI / TUI ### Changes
- Control UI: load cron run history on job selection and clarify empty-state messaging. (#866) - Usage: add MiniMax coding plan usage tracking.
- UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor. - Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- UI: always apply `?token=` from URL (fixes unauthorized after re-onboard). - Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- Browser: add tests for snapshot labels/efficient query params and labeled image responses. - Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
### Fixes
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. - TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. - TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`. - Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
#### Agents / Auth / Tools / Sandbox
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
#### macOS / Apps
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. - macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- macOS: update cron testing channel arg. (#896) — thanks @ngutman.
#### Channels / Messaging
- Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. - Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). - Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.
- Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko. - Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. - Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935) - Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester. - WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee. - WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
- WhatsApp: harden owner command auth.
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
#### Config / Doctor / Packaging
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
- Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
## 2026.1.13 ## 2026.1.13
+19 -18
View File
@@ -474,22 +474,23 @@ Core contributors:
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a>
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
<a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
<a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
<a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
<a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
<a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
<a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
<a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a>
</p> </p>
+3 -1
View File
@@ -191,7 +191,7 @@ Notes:
- Your config requires mentions and you didn’t mention it, or - Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user. - Your guild/channel allowlist denies the channel/user.
- **`requireMention: false` but still no replies**: - **`requireMention: false` but still no replies**:
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or explicitly list channels under `channels.discord.guilds.<id>.channels`. - `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored. - `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions. - **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`). - **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
@@ -352,6 +352,7 @@ Allowlist matching notes:
- Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported. - Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported.
- Use `*` to allow any sender/channel. - Use `*` to allow any sender/channel.
- When `guilds.<id>.channels` is present, channels not listed are denied by default. - When `guilds.<id>.channels` is present, channels not listed are denied by default.
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
Native command notes: Native command notes:
- The registered commands mirror Clawdbot’s chat commands. - The registered commands mirror Clawdbot’s chat commands.
@@ -362,6 +363,7 @@ The agent can call `discord` with actions like:
- `react` / `reactions` (add or list reactions) - `react` / `reactions` (add or list reactions)
- `sticker`, `poll`, `permissions` - `sticker`, `poll`, `permissions`
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage` - `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
- Read/search/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Discord `timestamp`.
- `threadCreate`, `threadList`, `threadReply` - `threadCreate`, `threadList`, `threadReply`
- `pinMessage`, `unpinMessage`, `listPins` - `pinMessage`, `unpinMessage`, `listPins`
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList` - `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
+1
View File
@@ -327,4 +327,5 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`. - Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. - Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions). - For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
- Read/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Slack `ts`.
- Attachments are downloaded to the media store when permitted and under the size limit. - Attachments are downloaded to the media store when permitted and under the size limit.
+46
View File
@@ -188,6 +188,7 @@ Disable with:
Telegram forum topics include a `message_thread_id` per message. Clawdbot: Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated. - Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
- Sends typing indicators and replies with `message_thread_id` so responses stay in the topic. - Sends typing indicators and replies with `message_thread_id` so responses stay in the topic.
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating. - Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable). - Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
@@ -297,6 +298,49 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
- Reaction removal semantics: see [/tools/reactions](/tools/reactions). - Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled). - Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
## Reaction notifications
**How reactions work:**
Telegram reactions arrive as **separate `message_reaction` events**, not as properties in message payloads. When a user adds a reaction, Clawdbot:
1. Receives the `message_reaction` update from Telegram API
2. Converts it to a **system event** with format: `"Telegram reaction added: {emoji} by {user} on msg {id}"`
3. Enqueues the system event using the **same session key** as regular messages
4. When the next message arrives in that conversation, system events are drained and prepended to the agent's context
The agent sees reactions as **system notifications** in the conversation history, not as message metadata.
**Configuration:**
- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications
- `"off"` — ignore all reactions (default when not set)
- `"own"` — notify when users react to bot messages (best-effort; in-memory)
- `"all"` — notify for all reactions
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
- `"off"` — agent cannot react to messages
- `"ack"` — bot sends acknowledgment reactions (👀 while processing) (default)
- `"minimal"` — agent can react sparingly (guideline: 1 per 5-10 exchanges)
- `"extensive"` — agent can react liberally when appropriate
**Forum groups:** Reactions in forum groups include `message_thread_id` and use session keys like `agent:main:telegram:group:{chatId}:topic:{threadId}`. This ensures reactions and messages in the same topic stay together.
**Example config:**
```json5
{
channels: {
telegram: {
reactionNotifications: "all", // See all reactions
reactionLevel: "minimal" // Agent can react sparingly
}
}
}
```
**Requirements:**
- Telegram bots must explicitly request `message_reaction` in `allowed_updates` (configured automatically by Clawdbot)
- For webhook mode, reactions are included in the webhook `allowed_updates`
- For polling mode, reactions are included in the `getUpdates` `allowed_updates`
## 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 --to 123456789 --message "hi"`.
@@ -360,6 +404,8 @@ Provider options:
- `channels.telegram.actions.reactions`: gate Telegram tool reactions. - `channels.telegram.actions.reactions`: gate Telegram tool reactions.
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. - `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `off` when not set).
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `ack` when not set).
Related global options: Related global options:
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns). - `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
+1 -1
View File
@@ -78,7 +78,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
- your configured remote gateway (if set), and - your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**. - localhost (loopback) **even if remote is configured**.
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway. If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
```bash ```bash
clawdbot gateway status clawdbot gateway status
+26
View File
@@ -33,6 +33,32 @@ Channels can redeliver the same message after reconnects. Clawdbot keeps a
short-lived cache keyed by channel/account/peer/session/message id so duplicate short-lived cache keyed by channel/account/peer/session/message id so duplicate
deliveries do not trigger another agent run. deliveries do not trigger another agent run.
## Inbound debouncing
Rapid consecutive messages from the **same sender** can be batched into a single
agent turn via `messages.inbound`. Debouncing is scoped per channel + conversation
and uses the most recent message for reply threading/IDs.
Config (global default + per-channel overrides):
```json5
{
messages: {
inbound: {
debounceMs: 2000,
byChannel: {
whatsapp: 5000,
slack: 1500,
discord: 1500
}
}
}
}
```
Notes:
- Debounce applies to **text-only** messages; media/attachments flush immediately.
- Control commands bypass debouncing so they remain standalone.
## Sessions and devices ## Sessions and devices
Sessions are owned by the gateway, not by clients. Sessions are owned by the gateway, not by clients.
+12 -5
View File
@@ -20,7 +20,7 @@ The prompt is intentionally compact and uses fixed sections:
- **Workspace**: working directory (`agents.defaults.workspace`). - **Workspace**: working directory (`agents.defaults.workspace`).
- **Workspace Files (injected)**: indicates bootstrap files are included below. - **Workspace Files (injected)**: indicates bootstrap files are included below.
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available. - **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
- **Time**: UTC default + the user’s local time (already converted). - **Current Date & Time**: user-local time, timezone, and time format.
- **Reply Tags**: optional reply tag syntax for supported providers. - **Reply Tags**: optional reply tag syntax for supported providers.
- **Heartbeats**: heartbeat prompt and ack behavior. - **Heartbeats**: heartbeat prompt and ack behavior.
- **Runtime**: host, OS, node, model, thinking level (one line). - **Runtime**: host, OS, node, model, thinking level (one line).
@@ -46,12 +46,19 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
## Time handling ## Time handling
The Time line is compact and explicit: The system prompt includes a dedicated **Current Date & Time** section when user
time or timezone is known. It is explicit about:
- Assume timestamps are **UTC** unless stated. - The user’s **local time** (already converted).
- The listed **user time** is already converted to `agents.defaults.userTimezone` (if set). - The **time zone** used for the conversion.
- The **time format** (12-hour / 24-hour).
Use `agents.defaults.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone. Configure with:
- `agents.defaults.userTimezone`
- `agents.defaults.timeFormat` (`auto` | `12` | `24`)
See [Date & Time](/date-time) for full behavior details.
## Skills ## Skills
+14 -5
View File
@@ -19,10 +19,15 @@ Inbound messages are wrapped in an envelope like:
The timestamp in the envelope is **always UTC**, with minutes precision. The timestamp in the envelope is **always UTC**, with minutes precision.
## Tool payloads (raw provider data) ## Tool payloads (raw provider data + normalized fields)
Tool calls (`channels.discord.readMessages`, `channels.slack.readMessages`, etc.) return **raw provider timestamps**. Tool calls (`channels.discord.readMessages`, `channels.slack.readMessages`, etc.) return **raw provider timestamps**.
These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We do not rewrite them. We also attach normalized fields for consistency:
- `timestampMs` (UTC epoch milliseconds)
- `timestampUtc` (ISO 8601 UTC string)
Raw provider fields are preserved.
## User timezone for the system prompt ## User timezone for the system prompt
@@ -31,10 +36,14 @@ unset, Clawdbot resolves the **host timezone at runtime** (no config write).
```json5 ```json5
{ {
agent: { userTimezone: "America/Chicago" } agents: { defaults: { userTimezone: "America/Chicago" } }
} }
``` ```
The system prompt includes: The system prompt includes:
- `User timezone: America/Chicago` - `Current Date & Time` section with local time and timezone
- `Current user time: 2026-01-05 15:26` - `Time format: 12-hour` or `24-hour`
You can control the prompt format with `agents.defaults.timeFormat` (`auto` | `12` | `24`).
See [Date & Time](/date-time) for the full behavior and examples.
+85
View File
@@ -0,0 +1,85 @@
---
summary: "Date and time handling across envelopes, prompts, tools, and connectors"
read_when:
- You are changing how timestamps are shown to the model or users
- You are debugging time formatting in messages or system prompt output
---
# Date & Time
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
We avoid rewriting provider timestamps so tools keep their native semantics.
## Message envelopes (UTC)
Inbound messages are wrapped with a UTC timestamp (minute precision):
```
[Provider ... 2026-01-05T21:26Z] message text
```
This envelope timestamp is **always UTC**, regardless of the host timezone.
## System prompt: Current Date & Time
If the user timezone or local time is known, the system prompt includes a dedicated
**Current Date & Time** section:
```
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
Time format: 12-hour
```
If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references.
## System event lines (UTC)
Queued system events inserted into agent context are prefixed with a UTC timestamp:
```
System: [2026-01-12T20:19:17Z] Model switched.
```
### Configure user timezone + format
```json5
{
agents: {
defaults: {
userTimezone: "America/Chicago",
timeFormat: "auto" // auto | 12 | 24
}
}
}
```
- `userTimezone` sets the **user-local timezone** for prompt context.
- `timeFormat` controls **12h/24h display** in the prompt. `auto` follows OS prefs.
## Time format detection (auto)
When `timeFormat: "auto"`, Clawdbot inspects the OS preference (macOS/Windows)
and falls back to locale formatting. The detected value is **cached per process**
to avoid repeated system calls.
## Tool payloads + connectors (raw provider time + normalized fields)
Channel tools return **provider-native timestamps** and add normalized fields for consistency:
- `timestampMs`: epoch milliseconds (UTC)
- `timestampUtc`: ISO 8601 UTC string
Raw provider fields are preserved so nothing is lost.
- Slack: epoch-like strings from the API
- Discord: UTC ISO timestamps
- Telegram/WhatsApp: provider-specific numeric/ISO timestamps
If you need local time, convert it downstream using the known timezone.
## Related docs
- [System Prompt](/concepts/system-prompt)
- [Timezones](/concepts/timezone)
- [Messages](/concepts/messages)
+37 -1
View File
@@ -822,6 +822,31 @@ Controls how inbound messages behave when an agent run is already active.
} }
``` ```
### `messages.inbound`
Debounce rapid inbound messages from the **same sender** so multiple back-to-back
messages become a single agent turn. Debouncing is scoped per channel + conversation
and uses the most recent message for reply threading/IDs.
```json5
{
messages: {
inbound: {
debounceMs: 2000, // 0 disables
byChannel: {
whatsapp: 5000,
slack: 1500,
discord: 1500
}
}
}
}
```
Notes:
- Debounce batches **text-only** messages; media/attachments flush immediately.
- Control commands (e.g. `/queue`, `/new`) bypass debouncing so they stay standalone.
### `commands` (chat command handling) ### `commands` (chat command handling)
Controls how chat commands are enabled across connectors. Controls how chat commands are enabled across connectors.
@@ -1211,6 +1236,17 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime.
} }
``` ```
### `agents.defaults.timeFormat`
Controls the **time format** shown in the system prompt’s Current Date & Time section.
Default: `auto` (OS preference).
```json5
{
agents: { defaults: { timeFormat: "auto" } } // auto | 12 | 24
}
```
### `messages` ### `messages`
Controls inbound/outbound prefixes and optional ack reactions. Controls inbound/outbound prefixes and optional ack reactions.
@@ -2512,7 +2548,7 @@ Requires full Gateway restart:
### Multi-instance isolation ### Multi-instance isolation
To run multiple gateways on one host, isolate per-instance state + config and use unique ports: To run multiple gateways on one host (for redundancy or a rescue bot), isolate per-instance state + config and use unique ports:
- `CLAWDBOT_CONFIG_PATH` (per-instance config) - `CLAWDBOT_CONFIG_PATH` (per-instance config)
- `CLAWDBOT_STATE_DIR` (sessions/creds) - `CLAWDBOT_STATE_DIR` (sessions/creds)
- `agents.defaults.workspace` (memories) - `agents.defaults.workspace` (memories)
+1 -1
View File
@@ -16,7 +16,7 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
## Terms ## Terms
- **Gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. - **Gateway**: a single long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. Most setups use one per host; isolated multi-gateway setups are possible.
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`. - **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only. - **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH. - **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
+1 -1
View File
@@ -9,7 +9,7 @@ read_when:
Last updated: 2025-12-11 Last updated: 2025-12-11
## Why ## Why
- Ensure only one gateway instance runs per host. - Ensure only one gateway instance runs per base port on the same host; additional gateways must use isolated profiles and unique ports.
- Survive crashes/SIGKILL without leaving stale lock files. - Survive crashes/SIGKILL without leaving stale lock files.
- Fail fast with a clear error when the control port is already occupied. - Fail fast with a clear error when the control port is already occupied.
+3 -1
View File
@@ -63,6 +63,8 @@ Install metadata is embedded in the service config:
- `CLAWDBOT_SERVICE_KIND=gateway` - `CLAWDBOT_SERVICE_KIND=gateway`
- `CLAWDBOT_SERVICE_VERSION=<version>` - `CLAWDBOT_SERVICE_VERSION=<version>`
Rescue-Bot Pattern: keep a second Gateway isolated with its own profile, state dir, workspace, and base port spacing. Full guide: [Rescue-bot guide](/gateway/multiple-gateways#rescue-bot-guide).
### Dev profile (`--dev`) ### Dev profile (`--dev`)
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup. Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup.
@@ -205,7 +207,7 @@ Notes:
- `daemon status` includes the last gateway error line when the service looks running but the port is closed. - `daemon status` includes the last gateway error line when the service looks running but the port is closed.
- `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed). - `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed).
- If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services. - If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services.
We still recommend **one gateway per machine** unless you need redundant profiles. We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways).
- Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations). - Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
- `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes). - `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes).
+33 -1
View File
@@ -6,7 +6,7 @@ read_when:
--- ---
# Multiple Gateways (same host) # Multiple Gateways (same host)
Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy, run separate Gateways. Both are supported. Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy (e.g., a rescue bot), run separate Gateways with isolated profiles/ports.
## Isolation checklist (required) ## Isolation checklist (required)
- `CLAWDBOT_CONFIG_PATH` — per-instance config file - `CLAWDBOT_CONFIG_PATH` — per-instance config file
@@ -37,6 +37,38 @@ clawdbot --profile main daemon install
clawdbot --profile rescue daemon install clawdbot --profile rescue daemon install
``` ```
## Rescue-bot guide
Run a second Gateway on the same host with its own:
- profile/config
- state dir
- workspace
- base port (plus derived ports)
This keeps the rescue bot isolated from the main bot so it can debug or apply config changes if the primary bot is down.
Port spacing: leave at least 20 ports between base ports so the derived bridge/browser/canvas/CDP ports never collide.
### How to install (rescue bot)
```bash
# Main bot (existing or fresh, without --profile param)
# Runs on port 18789 + Chrome CDC/Canvas/... Ports
clawdbot onboard
clawdbot daemon install
# Rescue bot (isolated profile + ports)
clawdbot --profile rescue onboard
# Notes:
# - workspace name will be postfixed with -rescue per default
# - Port should be at least 18789 + 20 Ports,
# better choose completely different base port, like 19789,
# - rest of the onboarding is the same as normal
# To install the daemon (if not happened automatically during onboarding)
clawdbot --profile rescue daemon install
```
## Port mapping (derived) ## Port mapping (derived)
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`). Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
+1 -1
View File
@@ -26,7 +26,7 @@ Flow example (Telegram → node):
- Node returns the result; Gateway replies back out to Telegram. - Node returns the result; Gateway replies back out to Telegram.
Notes: Notes:
- **Nodes do not run the gateway daemon.** Only one gateway should run per host. - **Nodes do not run the gateway daemon.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
- macOS app “node mode” is just a node client over the Bridge. - macOS app “node mode” is just a node client over the Bridge.
## SSH tunnel (CLI + tools) ## SSH tunnel (CLI + tools)
+1 -1
View File
@@ -66,7 +66,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
## Network model ## Network model
- **One Gateway per host**: it is the only process allowed to own the WhatsApp Web session. - **One Gateway per host (recommended)**: it is the only process allowed to own the WhatsApp Web session. If you need a rescue bot or strict isolation, run multiple gateways with isolated profiles and ports; see [Multiple gateways](/gateway/multiple-gateways).
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`. - **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
- The wizard now generates a gateway token by default (even for loopback). - The wizard now generates a gateway token by default (even for loopback).
- For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds). - For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).
+4 -3
View File
@@ -665,7 +665,7 @@ Nodes don’t see inbound provider traffic; they only receive bridge RPC calls.
### Do nodes run a gateway daemon? ### Do nodes run a gateway daemon?
No. Only **one gateway** should run per host. Nodes are peripherals that connect No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app). to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes. A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
@@ -1542,10 +1542,11 @@ See [Streaming](/concepts/streaming).
### Discord doesn’t reply in my server even with `requireMention: false`. Why? ### Discord doesn’t reply in my server even with `requireMention: false`. Why?
`requireMention` only controls mention‑gating **after** the channel passes allowlists. `requireMention` only controls mention‑gating **after** the channel passes allowlists.
By default `channels.discord.groupPolicy` is **allowlist**, so guild channels must be explicitly enabled. By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
Fix checklist: Fix checklist:
1) Set `channels.discord.groupPolicy: "open"` **or** add the guild/channel allowlist. 1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`. 2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel). 3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
Top‑level `channels.discord.requireMention` is not a supported key. Top‑level `channels.discord.requireMention` is not a supported key.
+3 -1
View File
@@ -169,7 +169,6 @@
"json5": "^2.2.3", "json5": "^2.2.3",
"long": "5.3.2", "long": "5.3.2",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"node-llama-cpp": "3.14.5",
"osc-progress": "^0.2.0", "osc-progress": "^0.2.0",
"playwright-core": "1.57.0", "playwright-core": "1.57.0",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
@@ -181,6 +180,9 @@
"ws": "^8.19.0", "ws": "^8.19.0",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"optionalDependencies": {
"node-llama-cpp": "3.14.5"
},
"devDependencies": { "devDependencies": {
"@grammyjs/types": "^3.23.0", "@grammyjs/types": "^3.23.0",
"@lit-labs/signals": "^0.2.0", "@lit-labs/signals": "^0.2.0",
+175 -60
View File
@@ -122,9 +122,6 @@ importers:
markdown-it: markdown-it:
specifier: ^14.1.0 specifier: ^14.1.0
version: 14.1.0 version: 14.1.0
node-llama-cpp:
specifier: 3.14.5
version: 3.14.5(typescript@5.9.3)
osc-progress: osc-progress:
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0 version: 0.2.0
@@ -237,6 +234,10 @@ importers:
wireit: wireit:
specifier: ^0.14.12 specifier: ^0.14.12
version: 0.14.12 version: 0.14.12
optionalDependencies:
node-llama-cpp:
specifier: 3.14.5
version: 3.14.5(typescript@5.9.3)
extensions/matrix: extensions/matrix:
dependencies: dependencies:
@@ -4942,7 +4943,8 @@ snapshots:
hono: 4.11.4 hono: 4.11.4
optional: true optional: true
'@huggingface/jinja@0.5.3': {} '@huggingface/jinja@0.5.3':
optional: true
'@img/colour@1.0.0': {} '@img/colour@1.0.0': {}
@@ -5081,8 +5083,10 @@ snapshots:
debug: 4.4.3 debug: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
optional: true
'@kwsites/promise-deferred@1.1.1': {} '@kwsites/promise-deferred@1.1.1':
optional: true
'@lit-labs/signals@0.2.0': '@lit-labs/signals@0.2.0':
dependencies: dependencies:
@@ -5338,6 +5342,7 @@ snapshots:
'@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6)
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
'@octokit/webhooks': 14.2.0 '@octokit/webhooks': 14.2.0
optional: true
'@octokit/auth-app@8.1.2': '@octokit/auth-app@8.1.2':
dependencies: dependencies:
@@ -5349,6 +5354,7 @@ snapshots:
toad-cache: 3.7.0 toad-cache: 3.7.0
universal-github-app-jwt: 2.2.2 universal-github-app-jwt: 2.2.2
universal-user-agent: 7.0.3 universal-user-agent: 7.0.3
optional: true
'@octokit/auth-oauth-app@9.0.3': '@octokit/auth-oauth-app@9.0.3':
dependencies: dependencies:
@@ -5357,6 +5363,7 @@ snapshots:
'@octokit/request': 10.0.7 '@octokit/request': 10.0.7
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
universal-user-agent: 7.0.3 universal-user-agent: 7.0.3
optional: true
'@octokit/auth-oauth-device@8.0.3': '@octokit/auth-oauth-device@8.0.3':
dependencies: dependencies:
@@ -5364,6 +5371,7 @@ snapshots:
'@octokit/request': 10.0.7 '@octokit/request': 10.0.7
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
universal-user-agent: 7.0.3 universal-user-agent: 7.0.3
optional: true
'@octokit/auth-oauth-user@6.0.2': '@octokit/auth-oauth-user@6.0.2':
dependencies: dependencies:
@@ -5372,13 +5380,16 @@ snapshots:
'@octokit/request': 10.0.7 '@octokit/request': 10.0.7
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
universal-user-agent: 7.0.3 universal-user-agent: 7.0.3
optional: true
'@octokit/auth-token@6.0.0': {} '@octokit/auth-token@6.0.0':
optional: true
'@octokit/auth-unauthenticated@7.0.3': '@octokit/auth-unauthenticated@7.0.3':
dependencies: dependencies:
'@octokit/request-error': 7.1.0 '@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
optional: true
'@octokit/core@7.0.6': '@octokit/core@7.0.6':
dependencies: dependencies:
@@ -5389,17 +5400,20 @@ snapshots:
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
before-after-hook: 4.0.0 before-after-hook: 4.0.0
universal-user-agent: 7.0.3 universal-user-agent: 7.0.3
optional: true
'@octokit/endpoint@11.0.2': '@octokit/endpoint@11.0.2':
dependencies: dependencies:
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
universal-user-agent: 7.0.3 universal-user-agent: 7.0.3
optional: true
'@octokit/graphql@9.0.3': '@octokit/graphql@9.0.3':
dependencies: dependencies:
'@octokit/request': 10.0.7 '@octokit/request': 10.0.7
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
universal-user-agent: 7.0.3 universal-user-agent: 7.0.3
optional: true
'@octokit/oauth-app@8.0.3': '@octokit/oauth-app@8.0.3':
dependencies: dependencies:
@@ -5411,8 +5425,10 @@ snapshots:
'@octokit/oauth-methods': 6.0.2 '@octokit/oauth-methods': 6.0.2
'@types/aws-lambda': 8.10.159 '@types/aws-lambda': 8.10.159
universal-user-agent: 7.0.3 universal-user-agent: 7.0.3
optional: true
'@octokit/oauth-authorization-url@8.0.0': {} '@octokit/oauth-authorization-url@8.0.0':
optional: true
'@octokit/oauth-methods@6.0.2': '@octokit/oauth-methods@6.0.2':
dependencies: dependencies:
@@ -5420,24 +5436,30 @@ snapshots:
'@octokit/request': 10.0.7 '@octokit/request': 10.0.7
'@octokit/request-error': 7.1.0 '@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
optional: true
'@octokit/openapi-types@27.0.0': {} '@octokit/openapi-types@27.0.0':
optional: true
'@octokit/openapi-webhooks-types@12.1.0': {} '@octokit/openapi-webhooks-types@12.1.0':
optional: true
'@octokit/plugin-paginate-graphql@6.0.0(@octokit/core@7.0.6)': '@octokit/plugin-paginate-graphql@6.0.0(@octokit/core@7.0.6)':
dependencies: dependencies:
'@octokit/core': 7.0.6 '@octokit/core': 7.0.6
optional: true
'@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)':
dependencies: dependencies:
'@octokit/core': 7.0.6 '@octokit/core': 7.0.6
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
optional: true
'@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)':
dependencies: dependencies:
'@octokit/core': 7.0.6 '@octokit/core': 7.0.6
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
optional: true
'@octokit/plugin-retry@8.0.3(@octokit/core@7.0.6)': '@octokit/plugin-retry@8.0.3(@octokit/core@7.0.6)':
dependencies: dependencies:
@@ -5445,16 +5467,19 @@ snapshots:
'@octokit/request-error': 7.1.0 '@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
bottleneck: 2.19.5 bottleneck: 2.19.5
optional: true
'@octokit/plugin-throttling@11.0.3(@octokit/core@7.0.6)': '@octokit/plugin-throttling@11.0.3(@octokit/core@7.0.6)':
dependencies: dependencies:
'@octokit/core': 7.0.6 '@octokit/core': 7.0.6
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
bottleneck: 2.19.5 bottleneck: 2.19.5
optional: true
'@octokit/request-error@7.1.0': '@octokit/request-error@7.1.0':
dependencies: dependencies:
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
optional: true
'@octokit/request@10.0.7': '@octokit/request@10.0.7':
dependencies: dependencies:
@@ -5463,18 +5488,22 @@ snapshots:
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
fast-content-type-parse: 3.0.0 fast-content-type-parse: 3.0.0
universal-user-agent: 7.0.3 universal-user-agent: 7.0.3
optional: true
'@octokit/types@16.0.0': '@octokit/types@16.0.0':
dependencies: dependencies:
'@octokit/openapi-types': 27.0.0 '@octokit/openapi-types': 27.0.0
optional: true
'@octokit/webhooks-methods@6.0.0': {} '@octokit/webhooks-methods@6.0.0':
optional: true
'@octokit/webhooks@14.2.0': '@octokit/webhooks@14.2.0':
dependencies: dependencies:
'@octokit/openapi-webhooks-types': 12.1.0 '@octokit/openapi-webhooks-types': 12.1.0
'@octokit/request-error': 7.1.0 '@octokit/request-error': 7.1.0
'@octokit/webhooks-methods': 6.0.0 '@octokit/webhooks-methods': 6.0.0
optional: true
'@oxc-project/types@0.107.0': {} '@oxc-project/types@0.107.0': {}
@@ -6111,7 +6140,8 @@ snapshots:
'@thi.ng/errors@2.6.0': '@thi.ng/errors@2.6.0':
optional: true optional: true
'@tinyhttp/content-disposition@2.2.2': {} '@tinyhttp/content-disposition@2.2.2':
optional: true
'@tokenizer/inflate@0.4.1': '@tokenizer/inflate@0.4.1':
dependencies: dependencies:
@@ -6127,7 +6157,8 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/aws-lambda@8.10.159': {} '@types/aws-lambda@8.10.159':
optional: true
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
dependencies: dependencies:
@@ -6426,7 +6457,8 @@ snapshots:
another-json@0.2.0: {} another-json@0.2.0: {}
ansi-escapes@6.2.1: {} ansi-escapes@6.2.1:
optional: true
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
@@ -6445,12 +6477,14 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.1 picomatch: 2.3.1
aproba@2.1.0: {} aproba@2.1.0:
optional: true
are-we-there-yet@3.0.1: are-we-there-yet@3.0.1:
dependencies: dependencies:
delegates: 1.0.0 delegates: 1.0.0
readable-stream: 3.6.2 readable-stream: 3.6.2
optional: true
argparse@2.0.1: {} argparse@2.0.1: {}
@@ -6469,6 +6503,7 @@ snapshots:
async-retry@1.3.3: async-retry@1.3.3:
dependencies: dependencies:
retry: 0.13.1 retry: 0.13.1
optional: true
asynckit@0.4.0: {} asynckit@0.4.0: {}
@@ -6508,7 +6543,8 @@ snapshots:
base64-js@1.5.1: {} base64-js@1.5.1: {}
before-after-hook@4.0.0: {} before-after-hook@4.0.0:
optional: true
bignumber.js@9.3.1: {} bignumber.js@9.3.1: {}
@@ -6593,7 +6629,8 @@ snapshots:
chalk@5.6.2: {} chalk@5.6.2: {}
chmodrp@1.0.2: {} chmodrp@1.0.2:
optional: true
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
@@ -6611,7 +6648,8 @@ snapshots:
dependencies: dependencies:
readdirp: 5.0.0 readdirp: 5.0.0
chownr@2.0.0: {} chownr@2.0.0:
optional: true
chownr@3.0.0: {} chownr@3.0.0: {}
@@ -6621,7 +6659,8 @@ snapshots:
mitt: 3.0.1 mitt: 3.0.1
zod: 3.25.76 zod: 3.25.76
ci-info@4.3.1: {} ci-info@4.3.1:
optional: true
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
dependencies: dependencies:
@@ -6630,6 +6669,7 @@ snapshots:
cli-cursor@5.0.0: cli-cursor@5.0.0:
dependencies: dependencies:
restore-cursor: 5.1.0 restore-cursor: 5.1.0
optional: true
cli-highlight@2.1.11: cli-highlight@2.1.11:
dependencies: dependencies:
@@ -6640,7 +6680,8 @@ snapshots:
parse5-htmlparser2-tree-adapter: 6.0.1 parse5-htmlparser2-tree-adapter: 6.0.1
yargs: 16.2.0 yargs: 16.2.0
cli-spinners@2.9.2: {} cli-spinners@2.9.2:
optional: true
cliui@7.0.4: cliui@7.0.4:
dependencies: dependencies:
@@ -6653,6 +6694,7 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 strip-ansi: 6.0.1
wrap-ansi: 7.0.0 wrap-ansi: 7.0.0
optional: true
clsx@2.1.1: {} clsx@2.1.1: {}
@@ -6672,6 +6714,7 @@ snapshots:
yargs: 17.7.2 yargs: 17.7.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
optional: true
codec-parser@2.5.0: codec-parser@2.5.0:
optional: true optional: true
@@ -6684,19 +6727,22 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
color-support@1.1.3: {} color-support@1.1.3:
optional: true
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
commander@10.0.1: {} commander@10.0.1:
optional: true
commander@14.0.2: {} commander@14.0.2: {}
commander@8.3.0: {} commander@8.3.0: {}
console-control-strings@1.1.0: {} console-control-strings@1.1.0:
optional: true
content-disposition@1.0.1: {} content-disposition@1.0.1: {}
@@ -6730,11 +6776,13 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
deep-extend@0.6.0: {} deep-extend@0.6.0:
optional: true
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
delegates@1.0.0: {} delegates@1.0.0:
optional: true
depd@2.0.0: {} depd@2.0.0: {}
@@ -6772,7 +6820,8 @@ snapshots:
ee-first@1.1.1: {} ee-first@1.1.1: {}
emoji-regex@10.6.0: {} emoji-regex@10.6.0:
optional: true
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@@ -6782,7 +6831,8 @@ snapshots:
entities@4.5.0: {} entities@4.5.0: {}
env-var@7.5.0: {} env-var@7.5.0:
optional: true
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
@@ -6885,7 +6935,8 @@ snapshots:
extend@3.0.2: {} extend@3.0.2: {}
fast-content-type-parse@3.0.0: {} fast-content-type-parse@3.0.0:
optional: true
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
@@ -6925,11 +6976,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
filename-reserved-regex@3.0.0: {} filename-reserved-regex@3.0.0:
optional: true
filenamify@6.0.0: filenamify@6.0.0:
dependencies: dependencies:
filename-reserved-regex: 3.0.0 filename-reserved-regex: 3.0.0
optional: true
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
@@ -6976,10 +7029,12 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonfile: 6.2.0 jsonfile: 6.2.0
universalify: 2.0.1 universalify: 2.0.1
optional: true
fs-minipass@2.1.0: fs-minipass@2.1.0:
dependencies: dependencies:
minipass: 3.3.6 minipass: 3.3.6
optional: true
fsevents@2.3.2: fsevents@2.3.2:
optional: true optional: true
@@ -6999,6 +7054,7 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 strip-ansi: 6.0.1
wide-align: 1.1.5 wide-align: 1.1.5
optional: true
gaxios@7.1.3: gaxios@7.1.3:
dependencies: dependencies:
@@ -7108,7 +7164,8 @@ snapshots:
dependencies: dependencies:
has-symbols: 1.1.0 has-symbols: 1.1.0
has-unicode@2.0.1: {} has-unicode@2.0.1:
optional: true
hashery@1.4.0: hashery@1.4.0:
dependencies: dependencies:
@@ -7158,13 +7215,15 @@ snapshots:
ieee754@1.2.1: {} ieee754@1.2.1: {}
ignore@7.0.5: {} ignore@7.0.5:
optional: true
immediate@3.0.6: {} immediate@3.0.6: {}
inherits@2.0.4: {} inherits@2.0.4: {}
ini@1.3.8: {} ini@1.3.8:
optional: true
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
@@ -7191,6 +7250,7 @@ snapshots:
strip-ansi: 7.1.2 strip-ansi: 7.1.2
optionalDependencies: optionalDependencies:
'@reflink/reflink': 0.1.19 '@reflink/reflink': 0.1.19
optional: true
is-binary-path@2.1.0: is-binary-path@2.1.0:
dependencies: dependencies:
@@ -7205,12 +7265,14 @@ snapshots:
is-fullwidth-code-point@5.1.0: is-fullwidth-code-point@5.1.0:
dependencies: dependencies:
get-east-asian-width: 1.4.0 get-east-asian-width: 1.4.0
optional: true
is-glob@4.0.3: is-glob@4.0.3:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
is-interactive@2.0.0: {} is-interactive@2.0.0:
optional: true
is-network-error@1.3.0: {} is-network-error@1.3.0: {}
@@ -7220,9 +7282,11 @@ snapshots:
is-stream@2.0.1: {} is-stream@2.0.1: {}
is-unicode-supported@1.3.0: {} is-unicode-supported@1.3.0:
optional: true
is-unicode-supported@2.1.0: {} is-unicode-supported@2.1.0:
optional: true
is-url@1.2.4: {} is-url@1.2.4: {}
@@ -7230,7 +7294,8 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
isexe@3.1.1: {} isexe@3.1.1:
optional: true
istanbul-lib-coverage@3.2.2: {} istanbul-lib-coverage@3.2.2: {}
@@ -7283,6 +7348,7 @@ snapshots:
universalify: 2.0.1 universalify: 2.0.1
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
optional: true
jsonwebtoken@9.0.3: jsonwebtoken@9.0.3:
dependencies: dependencies:
@@ -7340,9 +7406,11 @@ snapshots:
dependencies: dependencies:
immediate: 3.0.6 immediate: 3.0.6
lifecycle-utils@2.1.0: {} lifecycle-utils@2.1.0:
optional: true
lifecycle-utils@3.0.1: {} lifecycle-utils@3.0.1:
optional: true
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
optional: true optional: true
@@ -7418,7 +7486,8 @@ snapshots:
lodash.clonedeep@4.5.0: {} lodash.clonedeep@4.5.0: {}
lodash.debounce@4.0.8: {} lodash.debounce@4.0.8:
optional: true
lodash.includes@4.3.0: {} lodash.includes@4.3.0: {}
@@ -7440,11 +7509,13 @@ snapshots:
dependencies: dependencies:
chalk: 5.6.2 chalk: 5.6.2
is-unicode-supported: 1.3.0 is-unicode-supported: 1.3.0
optional: true
log-symbols@7.0.1: log-symbols@7.0.1:
dependencies: dependencies:
is-unicode-supported: 2.1.0 is-unicode-supported: 2.1.0
yoctocolors: 2.1.2 yoctocolors: 2.1.2
optional: true
loglevel@1.9.2: {} loglevel@1.9.2: {}
@@ -7455,6 +7526,7 @@ snapshots:
lowdb@7.0.1: lowdb@7.0.1:
dependencies: dependencies:
steno: 4.0.2 steno: 4.0.2
optional: true
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
@@ -7535,6 +7607,7 @@ snapshots:
memory-stream@1.0.0: memory-stream@1.0.0:
dependencies: dependencies:
readable-stream: 3.6.2 readable-stream: 3.6.2
optional: true
merge-descriptors@2.0.0: {} merge-descriptors@2.0.0: {}
@@ -7557,7 +7630,8 @@ snapshots:
dependencies: dependencies:
mime-db: 1.54.0 mime-db: 1.54.0
mimic-function@5.0.1: {} mimic-function@5.0.1:
optional: true
minimatch@10.1.1: minimatch@10.1.1:
dependencies: dependencies:
@@ -7567,13 +7641,16 @@ snapshots:
dependencies: dependencies:
brace-expansion: 2.0.2 brace-expansion: 2.0.2
minimist@1.2.8: {} minimist@1.2.8:
optional: true
minipass@3.3.6: minipass@3.3.6:
dependencies: dependencies:
yallist: 4.0.0 yallist: 4.0.0
optional: true
minipass@5.0.0: {} minipass@5.0.0:
optional: true
minipass@7.1.2: {} minipass@7.1.2: {}
@@ -7581,6 +7658,7 @@ snapshots:
dependencies: dependencies:
minipass: 3.3.6 minipass: 3.3.6
yallist: 4.0.0 yallist: 4.0.0
optional: true
minizlib@3.1.0: minizlib@3.1.0:
dependencies: dependencies:
@@ -7588,7 +7666,8 @@ snapshots:
mitt@3.0.1: {} mitt@3.0.1: {}
mkdirp@1.0.4: {} mkdirp@1.0.4:
optional: true
mpg123-decoder@1.0.3: mpg123-decoder@1.0.3:
dependencies: dependencies:
@@ -7621,13 +7700,16 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
nanoid@5.1.6: {} nanoid@5.1.6:
optional: true
negotiator@1.0.0: {} negotiator@1.0.0: {}
node-addon-api@8.5.0: {} node-addon-api@8.5.0:
optional: true
node-api-headers@1.7.0: {} node-api-headers@1.7.0:
optional: true
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
@@ -7689,6 +7771,7 @@ snapshots:
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
optional: true
node-wav@0.0.2: node-wav@0.0.2:
optional: true optional: true
@@ -7701,6 +7784,7 @@ snapshots:
console-control-strings: 1.1.0 console-control-strings: 1.1.0
gauge: 4.0.4 gauge: 4.0.4
set-blocking: 2.0.0 set-blocking: 2.0.0
optional: true
object-assign@4.1.1: {} object-assign@4.1.1: {}
@@ -7723,6 +7807,7 @@ snapshots:
'@octokit/request-error': 7.1.0 '@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0 '@octokit/types': 16.0.0
'@octokit/webhooks': 14.2.0 '@octokit/webhooks': 14.2.0
optional: true
ogg-opus-decoder@1.7.3: ogg-opus-decoder@1.7.3:
dependencies: dependencies:
@@ -7753,6 +7838,7 @@ snapshots:
onetime@7.0.0: onetime@7.0.0:
dependencies: dependencies:
mimic-function: 5.0.1 mimic-function: 5.0.1
optional: true
openai@6.10.0(ws@8.19.0)(zod@4.3.5): openai@6.10.0(ws@8.19.0)(zod@4.3.5):
optionalDependencies: optionalDependencies:
@@ -7775,6 +7861,7 @@ snapshots:
stdin-discarder: 0.2.2 stdin-discarder: 0.2.2
string-width: 7.2.0 string-width: 7.2.0
strip-ansi: 7.1.2 strip-ansi: 7.1.2
optional: true
osc-progress@0.2.0: {} osc-progress@0.2.0: {}
@@ -7845,9 +7932,11 @@ snapshots:
pako@1.0.11: {} pako@1.0.11: {}
parse-ms@3.0.0: {} parse-ms@3.0.0:
optional: true
parse-ms@4.0.0: {} parse-ms@4.0.0:
optional: true
parse5-htmlparser2-tree-adapter@6.0.1: parse5-htmlparser2-tree-adapter@6.0.1:
dependencies: dependencies:
@@ -7925,15 +8014,18 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
pretty-bytes@6.1.1: {} pretty-bytes@6.1.1:
optional: true
pretty-ms@8.0.0: pretty-ms@8.0.0:
dependencies: dependencies:
parse-ms: 3.0.0 parse-ms: 3.0.0
optional: true
pretty-ms@9.3.0: pretty-ms@9.3.0:
dependencies: dependencies:
parse-ms: 4.0.0 parse-ms: 4.0.0
optional: true
prism-media@1.3.5: prism-media@1.3.5:
optional: true optional: true
@@ -8043,6 +8135,7 @@ snapshots:
ini: 1.3.8 ini: 1.3.8
minimist: 1.2.8 minimist: 1.2.8
strip-json-comments: 2.0.1 strip-json-comments: 2.0.1
optional: true
readable-stream@2.3.8: readable-stream@2.3.8:
dependencies: dependencies:
@@ -8059,6 +8152,7 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
string_decoder: 1.3.0 string_decoder: 1.3.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
optional: true
readable-stream@4.5.2: readable-stream@4.5.2:
dependencies: dependencies:
@@ -8086,6 +8180,7 @@ snapshots:
dependencies: dependencies:
onetime: 7.0.0 onetime: 7.0.0
signal-exit: 4.1.0 signal-exit: 4.1.0
optional: true
retry@0.12.0: {} retry@0.12.0: {}
@@ -8198,7 +8293,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
set-blocking@2.0.0: {} set-blocking@2.0.0:
optional: true
setimmediate@1.0.5: {} setimmediate@1.0.5: {}
@@ -8288,6 +8384,7 @@ snapshots:
debug: 4.4.3 debug: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
optional: true
simple-yenc@1.0.4: simple-yenc@1.0.4:
optional: true optional: true
@@ -8300,12 +8397,14 @@ snapshots:
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
sleep-promise@9.1.0: {} sleep-promise@9.1.0:
optional: true
slice-ansi@7.1.2: slice-ansi@7.1.2:
dependencies: dependencies:
ansi-styles: 6.2.3 ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0 is-fullwidth-code-point: 5.1.0
optional: true
sonic-boom@4.2.0: sonic-boom@4.2.0:
dependencies: dependencies:
@@ -8328,7 +8427,8 @@ snapshots:
std-env@3.10.0: {} std-env@3.10.0: {}
stdin-discarder@0.2.2: {} stdin-discarder@0.2.2:
optional: true
stdout-update@4.0.1: stdout-update@4.0.1:
dependencies: dependencies:
@@ -8336,8 +8436,10 @@ snapshots:
ansi-styles: 6.2.3 ansi-styles: 6.2.3
string-width: 7.2.0 string-width: 7.2.0
strip-ansi: 7.1.2 strip-ansi: 7.1.2
optional: true
steno@4.0.2: {} steno@4.0.2:
optional: true
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
@@ -8356,6 +8458,7 @@ snapshots:
emoji-regex: 10.6.0 emoji-regex: 10.6.0
get-east-asian-width: 1.4.0 get-east-asian-width: 1.4.0
strip-ansi: 7.1.2 strip-ansi: 7.1.2
optional: true
string_decoder@1.1.1: string_decoder@1.1.1:
dependencies: dependencies:
@@ -8373,7 +8476,8 @@ snapshots:
dependencies: dependencies:
ansi-regex: 6.2.2 ansi-regex: 6.2.2
strip-json-comments@2.0.1: {} strip-json-comments@2.0.1:
optional: true
strnum@2.1.2: {} strnum@2.1.2: {}
@@ -8403,6 +8507,7 @@ snapshots:
minizlib: 2.1.2 minizlib: 2.1.2
mkdirp: 1.0.4 mkdirp: 1.0.4
yallist: 4.0.0 yallist: 4.0.0
optional: true
tar@7.5.2: tar@7.5.2:
dependencies: dependencies:
@@ -8443,7 +8548,8 @@ snapshots:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
toad-cache@3.7.0: {} toad-cache@3.7.0:
optional: true
toidentifier@1.0.1: {} toidentifier@1.0.1: {}
@@ -8504,17 +8610,21 @@ snapshots:
pako: 0.2.9 pako: 0.2.9
tiny-inflate: 1.0.3 tiny-inflate: 1.0.3
universal-github-app-jwt@2.2.2: {} universal-github-app-jwt@2.2.2:
optional: true
universal-user-agent@7.0.3: {} universal-user-agent@7.0.3:
optional: true
universalify@2.0.1: {} universalify@2.0.1:
optional: true
unpipe@1.0.0: {} unpipe@1.0.0: {}
urijs@1.19.11: {} urijs@1.19.11: {}
url-join@4.0.1: {} url-join@4.0.1:
optional: true
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
@@ -8524,7 +8634,8 @@ snapshots:
uuid@8.3.2: {} uuid@8.3.2: {}
validate-npm-package-name@6.0.2: {} validate-npm-package-name@6.0.2:
optional: true
vary@1.1.2: {} vary@1.1.2: {}
@@ -8602,6 +8713,7 @@ snapshots:
which@5.0.0: which@5.0.0:
dependencies: dependencies:
isexe: 3.1.1 isexe: 3.1.1
optional: true
why-is-node-running@2.3.0: why-is-node-running@2.3.0:
dependencies: dependencies:
@@ -8611,6 +8723,7 @@ snapshots:
wide-align@1.1.5: wide-align@1.1.5:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
optional: true
wireit@0.14.12: wireit@0.14.12:
dependencies: dependencies:
@@ -8651,7 +8764,8 @@ snapshots:
yargs-parser@20.2.9: {} yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1:
optional: true
yargs@16.2.0: yargs@16.2.0:
dependencies: dependencies:
@@ -8672,6 +8786,7 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
y18n: 5.0.8 y18n: 5.0.8
yargs-parser: 21.1.1 yargs-parser: 21.1.1
optional: true
yoctocolors@2.1.2: {} yoctocolors@2.1.2: {}
@@ -1,15 +1,22 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
const loadSessionStoreMock = vi.fn(); const loadSessionStoreMock = vi.fn();
const saveSessionStoreMock = vi.fn(); const updateSessionStoreMock = vi.fn();
vi.mock("../config/sessions.js", async (importOriginal) => { vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>(); const actual = await importOriginal<typeof import("../config/sessions.js")>();
return { return {
...actual, ...actual,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath), loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
saveSessionStore: (storePath: string, store: Record<string, unknown>) => updateSessionStore: async (
saveSessionStoreMock(storePath, store), storePath: string,
mutator: (store: Record<string, unknown>) => Promise<void> | void,
) => {
const store = loadSessionStoreMock(storePath) as Record<string, unknown>;
await mutator(store);
updateSessionStoreMock(storePath, store);
return store;
},
resolveStorePath: () => "/tmp/sessions.json", resolveStorePath: () => "/tmp/sessions.json",
}; };
}); });
@@ -73,7 +80,7 @@ import { createClawdbotTools } from "./clawdbot-tools.js";
describe("session_status tool", () => { describe("session_status tool", () => {
it("returns a status card for the current session", async () => { it("returns a status card for the current session", async () => {
loadSessionStoreMock.mockReset(); loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset(); updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({ loadSessionStoreMock.mockReturnValue({
main: { main: {
sessionId: "s1", sessionId: "s1",
@@ -96,7 +103,7 @@ describe("session_status tool", () => {
it("errors for unknown session keys", async () => { it("errors for unknown session keys", async () => {
loadSessionStoreMock.mockReset(); loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset(); updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({ loadSessionStoreMock.mockReturnValue({
main: { sessionId: "s1", updatedAt: 10 }, main: { sessionId: "s1", updatedAt: 10 },
}); });
@@ -110,12 +117,12 @@ describe("session_status tool", () => {
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow( await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
"Unknown sessionKey", "Unknown sessionKey",
); );
expect(saveSessionStoreMock).not.toHaveBeenCalled(); expect(updateSessionStoreMock).not.toHaveBeenCalled();
}); });
it("resets per-session model override via model=default", async () => { it("resets per-session model override via model=default", async () => {
loadSessionStoreMock.mockReset(); loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset(); updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({ loadSessionStoreMock.mockReturnValue({
main: { main: {
sessionId: "s1", sessionId: "s1",
@@ -133,8 +140,8 @@ describe("session_status tool", () => {
if (!tool) throw new Error("missing session_status tool"); if (!tool) throw new Error("missing session_status tool");
await tool.execute("call3", { model: "default" }); await tool.execute("call3", { model: "default" });
expect(saveSessionStoreMock).toHaveBeenCalled(); expect(updateSessionStoreMock).toHaveBeenCalled();
const [, savedStore] = saveSessionStoreMock.mock.calls.at(-1) as [ const [, savedStore] = updateSessionStoreMock.mock.calls.at(-1) as [
string, string,
Record<string, unknown>, Record<string, unknown>,
]; ];
@@ -34,17 +34,15 @@ describe("clawdbot-tools: subagents", () => {
}; };
}); });
it("sessions_spawn announces via agent.wait when lifecycle events are missing", async () => { it("sessions_spawn deletes session when cleanup=delete via agent.wait", async () => {
resetSubagentRegistryForTests(); resetSubagentRegistryForTests();
callGatewayMock.mockReset(); callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = []; const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0; let agentCallCount = 0;
let sendParams: { to?: string; channel?: string; message?: string } = {};
let deletedKey: string | undefined; let deletedKey: string | undefined;
let childRunId: string | undefined; let childRunId: string | undefined;
let childSessionKey: string | undefined; let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
const sessionLastAssistantText = new Map<string, string>();
callGatewayMock.mockImplementation(async (opts: unknown) => { callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown }; const request = opts as { method?: string; params?: unknown };
@@ -57,15 +55,12 @@ describe("clawdbot-tools: subagents", () => {
sessionKey?: string; sessionKey?: string;
channel?: string; channel?: string;
timeout?: number; timeout?: number;
lane?: string;
}; };
const message = params?.message ?? ""; // Only capture the first agent call (subagent spawn, not main agent trigger)
const sessionKey = params?.sessionKey ?? ""; if (params?.lane === "subagent") {
if (message === "Sub-agent announce step.") {
sessionLastAssistantText.set(sessionKey, "announce now");
} else {
childRunId = runId; childRunId = runId;
childSessionKey = sessionKey; childSessionKey = params?.sessionKey ?? "";
sessionLastAssistantText.set(sessionKey, "result");
expect(params?.channel).toBe("discord"); expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1); expect(params?.timeout).toBe(1);
} }
@@ -85,24 +80,6 @@ describe("clawdbot-tools: subagents", () => {
endedAt: 4000, endedAt: 4000,
}; };
} }
if (request.method === "chat.history") {
const params = request.params as { sessionKey?: string } | undefined;
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
return {
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
};
}
if (request.method === "send") {
const params = request.params as
| { to?: string; channel?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
channel: params?.channel,
message: params?.message,
};
return { messageId: "m-announce" };
}
if (request.method === "sessions.delete") { if (request.method === "sessions.delete") {
const params = request.params as { key?: string } | undefined; const params = request.params as { key?: string } | undefined;
deletedKey = params?.key; deletedKey = params?.key;
@@ -135,19 +112,24 @@ describe("clawdbot-tools: subagents", () => {
expect(childWait?.timeoutMs).toBe(1000); expect(childWait?.timeoutMs).toBe(1000);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
// Two agent calls: subagent spawn + main agent trigger
const agentCalls = calls.filter((call) => call.method === "agent"); const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2); expect(agentCalls).toHaveLength(2);
const second = agentCalls[1]?.params as
| { channel?: string; deliver?: boolean; lane?: string }
| undefined;
expect(second?.lane).toBe("nested");
expect(second?.deliver).toBe(false);
expect(second?.channel).toBe("webchat");
expect(sendParams.channel).toBe("discord"); // First call: subagent spawn
expect(sendParams.to).toBe("channel:req"); const first = agentCalls[0]?.params as { lane?: string } | undefined;
expect(sendParams.message ?? "").toContain("announce now"); expect(first?.lane).toBe("subagent");
expect(sendParams.message ?? "").toContain("Stats:");
// Second call: main agent trigger
const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined;
expect(second?.sessionKey).toBe("discord:group:req");
expect(second?.deliver).toBe(true);
// No direct send to external channel (main agent handles delivery)
const sendCalls = calls.filter((c) => c.method === "send");
expect(sendCalls.length).toBe(0);
// Session should be deleted
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
}); });
}); });
@@ -35,17 +35,15 @@ describe("clawdbot-tools: subagents", () => {
}; };
}); });
it("sessions_spawn announces back to the requester group channel", async () => { it("sessions_spawn runs cleanup via lifecycle events", async () => {
resetSubagentRegistryForTests(); resetSubagentRegistryForTests();
callGatewayMock.mockReset(); callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = []; const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0; let agentCallCount = 0;
let sendParams: { to?: string; channel?: string; message?: string } = {};
let deletedKey: string | undefined; let deletedKey: string | undefined;
let childRunId: string | undefined; let childRunId: string | undefined;
let childSessionKey: string | undefined; let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
const sessionLastAssistantText = new Map<string, string>();
callGatewayMock.mockImplementation(async (opts: unknown) => { callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown }; const request = opts as { method?: string; params?: unknown };
@@ -58,15 +56,12 @@ describe("clawdbot-tools: subagents", () => {
sessionKey?: string; sessionKey?: string;
channel?: string; channel?: string;
timeout?: number; timeout?: number;
lane?: string;
}; };
const message = params?.message ?? ""; // Only capture the first agent call (subagent spawn, not main agent trigger)
const sessionKey = params?.sessionKey ?? ""; if (params?.lane === "subagent") {
if (message === "Sub-agent announce step.") {
sessionLastAssistantText.set(sessionKey, "announce now");
} else {
childRunId = runId; childRunId = runId;
childSessionKey = sessionKey; childSessionKey = params?.sessionKey ?? "";
sessionLastAssistantText.set(sessionKey, "result");
expect(params?.channel).toBe("discord"); expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1); expect(params?.timeout).toBe(1);
} }
@@ -79,26 +74,8 @@ describe("clawdbot-tools: subagents", () => {
if (request.method === "agent.wait") { if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined; const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {}); waitCalls.push(params ?? {});
const status = params?.runId === childRunId ? "timeout" : "ok"; // Return "ok" with timing info for the child run
return { runId: params?.runId ?? "run-1", status }; return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "chat.history") {
const params = request.params as { sessionKey?: string } | undefined;
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
return {
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
};
}
if (request.method === "send") {
const params = request.params as
| { to?: string; channel?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
channel: params?.channel,
message: params?.message,
};
return { messageId: "m-announce" };
} }
if (request.method === "sessions.delete") { if (request.method === "sessions.delete") {
const params = request.params as { key?: string } | undefined; const params = request.params as { key?: string } | undefined;
@@ -141,8 +118,12 @@ describe("clawdbot-tools: subagents", () => {
const childWait = waitCalls.find((call) => call.runId === childRunId); const childWait = waitCalls.find((call) => call.runId === childRunId);
expect(childWait?.timeoutMs).toBe(1000); expect(childWait?.timeoutMs).toBe(1000);
// Two agent calls: subagent spawn + main agent trigger
const agentCalls = calls.filter((call) => call.method === "agent"); const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2); expect(agentCalls).toHaveLength(2);
// First call: subagent spawn
const first = agentCalls[0]?.params as const first = agentCalls[0]?.params as
| { | {
lane?: string; lane?: string;
@@ -156,17 +137,24 @@ describe("clawdbot-tools: subagents", () => {
expect(first?.channel).toBe("discord"); expect(first?.channel).toBe("discord");
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
const second = agentCalls[1]?.params as
| { channel?: string; deliver?: boolean; lane?: string }
| undefined;
expect(second?.lane).toBe("nested");
expect(second?.deliver).toBe(false);
expect(second?.channel).toBe("webchat");
expect(sendParams.channel).toBe("discord"); // Second call: main agent trigger with announce message
expect(sendParams.to).toBe("channel:req"); const second = agentCalls[1]?.params as
expect(sendParams.message ?? "").toContain("announce now"); | {
expect(sendParams.message ?? "").toContain("Stats:"); sessionKey?: string;
message?: string;
deliver?: boolean;
}
| undefined;
expect(second?.sessionKey).toBe("discord:group:req");
expect(second?.deliver).toBe(true);
expect(second?.message).toContain("background task");
// No direct send to external channel (main agent handles delivery)
const sendCalls = calls.filter((c) => c.method === "send");
expect(sendCalls.length).toBe(0);
// Session should be deleted since cleanup=delete
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
}); });
}); });
@@ -35,16 +35,15 @@ describe("clawdbot-tools: subagents", () => {
}; };
}); });
it("sessions_spawn resolves main announce target from sessions.list", async () => { it("sessions_spawn runs cleanup flow after subagent completion", async () => {
resetSubagentRegistryForTests(); resetSubagentRegistryForTests();
callGatewayMock.mockReset(); callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = []; const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0; let agentCallCount = 0;
let sendParams: { to?: string; channel?: string; message?: string } = {};
let childRunId: string | undefined; let childRunId: string | undefined;
let childSessionKey: string | undefined; let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
const sessionLastAssistantText = new Map<string, string>(); let patchParams: { key?: string; label?: string } = {};
callGatewayMock.mockImplementation(async (opts: unknown) => { callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown }; const request = opts as { method?: string; params?: unknown };
@@ -66,15 +65,12 @@ describe("clawdbot-tools: subagents", () => {
const params = request.params as { const params = request.params as {
message?: string; message?: string;
sessionKey?: string; sessionKey?: string;
lane?: string;
}; };
const message = params?.message ?? ""; // Only capture the first agent call (subagent spawn, not main agent trigger)
const sessionKey = params?.sessionKey ?? ""; if (params?.lane === "subagent") {
if (message === "Sub-agent announce step.") {
sessionLastAssistantText.set(sessionKey, "hello from sub");
} else {
childRunId = runId; childRunId = runId;
childSessionKey = sessionKey; childSessionKey = params?.sessionKey ?? "";
sessionLastAssistantText.set(sessionKey, "done");
} }
return { return {
runId, runId,
@@ -85,26 +81,12 @@ describe("clawdbot-tools: subagents", () => {
if (request.method === "agent.wait") { if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined; const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {}); waitCalls.push(params ?? {});
const status = params?.runId === childRunId ? "timeout" : "ok"; return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
return { runId: params?.runId ?? "run-1", status };
} }
if (request.method === "chat.history") { if (request.method === "sessions.patch") {
const params = request.params as { sessionKey?: string } | undefined; const params = request.params as { key?: string; label?: string } | undefined;
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? ""; patchParams = { key: params?.key, label: params?.label };
return { return { ok: true };
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
};
}
if (request.method === "send") {
const params = request.params as
| { to?: string; channel?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
channel: params?.channel,
message: params?.message,
};
return { messageId: "m1" };
} }
if (request.method === "sessions.delete") { if (request.method === "sessions.delete") {
return { ok: true }; return { ok: true };
@@ -121,6 +103,7 @@ describe("clawdbot-tools: subagents", () => {
const result = await tool.execute("call2", { const result = await tool.execute("call2", {
task: "do thing", task: "do thing",
runTimeoutSeconds: 1, runTimeoutSeconds: 1,
label: "my-task",
}); });
expect(result.details).toMatchObject({ expect(result.details).toMatchObject({
status: "accepted", status: "accepted",
@@ -144,12 +127,29 @@ describe("clawdbot-tools: subagents", () => {
const childWait = waitCalls.find((call) => call.runId === childRunId); const childWait = waitCalls.find((call) => call.runId === childRunId);
expect(childWait?.timeoutMs).toBe(1000); expect(childWait?.timeoutMs).toBe(1000);
expect(sendParams.channel).toBe("whatsapp"); // Cleanup should patch the label
expect(sendParams.to).toBe("+123"); expect(patchParams.key).toBe(childSessionKey);
expect(sendParams.message ?? "").toContain("hello from sub"); expect(patchParams.label).toBe("my-task");
expect(sendParams.message ?? "").toContain("Stats:");
// Two agent calls: subagent spawn + main agent trigger
const agentCalls = calls.filter((c) => c.method === "agent");
expect(agentCalls).toHaveLength(2);
// First call: subagent spawn
const first = agentCalls[0]?.params as { lane?: string } | undefined;
expect(first?.lane).toBe("subagent");
// Second call: main agent trigger (not "Sub-agent announce step." anymore)
const second = agentCalls[1]?.params as { sessionKey?: string; message?: string } | undefined;
expect(second?.sessionKey).toBe("main");
expect(second?.message).toContain("background task");
// No direct send to external channel (main agent handles delivery)
const sendCalls = calls.filter((c) => c.method === "send");
expect(sendCalls.length).toBe(0);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
}); });
it("sessions_spawn only allows same-agent by default", async () => { it("sessions_spawn only allows same-agent by default", async () => {
resetSubagentRegistryForTests(); resetSubagentRegistryForTests();
callGatewayMock.mockReset(); callGatewayMock.mockReset();
+4 -39
View File
@@ -9,6 +9,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { CliBackendConfig } from "../../config/types.js"; import type { CliBackendConfig } from "../../config/types.js";
import { runExec } from "../../process/exec.js"; import { runExec } from "../../process/exec.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildAgentSystemPrompt } from "../system-prompt.js"; import { buildAgentSystemPrompt } from "../system-prompt.js";
@@ -69,44 +70,6 @@ export type CliOutput = {
usage?: CliUsage; usage?: CliUsage;
}; };
function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
function formatUserTime(date: Date, timeZone: string): string | undefined {
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
weekday: "long",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
return undefined;
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
} catch {
return undefined;
}
}
function buildModelAliasLines(cfg?: ClawdbotConfig) { function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agents?.defaults?.models ?? {}; const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = []; const entries: Array<{ alias: string; model: string }> = [];
@@ -134,7 +97,8 @@ export function buildSystemPrompt(params: {
modelDisplay: string; modelDisplay: string;
}) { }) {
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTime = formatUserTime(new Date(), userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
return buildAgentSystemPrompt({ return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel, defaultThinkLevel: params.defaultThinkLevel,
@@ -153,6 +117,7 @@ export function buildSystemPrompt(params: {
modelAliasLines: buildModelAliasLines(params.config), modelAliasLines: buildModelAliasLines(params.config),
userTimezone, userTimezone,
userTime, userTime,
userTimeFormat,
contextFiles: params.contextFiles, contextFiles: params.contextFiles,
}); });
} }
+164
View File
@@ -0,0 +1,164 @@
import { execSync } from "node:child_process";
export type TimeFormatPreference = "auto" | "12" | "24";
export type ResolvedTimeFormat = "12" | "24";
let cachedTimeFormat: ResolvedTimeFormat | undefined;
export function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
export function resolveUserTimeFormat(preference?: TimeFormatPreference): ResolvedTimeFormat {
if (preference === "12" || preference === "24") return preference;
if (cachedTimeFormat) return cachedTimeFormat;
cachedTimeFormat = detectSystemTimeFormat() ? "24" : "12";
return cachedTimeFormat;
}
export function normalizeTimestamp(
raw: unknown,
): { timestampMs: number; timestampUtc: string } | undefined {
if (raw == null) return undefined;
let timestampMs: number | undefined;
if (raw instanceof Date) {
timestampMs = raw.getTime();
} else if (typeof raw === "number" && Number.isFinite(raw)) {
timestampMs = raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw);
} else if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) return undefined;
if (/^\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (Number.isFinite(num)) {
if (trimmed.includes(".")) {
timestampMs = Math.round(num * 1000);
} else if (trimmed.length >= 13) {
timestampMs = Math.round(num);
} else {
timestampMs = Math.round(num * 1000);
}
}
} else {
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) timestampMs = parsed;
}
}
if (timestampMs === undefined || !Number.isFinite(timestampMs)) return undefined;
return { timestampMs, timestampUtc: new Date(timestampMs).toISOString() };
}
export function withNormalizedTimestamp<T extends Record<string, unknown>>(
value: T,
rawTimestamp: unknown,
): T & { timestampMs?: number; timestampUtc?: string } {
const normalized = normalizeTimestamp(rawTimestamp);
if (!normalized) return value;
return {
...value,
timestampMs:
typeof value.timestampMs === "number" && Number.isFinite(value.timestampMs)
? value.timestampMs
: normalized.timestampMs,
timestampUtc:
typeof value.timestampUtc === "string" && value.timestampUtc.trim()
? value.timestampUtc
: normalized.timestampUtc,
};
}
function detectSystemTimeFormat(): boolean {
if (process.platform === "darwin") {
try {
const result = execSync("defaults read -g AppleICUForce24HourTime 2>/dev/null", {
encoding: "utf8",
timeout: 500,
}).trim();
if (result === "1") return true;
if (result === "0") return false;
} catch {
// Not set, fall through
}
}
if (process.platform === "win32") {
try {
const result = execSync(
'powershell -Command "(Get-Culture).DateTimeFormat.ShortTimePattern"',
{ encoding: "utf8", timeout: 1000 },
).trim();
if (result.startsWith("H")) return true;
if (result.startsWith("h")) return false;
} catch {
// Fall through
}
}
try {
const sample = new Date(2000, 0, 1, 13, 0);
const formatted = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).format(sample);
return formatted.includes("13");
} catch {
return false;
}
}
function ordinalSuffix(day: number): string {
if (day >= 11 && day <= 13) return "th";
switch (day % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}
export function formatUserTime(
date: Date,
timeZone: string,
format: ResolvedTimeFormat,
): string | undefined {
const use24Hour = format === "24";
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: use24Hour ? "2-digit" : "numeric",
minute: "2-digit",
hourCycle: use24Hour ? "h23" : "h12",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
return undefined;
const dayNum = parseInt(map.day, 10);
const suffix = ordinalSuffix(dayNum);
const timePart = use24Hour
? `${map.hour}:${map.minute}`
: `${map.hour}:${map.minute} ${map.dayPeriod ?? ""}`.trim();
return `${map.weekday}, ${map.month} ${dayNum}${suffix}, ${map.year} — ${timePart}`;
} catch {
return undefined;
}
}
+28
View File
@@ -213,6 +213,34 @@ describe("runWithModelFallback", () => {
expect(calls).toEqual([{ provider: "anthropic", model: "claude-opus-4-5" }]); expect(calls).toEqual([{ provider: "anthropic", model: "claude-opus-4-5" }]);
}); });
it("defaults provider/model when missing (regression #946)", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: [],
},
},
},
});
const calls: Array<{ provider: string; model: string }> = [];
const result = await runWithModelFallback({
cfg,
provider: undefined as unknown as string,
model: undefined as unknown as string,
run: async (provider, model) => {
calls.push({ provider, model });
return "ok";
},
});
expect(result.result).toBe("ok");
expect(calls).toEqual([{ provider: "openai", model: "gpt-4.1-mini" }]);
});
it("falls back on missing API key errors", async () => { it("falls back on missing API key errors", async () => {
const cfg = makeCfg(); const cfg = makeCfg();
const run = vi const run = vi
+7 -5
View File
@@ -119,8 +119,6 @@ function resolveFallbackCandidates(params: {
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */ /** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[]; fallbacksOverride?: string[];
}): ModelCandidate[] { }): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
const primary = params.cfg const primary = params.cfg
? resolveConfiguredModelRef({ ? resolveConfiguredModelRef({
cfg: params.cfg, cfg: params.cfg,
@@ -128,11 +126,15 @@ function resolveFallbackCandidates(params: {
defaultModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL,
}) })
: null; : null;
const defaultProvider = primary?.provider ?? DEFAULT_PROVIDER;
const defaultModel = primary?.model ?? DEFAULT_MODEL;
const provider = String(params.provider ?? "").trim() || defaultProvider;
const model = String(params.model ?? "").trim() || defaultModel;
const aliasIndex = buildModelAliasIndex({ const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {}, cfg: params.cfg ?? {},
defaultProvider: DEFAULT_PROVIDER, defaultProvider,
}); });
const allowlist = buildAllowedModelKeys(params.cfg, DEFAULT_PROVIDER); const allowlist = buildAllowedModelKeys(params.cfg, defaultProvider);
const seen = new Set<string>(); const seen = new Set<string>();
const candidates: ModelCandidate[] = []; const candidates: ModelCandidate[] = [];
@@ -160,7 +162,7 @@ function resolveFallbackCandidates(params: {
for (const raw of modelFallbacks) { for (const raw of modelFallbacks) {
const resolved = resolveModelRefFromString({ const resolved = resolveModelRefFromString({
raw: String(raw ?? ""), raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER, defaultProvider,
aliasIndex, aliasIndex,
}); });
if (!resolved) continue; if (!resolved) continue;
+5
View File
@@ -8,6 +8,11 @@ export function isGoogleModelApi(api?: string | null): boolean {
); );
} }
export function isAntigravityClaude(api?: string | null, modelId?: string): boolean {
if (api !== "google-antigravity") return false;
return modelId?.toLowerCase().includes("claude") ?? false;
}
export { sanitizeGoogleTurnOrdering }; export { sanitizeGoogleTurnOrdering };
/** /**
+9 -2
View File
@@ -30,7 +30,11 @@ function isEmptyAssistantErrorMessage(
export async function sanitizeSessionMessagesImages( export async function sanitizeSessionMessagesImages(
messages: AgentMessage[], messages: AgentMessage[],
label: string, label: string,
options?: { sanitizeToolCallIds?: boolean; enforceToolCallLast?: boolean }, options?: {
sanitizeToolCallIds?: boolean;
enforceToolCallLast?: boolean;
preserveSignatures?: boolean;
},
): Promise<AgentMessage[]> { ): Promise<AgentMessage[]> {
// We sanitize historical session messages because Anthropic can reject a request // We sanitize historical session messages because Anthropic can reject a request
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX). // if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
@@ -76,7 +80,10 @@ export async function sanitizeSessionMessagesImages(
} }
const content = assistantMsg.content; const content = assistantMsg.content;
if (Array.isArray(content)) { if (Array.isArray(content)) {
const strippedContent = stripThoughtSignatures(content); const strippedContent = options?.preserveSignatures
? content // Keep signatures for Antigravity Claude
: stripThoughtSignatures(content); // Strip for Gemini
const filteredContent = strippedContent.filter((block) => { const filteredContent = strippedContent.filter((block) => {
if (!block || typeof block !== "object") return true; if (!block || typeof block !== "object") return true;
const rec = block as { type?: unknown; text?: unknown }; const rec = block as { type?: unknown; text?: unknown };
@@ -59,6 +59,34 @@ describe("sanitizeSessionHistory (google thinking)", () => {
expect(assistant.content?.[0]?.thinkingSignature).toBe("sig"); expect(assistant.content?.[0]?.thinkingSignature).toBe("sig");
}); });
it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
modelId: "anthropic/claude-3.5-sonnet",
sessionManager,
sessionId: "session:antigravity-claude",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string }>;
};
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
});
it("preserves order when downgrading mixed assistant content", async () => { it("preserves order when downgrading mixed assistant content", async () => {
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const input = [ const input = [
+9 -8
View File
@@ -10,6 +10,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { getMachineDisplayName } from "../../infra/machine-name.js"; import { getMachineDisplayName } from "../../infra/machine-name.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { resolveUserPath } from "../../utils.js"; import { resolveUserPath } from "../../utils.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js";
@@ -53,13 +54,8 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-prompt.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-prompt.js";
import { splitSdkTools } from "./tool-split.js"; import { splitSdkTools } from "./tool-split.js";
import type { EmbeddedPiCompactResult } from "./types.js"; import type { EmbeddedPiCompactResult } from "./types.js";
import { import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
describeUnknownError, import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
formatUserTime,
mapThinkingLevel,
resolveExecToolDefaults,
resolveUserTimezone,
} from "./utils.js";
export async function compactEmbeddedPiSession(params: { export async function compactEmbeddedPiSession(params: {
sessionId: string; sessionId: string;
@@ -228,12 +224,14 @@ export async function compactEmbeddedPiSession(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated); const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider); const reasoningTagHint = isReasoningTagProvider(provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTime = formatUserTime(new Date(), userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
config: params.config, config: params.config,
}); });
const isDefaultAgent = sessionAgentId === defaultAgentId; const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const appendPrompt = buildEmbeddedSystemPrompt({ const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel, defaultThinkLevel: params.thinkLevel,
@@ -245,12 +243,14 @@ export async function compactEmbeddedPiSession(params: {
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined, : undefined,
skillsPrompt, skillsPrompt,
promptMode,
runtimeInfo, runtimeInfo,
sandboxInfo, sandboxInfo,
tools, tools,
modelAliasLines: buildModelAliasLines(params.config), modelAliasLines: buildModelAliasLines(params.config),
userTimezone, userTimezone,
userTime, userTime,
userTimeFormat,
contextFiles, contextFiles,
}); });
const systemPrompt = createSystemPromptOverride(appendPrompt); const systemPrompt = createSystemPromptOverride(appendPrompt);
@@ -302,6 +302,7 @@ export async function compactEmbeddedPiSession(params: {
const prior = await sanitizeSessionHistory({ const prior = await sanitizeSessionHistory({
messages: session.messages, messages: session.messages,
modelApi: model.api, modelApi: model.api,
modelId,
sessionManager, sessionManager,
sessionId: params.sessionId, sessionId: params.sessionId,
}); });
+7 -2
View File
@@ -13,6 +13,7 @@ import {
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js"; import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
import { log } from "./logger.js"; import { log } from "./logger.js";
import { describeUnknownError } from "./utils.js"; import { describeUnknownError } from "./utils.js";
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap"; const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
@@ -152,19 +153,23 @@ export function applyGoogleTurnOrderingFix(params: {
export async function sanitizeSessionHistory(params: { export async function sanitizeSessionHistory(params: {
messages: AgentMessage[]; messages: AgentMessage[];
modelApi?: string | null; modelApi?: string | null;
modelId?: string;
sessionManager: SessionManager; sessionManager: SessionManager;
sessionId: string; sessionId: string;
}): Promise<AgentMessage[]> { }): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", { const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi), sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
enforceToolCallLast: params.modelApi === "anthropic-messages", enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
}); });
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages); const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel;
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT. // Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
const downgradedThinking = isGoogleModelApi(params.modelApi) const downgradedThinking = shouldDowngradeGemini
? downgradeGeminiThinkingBlocks(repairedTools) ? downgradeGeminiThinkingBlocks(repairedTools)
: repairedTools; : repairedTools;
const downgraded = isGoogleModelApi(params.modelApi) const downgraded = shouldDowngradeGemini
? downgradeGeminiHistory(downgradedThinking) ? downgradeGeminiHistory(downgradedThinking)
: downgradedThinking; : downgradedThinking;
+22 -7
View File
@@ -9,8 +9,10 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveUserPath } from "../../../utils.js"; import { resolveUserPath } from "../../../utils.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js"; import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js"; import { resolveSessionAgentIds } from "../../agent-scope.js";
@@ -57,12 +59,8 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage
import { prepareSessionManagerForRun } from "../session-manager-init.js"; import { prepareSessionManagerForRun } from "../session-manager-init.js";
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
import { splitSdkTools } from "../tool-split.js"; import { splitSdkTools } from "../tool-split.js";
import { import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
formatUserTime, import { mapThinkingLevel, resolveExecToolDefaults } from "../utils.js";
mapThinkingLevel,
resolveExecToolDefaults,
resolveUserTimezone,
} from "../utils.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
@@ -161,6 +159,17 @@ export async function runEmbeddedAttempt(
accountId: params.agentAccountId, accountId: params.agentAccountId,
}) ?? []) }) ?? [])
: undefined; : undefined;
const reactionGuidance =
runtimeChannel === "telegram" && params.config
? (() => {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
})()
: undefined;
const runtimeInfo = { const runtimeInfo = {
host: machineName, host: machineName,
os: `${os.type()} ${os.release()}`, os: `${os.type()} ${os.release()}`,
@@ -174,12 +183,14 @@ export async function runEmbeddedAttempt(
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated); const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(params.provider); const reasoningTagHint = isReasoningTagProvider(params.provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTime = formatUserTime(new Date(), userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
config: params.config, config: params.config,
}); });
const isDefaultAgent = sessionAgentId === defaultAgentId; const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const appendPrompt = buildEmbeddedSystemPrompt({ const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,
@@ -192,12 +203,15 @@ export async function runEmbeddedAttempt(
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined, : undefined,
skillsPrompt, skillsPrompt,
reactionGuidance,
promptMode,
runtimeInfo, runtimeInfo,
sandboxInfo, sandboxInfo,
tools, tools,
modelAliasLines: buildModelAliasLines(params.config), modelAliasLines: buildModelAliasLines(params.config),
userTimezone, userTimezone,
userTime, userTime,
userTimeFormat,
contextFiles, contextFiles,
}); });
const systemPromptReport = buildSystemPromptReport({ const systemPromptReport = buildSystemPromptReport({
@@ -303,6 +317,7 @@ export async function runEmbeddedAttempt(
const prior = await sanitizeSessionHistory({ const prior = await sanitizeSessionHistory({
messages: activeSession.messages, messages: activeSession.messages,
modelApi: params.model.api, modelApi: params.model.api,
modelId: params.modelId,
sessionManager, sessionManager,
sessionId: params.sessionId, sessionId: params.sessionId,
}); });
+12 -1
View File
@@ -1,6 +1,7 @@
import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ResolvedTimeFormat } from "../date-time.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildAgentSystemPrompt } from "../system-prompt.js"; import { buildAgentSystemPrompt, type PromptMode } from "../system-prompt.js";
import { buildToolSummaryMap } from "../tool-summaries.js"; import { buildToolSummaryMap } from "../tool-summaries.js";
import type { EmbeddedSandboxInfo } from "./types.js"; import type { EmbeddedSandboxInfo } from "./types.js";
import type { ReasoningLevel, ThinkLevel } from "./utils.js"; import type { ReasoningLevel, ThinkLevel } from "./utils.js";
@@ -14,6 +15,12 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: boolean; reasoningTagHint: boolean;
heartbeatPrompt?: string; heartbeatPrompt?: string;
skillsPrompt?: string; skillsPrompt?: string;
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
};
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo: { runtimeInfo: {
host: string; host: string;
os: string; os: string;
@@ -29,6 +36,7 @@ export function buildEmbeddedSystemPrompt(params: {
modelAliasLines: string[]; modelAliasLines: string[];
userTimezone: string; userTimezone: string;
userTime?: string; userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[]; contextFiles?: EmbeddedContextFile[];
}): string { }): string {
return buildAgentSystemPrompt({ return buildAgentSystemPrompt({
@@ -40,6 +48,8 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: params.reasoningTagHint, reasoningTagHint: params.reasoningTagHint,
heartbeatPrompt: params.heartbeatPrompt, heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt, skillsPrompt: params.skillsPrompt,
reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo, runtimeInfo: params.runtimeInfo,
sandboxInfo: params.sandboxInfo, sandboxInfo: params.sandboxInfo,
toolNames: params.tools.map((tool) => tool.name), toolNames: params.tools.map((tool) => tool.name),
@@ -47,6 +57,7 @@ export function buildEmbeddedSystemPrompt(params: {
modelAliasLines: params.modelAliasLines, modelAliasLines: params.modelAliasLines,
userTimezone: params.userTimezone, userTimezone: params.userTimezone,
userTime: params.userTime, userTime: params.userTime,
userTimeFormat: params.userTimeFormat,
contextFiles: params.contextFiles, contextFiles: params.contextFiles,
}); });
} }
-39
View File
@@ -17,45 +17,6 @@ export function resolveExecToolDefaults(config?: ClawdbotConfig): ExecToolDefaul
return { ...tools.bash, ...tools.exec }; return { ...tools.bash, ...tools.exec };
} }
export function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
export function formatUserTime(date: Date, timeZone: string): string | undefined {
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
weekday: "long",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute) {
return undefined;
}
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
} catch {
return undefined;
}
}
export function describeUnknownError(error: unknown): string { export function describeUnknownError(error: unknown): string {
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
if (typeof error === "string") return error; if (typeof error === "string") return error;
+12
View File
@@ -5,10 +5,22 @@ import type { SandboxToolPolicy } from "./sandbox.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js"; import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
const DEFAULT_SUBAGENT_TOOL_DENY = [ const DEFAULT_SUBAGENT_TOOL_DENY = [
// Session management - main agent orchestrates
"sessions_list", "sessions_list",
"sessions_history", "sessions_history",
"sessions_send", "sessions_send",
"sessions_spawn", "sessions_spawn",
// System admin - dangerous from subagent
"gateway",
"agents_list",
// Interactive setup - not a task
"whatsapp_login",
// Status/scheduling - main agent coordinates
"session_status",
"cron",
// Memory - pass relevant info in spawn prompt instead
"memory_search",
"memory_get",
]; ];
export function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy { export function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
+23 -37
View File
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const sendSpy = vi.fn(async () => ({})); const agentSpy = vi.fn(async () => ({ runId: "run-main", status: "ok" }));
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async (req: unknown) => { callGateway: vi.fn(async (req: unknown) => {
const typed = req as { method?: string; params?: { message?: string } }; const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } };
if (typed.method === "send") { if (typed.method === "agent") {
return await sendSpy(typed); return await agentSpy(typed);
} }
if (typed.method === "agent.wait") { if (typed.method === "agent.wait") {
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" }; return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
@@ -18,24 +18,11 @@ vi.mock("../gateway/call.js", () => ({
})); }));
vi.mock("./tools/agent-step.js", () => ({ vi.mock("./tools/agent-step.js", () => ({
runAgentStep: vi.fn(async () => "did some stuff"),
readLatestAssistantReply: vi.fn(async () => "raw subagent reply"), readLatestAssistantReply: vi.fn(async () => "raw subagent reply"),
})); }));
vi.mock("./tools/sessions-announce-target.js", () => ({
resolveAnnounceTarget: vi.fn(async () => ({
provider: "telegram",
to: "+15550001111",
accountId: "default",
})),
}));
vi.mock("./tools/sessions-send-helpers.js", () => ({
isAnnounceSkip: () => false,
}));
vi.mock("../config/sessions.js", () => ({ vi.mock("../config/sessions.js", () => ({
loadSessionStore: vi.fn(async () => ({ entries: {} })), loadSessionStore: vi.fn(() => ({})),
resolveAgentIdFromSessionKey: () => "main", resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions.json", resolveStorePath: () => "/tmp/sessions.json",
})); }));
@@ -48,10 +35,10 @@ vi.mock("../config/config.js", () => ({
describe("subagent announce formatting", () => { describe("subagent announce formatting", () => {
beforeEach(() => { beforeEach(() => {
sendSpy.mockClear(); agentSpy.mockClear();
}); });
it("wraps unstructured announce into Status/Result/Notes", async () => { it("sends instructional message to main agent with status and findings", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
await runSubagentAnnounceFlow({ await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test", childSessionKey: "agent:main:subagent:test",
@@ -66,22 +53,21 @@ describe("subagent announce formatting", () => {
endedAt: 20, endedAt: 20,
}); });
expect(sendSpy).toHaveBeenCalled(); expect(agentSpy).toHaveBeenCalled();
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string; const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string; sessionKey?: string } };
expect(msg).toContain("Status:"); const msg = call?.params?.message as string;
expect(msg).toContain("Status: error"); expect(call?.params?.sessionKey).toBe("agent:main:main");
expect(msg).toContain("Result:"); expect(msg).toContain("background task");
expect(msg).toContain("Notes:"); expect(msg).toContain("failed");
expect(msg).toContain("boom"); expect(msg).toContain("boom");
expect(msg).toContain("Findings:");
expect(msg).toContain("raw subagent reply");
expect(msg).toContain("Stats:");
}); });
it("keeps runtime status even when announce reply is structured", async () => { it("includes success status when outcome is ok", async () => {
const agentStep = await import("./tools/agent-step.js");
vi.mocked(agentStep.runAgentStep).mockResolvedValueOnce(
"- **Status:** success\n\n- **Result:** did some stuff\n\n- **Notes:** all good",
);
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
// Use waitForCompletion: false so it uses the provided outcome instead of calling agent.wait
await runSubagentAnnounceFlow({ await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test", childSessionKey: "agent:main:subagent:test",
childRunId: "run-456", childRunId: "run-456",
@@ -90,14 +76,14 @@ describe("subagent announce formatting", () => {
task: "do thing", task: "do thing",
timeoutMs: 1000, timeoutMs: 1000,
cleanup: "keep", cleanup: "keep",
waitForCompletion: true, waitForCompletion: false,
startedAt: 10, startedAt: 10,
endedAt: 20, endedAt: 20,
outcome: { status: "ok" },
}); });
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string; const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
expect(msg).toContain("Status: error"); const msg = call?.params?.message as string;
expect(msg).toContain("Result:"); expect(msg).toContain("completed successfully");
expect(msg).toContain("Notes:");
}); });
}); });
+44 -158
View File
@@ -8,11 +8,7 @@ import {
resolveStorePath, resolveStorePath,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js"; import { callGateway } from "../gateway/call.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; import { readLatestAssistantReply } from "./tools/agent-step.js";
import { AGENT_LANE_NESTED } from "./lanes.js";
import { readLatestAssistantReply, runAgentStep } from "./tools/agent-step.js";
import { resolveAnnounceTarget } from "./tools/sessions-announce-target.js";
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
function formatDurationShort(valueMs?: number) { function formatDurationShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return undefined; if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return undefined;
@@ -149,27 +145,27 @@ export function buildSubagentSystemPrompt(params: {
"", "",
"## Your Role", "## Your Role",
`- You were created to handle: ${taskText}`, `- You were created to handle: ${taskText}`,
"- Complete this task and report back. That's your entire purpose.", "- Complete this task. That's your entire purpose.",
"- You are NOT the main agent. Don't try to be.", "- You are NOT the main agent. Don't try to be.",
"", "",
"## Rules", "## Rules",
"1. **Stay focused** - Do your assigned task, nothing else", "1. **Stay focused** - Do your assigned task, nothing else",
"2. **Report completion** - When done, summarize results clearly", "2. **Complete the task** - Your final message will be automatically reported to the main agent",
"3. **Don't initiate** - No heartbeats, no proactive actions, no side quests", "3. **Don't initiate** - No heartbeats, no proactive actions, no side quests",
"4. **Ask the spawner** - If blocked or confused, report back rather than improvising", "4. **Be ephemeral** - You may be terminated after task completion. That's fine.",
"5. **Be ephemeral** - You may be terminated after task completion. That's fine.", "",
"## Output Format",
"When complete, your final response should include:",
"- What you accomplished or found",
"- Any relevant details the main agent should know",
"- Keep it concise but informative",
"", "",
"## What You DON'T Do", "## What You DON'T Do",
"- NO user conversations (that's main agent's job)", "- NO user conversations (that's main agent's job)",
"- NO external messages (email, tweets, etc.) unless explicitly tasked", "- NO external messages (email, tweets, etc.) unless explicitly tasked",
"- NO cron jobs or persistent state", "- NO cron jobs or persistent state",
"- NO pretending to be the main agent", "- NO pretending to be the main agent",
"", "- NO using the `message` tool directly",
"## Output Format",
"When complete, respond with:",
"- **Status:** success | failed | blocked",
"- **Result:** [what you accomplished]",
"- **Notes:** [anything the main agent should know] - discuss gimme options",
"", "",
"## Session Context", "## Session Context",
params.label ? `- Label: ${params.label}` : undefined, params.label ? `- Label: ${params.label}` : undefined,
@@ -177,8 +173,6 @@ export function buildSubagentSystemPrompt(params: {
params.requesterChannel ? `- Requester channel: ${params.requesterChannel}.` : undefined, params.requesterChannel ? `- Requester channel: ${params.requesterChannel}.` : undefined,
`- Your session: ${params.childSessionKey}.`, `- Your session: ${params.childSessionKey}.`,
"", "",
"Run the task. Provide a clear final answer (plain text).",
'After you finish, you may be asked to produce an "announce" message to post back to the requester chat.',
].filter((line): line is string => line !== undefined); ].filter((line): line is string => line !== undefined);
return lines.join("\n"); return lines.join("\n");
} }
@@ -188,109 +182,6 @@ export type SubagentRunOutcome = {
error?: string; error?: string;
}; };
const ANNOUNCE_SECTION_RE = /^\s*[-*]?\s*(?:\*\*)?(status|result|notes)(?:\*\*)?\s*:\s*(.*)$/i;
function parseAnnounceSections(announce: string) {
const sections = {
status: [] as string[],
result: [] as string[],
notes: [] as string[],
};
let current: keyof typeof sections | null = null;
let sawSection = false;
for (const line of announce.split(/\r?\n/)) {
const match = line.match(ANNOUNCE_SECTION_RE);
if (match) {
const key = match[1]?.toLowerCase() as keyof typeof sections;
current = key;
sawSection = true;
const rest = match[2]?.trim();
if (rest) sections[key].push(rest);
continue;
}
if (current) sections[current].push(line);
}
const normalize = (lines: string[]) => {
const joined = lines.join("\n").trim();
return joined.length > 0 ? joined : undefined;
};
return {
sawSection,
status: normalize(sections.status),
result: normalize(sections.result),
notes: normalize(sections.notes),
};
}
function normalizeAnnounceBody(params: {
outcome: SubagentRunOutcome;
announceReply: string;
statsLine?: string;
}) {
const announce = params.announceReply.trim();
const statsLine = params.statsLine?.trim();
const statusLabel =
params.outcome.status === "ok"
? "success"
: params.outcome.status === "timeout"
? "timeout"
: params.outcome.status === "unknown"
? "unknown"
: "error";
const parsed = parseAnnounceSections(announce);
const resultText = parsed.result ?? (announce || "(not available)");
const notesParts: string[] = [];
if (parsed.notes) notesParts.push(parsed.notes);
if (params.outcome.error) notesParts.push(`- Error: ${params.outcome.error}`);
const notesBlock = notesParts.length ? notesParts.join("\n") : "- (none)";
const message = [
`Status: ${statusLabel}`,
"",
"Result:",
resultText,
"",
"Notes:",
notesBlock,
].join("\n");
return statsLine ? `${message}\n\n${statsLine}` : message;
}
function buildSubagentAnnouncePrompt(params: {
requesterSessionKey?: string;
requesterChannel?: string;
announceChannel: string;
task: string;
subagentReply?: string;
}) {
const lines = [
"Sub-agent announce step:",
params.requesterSessionKey ? `Requester session: ${params.requesterSessionKey}.` : undefined,
params.requesterChannel ? `Requester channel: ${params.requesterChannel}.` : undefined,
`Post target channel: ${params.announceChannel}.`,
`Original task: ${params.task}`,
params.subagentReply
? `Sub-agent result: ${params.subagentReply}`
: "Sub-agent result: (not available).",
"",
"**You MUST announce your result.** The requester is waiting for your response.",
"Provide a brief, useful summary of what you accomplished.",
"Reply with Result and Notes only (no Status line; status is added by the system).",
"Format:",
"Result: <summary>",
"Notes: <extra context>",
'Only reply "ANNOUNCE_SKIP" if the task completely failed with no useful output.',
"Your reply will be posted to the requester chat.",
].filter(Boolean);
return lines.join("\n");
}
export async function runSubagentAnnounceFlow(params: { export async function runSubagentAnnounceFlow(params: {
childSessionKey: string; childSessionKey: string;
childRunId: string; childRunId: string;
@@ -340,8 +231,6 @@ export async function runSubagentAnnounceFlow(params: {
params.endedAt = wait.endedAt; params.endedAt = wait.endedAt;
} }
if (wait?.status === "timeout") { if (wait?.status === "timeout") {
// No lifecycle end seen before timeout. Still attempt an announce so
// requesters are not left hanging.
if (!outcome) outcome = { status: "timeout" }; if (!outcome) outcome = { status: "timeout" };
} }
reply = await readLatestAssistantReply({ reply = await readLatestAssistantReply({
@@ -357,53 +246,50 @@ export async function runSubagentAnnounceFlow(params: {
if (!outcome) outcome = { status: "unknown" }; if (!outcome) outcome = { status: "unknown" };
const announceTarget = await resolveAnnounceTarget({ // Build stats
sessionKey: params.requesterSessionKey,
displayKey: params.requesterDisplayKey,
});
if (!announceTarget) return false;
const announcePrompt = buildSubagentAnnouncePrompt({
requesterSessionKey: params.requesterSessionKey,
requesterChannel: params.requesterChannel,
announceChannel: announceTarget.channel,
task: params.task,
subagentReply: reply,
});
const announceReply = await runAgentStep({
sessionKey: params.childSessionKey,
message: "Sub-agent announce step.",
extraSystemPrompt: announcePrompt,
timeoutMs: params.timeoutMs,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_NESTED,
});
if (!announceReply || !announceReply.trim() || isAnnounceSkip(announceReply)) return false;
const statsLine = await buildSubagentStatsLine({ const statsLine = await buildSubagentStatsLine({
sessionKey: params.childSessionKey, sessionKey: params.childSessionKey,
startedAt: params.startedAt, startedAt: params.startedAt,
endedAt: params.endedAt, endedAt: params.endedAt,
}); });
const message = normalizeAnnounceBody({
outcome,
announceReply,
statsLine,
});
// Build status label
const statusLabel =
outcome.status === "ok"
? "completed successfully"
: outcome.status === "timeout"
? "timed out"
: outcome.status === "error"
? `failed: ${outcome.error || "unknown error"}`
: "finished with unknown status";
// Build instructional message for main agent
const taskLabel = params.label || params.task || "background task";
const triggerMessage = [
`A background task "${taskLabel}" just ${statusLabel}.`,
"",
"Findings:",
reply || "(no output)",
"",
statsLine,
"",
"Summarize this naturally for the user. Keep it brief (1-2 sentences). Flow it into the conversation naturally.",
"Do not mention technical details like tokens, stats, or that this was a background task.",
"You can respond with NO_REPLY if no announcement is needed (e.g., internal task with no user-facing result).",
].join("\n");
// Send to main agent - it will respond in its own voice
await callGateway({ await callGateway({
method: "send", method: "agent",
params: { params: {
to: announceTarget.to, sessionKey: params.requesterSessionKey,
message, message: triggerMessage,
channel: announceTarget.channel, deliver: true,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
}, },
timeoutMs: 10_000, timeoutMs: 60_000,
}); });
didAnnounce = true; didAnnounce = true;
} catch { } catch {
// Best-effort follow-ups; ignore failures to avoid breaking the caller response. // Best-effort follow-ups; ignore failures to avoid breaking the caller response.
@@ -68,21 +68,24 @@ describe("subagent registry persistence", () => {
const mod2 = await import("./subagent-registry.js"); const mod2 = await import("./subagent-registry.js");
mod2.initSubagentRegistry(); mod2.initSubagentRegistry();
// allow queued async wait/announce to execute // allow queued async wait/cleanup to execute
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(announceSpy).toHaveBeenCalled(); expect(announceSpy).toHaveBeenCalled();
type AnnounceParams = { type AnnounceParams = {
childRunId: string;
childSessionKey: string; childSessionKey: string;
childRunId: string;
requesterSessionKey: string;
task: string;
cleanup: 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.childRunId).toBe("run-1");
expect(first.childSessionKey).toBe("agent:main:subagent:test"); expect(first.childSessionKey).toBe("agent:main:subagent:test");
}); });
it("retries announce even when announceHandled was persisted", async () => { it("skips cleanup when cleanupHandled/announceHandled 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;
@@ -100,7 +103,7 @@ describe("subagent registry persistence", () => {
createdAt: 1, createdAt: 1,
startedAt: 1, startedAt: 1,
endedAt: 2, endedAt: 2,
announceHandled: true, cleanupHandled: true, // Already handled - should be skipped
}, },
}, },
}; };
@@ -113,10 +116,11 @@ describe("subagent registry persistence", () => {
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
// announce should NOT be called since cleanupHandled was true
const calls = announceSpy.mock.calls.map((call) => call[0]); const calls = announceSpy.mock.calls.map((call) => call[0]);
const match = calls.find( const match = calls.find(
(params) => (params as { childRunId?: string }).childRunId === "run-2", (params) => (params as { childSessionKey?: string }).childSessionKey === "agent:main:subagent:two",
); );
expect(match).toBeTruthy(); expect(match).toBeFalsy();
}); });
}); });
+19 -2
View File
@@ -13,7 +13,9 @@ type PersistedSubagentRegistry = {
const REGISTRY_VERSION = 1 as const; const REGISTRY_VERSION = 1 as const;
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled">; type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled"> & {
announceHandled?: boolean;
};
export function resolveSubagentRegistryPath(): string { export function resolveSubagentRegistryPath(): string {
return path.join(STATE_DIR_CLAWDBOT, "subagents", "runs.json"); return path.join(STATE_DIR_CLAWDBOT, "subagents", "runs.json");
@@ -32,12 +34,27 @@ export function loadSubagentRegistryFromDisk(): Map<string, SubagentRunRecord> {
if (!entry || typeof entry !== "object") continue; if (!entry || typeof entry !== "object") continue;
const typed = entry as PersistedSubagentRunRecord; const typed = entry as PersistedSubagentRunRecord;
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 announceCompletedAt = const announceCompletedAt =
typeof typed.announceCompletedAt === "number" ? typed.announceCompletedAt : undefined; typeof typed.announceCompletedAt === "number" ? typed.announceCompletedAt : undefined;
const cleanupCompletedAt =
typeof typed.cleanupCompletedAt === "number"
? typed.cleanupCompletedAt
: announceCompletedAt;
const cleanupHandled =
typeof typed.cleanupHandled === "boolean"
? typed.cleanupHandled
: Boolean(typed.announceHandled ?? announceCompletedAt ?? cleanupCompletedAt);
const announceHandled =
typeof typed.announceHandled === "boolean"
? typed.announceHandled
: Boolean(announceCompletedAt);
out.set(runId, { out.set(runId, {
...typed, ...typed,
announceCompletedAt, announceCompletedAt,
announceHandled: Boolean(announceCompletedAt), announceHandled,
cleanupCompletedAt,
cleanupHandled,
}); });
} }
return out; return out;
+39 -26
View File
@@ -22,8 +22,12 @@ export type SubagentRunRecord = {
endedAt?: number; endedAt?: number;
outcome?: SubagentRunOutcome; outcome?: SubagentRunOutcome;
archiveAtMs?: number; archiveAtMs?: number;
/** @deprecated Use cleanupCompletedAt instead */
announceCompletedAt?: number; announceCompletedAt?: number;
announceHandled: boolean; /** @deprecated Use cleanupHandled instead */
announceHandled?: boolean;
cleanupCompletedAt?: number;
cleanupHandled?: boolean;
}; };
const subagentRuns = new Map<string, SubagentRunRecord>(); const subagentRuns = new Map<string, SubagentRunRecord>();
@@ -46,11 +50,11 @@ function resumeSubagentRun(runId: string) {
if (!runId || resumedRuns.has(runId)) return; if (!runId || resumedRuns.has(runId)) return;
const entry = subagentRuns.get(runId); const entry = subagentRuns.get(runId);
if (!entry) return; if (!entry) return;
if (entry.announceCompletedAt) return; if (entry.cleanupCompletedAt) return;
if (typeof entry.endedAt === "number" && entry.endedAt > 0) { if (typeof entry.endedAt === "number" && entry.endedAt > 0) {
if (!beginSubagentAnnounce(runId)) return; if (!beginSubagentCleanup(runId)) return;
const announce = runSubagentAnnounceFlow({ void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey, childSessionKey: entry.childSessionKey,
childRunId: entry.runId, childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey, requesterSessionKey: entry.requesterSessionKey,
@@ -64,9 +68,8 @@ function resumeSubagentRun(runId: string) {
endedAt: entry.endedAt, endedAt: entry.endedAt,
label: entry.label, label: entry.label,
outcome: entry.outcome, outcome: entry.outcome,
}); }).then((didAnnounce) => {
void announce.then((didAnnounce) => { finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
}); });
resumedRuns.add(runId); resumedRuns.add(runId);
return; return;
@@ -156,7 +159,9 @@ async function sweepSubagentRuns() {
} }
function ensureListener() { function ensureListener() {
if (listenerStarted) return; if (listenerStarted) {
return;
}
listenerStarted = true; listenerStarted = true;
listenerStop = onAgentEvent((evt) => { listenerStop = onAgentEvent((evt) => {
if (!evt || evt.stream !== "lifecycle") return; if (!evt || evt.stream !== "lifecycle") return;
@@ -186,10 +191,10 @@ function ensureListener() {
} }
persistSubagentRuns(); persistSubagentRuns();
if (!beginSubagentAnnounce(evt.runId)) { if (!beginSubagentCleanup(evt.runId)) {
return; return;
} }
const announce = runSubagentAnnounceFlow({ void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey, childSessionKey: entry.childSessionKey,
childRunId: entry.runId, childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey, requesterSessionKey: entry.requesterSessionKey,
@@ -203,14 +208,17 @@ function ensureListener() {
endedAt: entry.endedAt, endedAt: entry.endedAt,
label: entry.label, label: entry.label,
outcome: entry.outcome, outcome: entry.outcome,
}); }).then((didAnnounce) => {
void announce.then((didAnnounce) => { finalizeSubagentCleanup(evt.runId, entry.cleanup, didAnnounce);
finalizeSubagentAnnounce(evt.runId, entry.cleanup, didAnnounce);
}); });
}); });
} }
function finalizeSubagentAnnounce(runId: string, cleanup: "delete" | "keep", didAnnounce: boolean) { function finalizeSubagentCleanup(
runId: string,
cleanup: "delete" | "keep",
didAnnounce: boolean,
) {
const entry = subagentRuns.get(runId); const entry = subagentRuns.get(runId);
if (!entry) return; if (!entry) return;
if (cleanup === "delete") { if (cleanup === "delete") {
@@ -218,17 +226,23 @@ function finalizeSubagentAnnounce(runId: string, cleanup: "delete" | "keep", did
persistSubagentRuns(); persistSubagentRuns();
return; return;
} }
if (!didAnnounce) return; if (!didAnnounce) {
entry.announceCompletedAt = Date.now(); // Allow retry on the next wake if the announce failed.
entry.cleanupHandled = false;
persistSubagentRuns();
return;
}
entry.cleanupCompletedAt = Date.now();
persistSubagentRuns(); persistSubagentRuns();
} }
export function beginSubagentAnnounce(runId: string) { function beginSubagentCleanup(runId: string) {
const entry = subagentRuns.get(runId); const entry = subagentRuns.get(runId);
if (!entry) return false; if (!entry) return false;
if (entry.announceCompletedAt) return false; // Support legacy field names for backward compatibility
if (entry.announceHandled) return false; if (entry.cleanupCompletedAt || entry.announceCompletedAt) return false;
entry.announceHandled = true; if (entry.cleanupHandled || entry.announceHandled) return false;
entry.cleanupHandled = true;
persistSubagentRuns(); persistSubagentRuns();
return true; return true;
} }
@@ -261,7 +275,7 @@ export function registerSubagentRun(params: {
createdAt: now, createdAt: now,
startedAt: now, startedAt: now,
archiveAtMs, archiveAtMs,
announceHandled: false, cleanupHandled: false,
}); });
ensureListener(); ensureListener();
persistSubagentRuns(); persistSubagentRuns();
@@ -302,8 +316,8 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
wait.status === "error" ? { status: "error", error: wait.error } : { status: "ok" }; wait.status === "error" ? { status: "error", error: wait.error } : { status: "ok" };
mutated = true; mutated = true;
if (mutated) persistSubagentRuns(); if (mutated) persistSubagentRuns();
if (!beginSubagentAnnounce(runId)) return; if (!beginSubagentCleanup(runId)) return;
const announce = runSubagentAnnounceFlow({ void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey, childSessionKey: entry.childSessionKey,
childRunId: entry.runId, childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey, requesterSessionKey: entry.requesterSessionKey,
@@ -317,9 +331,8 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
endedAt: entry.endedAt, endedAt: entry.endedAt,
label: entry.label, label: entry.label,
outcome: entry.outcome, outcome: entry.outcome,
}); }).then((didAnnounce) => {
void announce.then((didAnnounce) => { finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
}); });
} catch { } catch {
// ignore // ignore
+43 -3
View File
@@ -71,15 +71,42 @@ describe("buildAgentSystemPrompt", () => {
); );
}); });
it("includes user time when provided", () => { it("includes user time when provided (12-hour)", () => {
const prompt = buildAgentSystemPrompt({ const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd", workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago", userTimezone: "America/Chicago",
userTime: "Monday 2026-01-05 15:26", userTime: "Monday, January 5th, 2026 — 3:26 PM",
userTimeFormat: "12",
}); });
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Monday, January 5th, 2026 — 3:26 PM (America/Chicago)");
expect(prompt).toContain("Time format: 12-hour");
});
it("includes user time when provided (24-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
userTime: "Monday, January 5th, 2026 — 15:26",
userTimeFormat: "24",
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Monday, January 5th, 2026 — 15:26 (America/Chicago)");
expect(prompt).toContain("Time format: 24-hour");
});
it("shows UTC fallback when only timezone is provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
userTimeFormat: "24",
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain( expect(prompt).toContain(
"Time: assume UTC unless stated. User time zone: America/Chicago. Current user time (local, 24-hour): Monday 2026-01-05 15:26 (America/Chicago).", "Time zone: America/Chicago. Current time unknown; assume UTC for date/time references.",
); );
}); });
@@ -208,4 +235,17 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("User can toggle with /elevated on|off."); expect(prompt).toContain("User can toggle with /elevated on|off.");
expect(prompt).toContain("Current elevated level: on"); expect(prompt).toContain("Current elevated level: on");
}); });
it("includes reaction guidance when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
reactionGuidance: {
level: "minimal",
channel: "Telegram",
},
});
expect(prompt).toContain("## Reactions");
expect(prompt).toContain("Reactions are enabled for Telegram in MINIMAL mode.");
});
}); });
+157 -74
View File
@@ -1,8 +1,17 @@
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js";
import type { ResolvedTimeFormat } from "./date-time.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
/**
* Controls which hardcoded sections are included in the system prompt.
* - "full": All sections (default, for main agent)
* - "minimal": Reduced sections (Tooling, Workspace, Runtime) - used for subagents
* - "none": Just basic identity line, no sections
*/
export type PromptMode = "full" | "minimal" | "none";
export function buildAgentSystemPrompt(params: { export function buildAgentSystemPrompt(params: {
workspaceDir: string; workspaceDir: string;
defaultThinkLevel?: ThinkLevel; defaultThinkLevel?: ThinkLevel;
@@ -15,9 +24,12 @@ export function buildAgentSystemPrompt(params: {
modelAliasLines?: string[]; modelAliasLines?: string[];
userTimezone?: string; userTimezone?: string;
userTime?: string; userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[]; contextFiles?: EmbeddedContextFile[];
skillsPrompt?: string; skillsPrompt?: string;
heartbeatPrompt?: string; heartbeatPrompt?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo?: { runtimeInfo?: {
host?: string; host?: string;
os?: string; os?: string;
@@ -43,6 +55,11 @@ export function buildAgentSystemPrompt(params: {
defaultLevel: "on" | "off"; defaultLevel: "on" | "off";
}; };
}; };
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
};
}) { }) {
const coreToolSummaries: Record<string, string> = { const coreToolSummaries: Record<string, string> = {
read: "Read file contents", read: "Read file contents",
@@ -172,17 +189,22 @@ export function buildAgentSystemPrompt(params: {
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase())); const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
const messageChannelOptions = listDeliverableMessageChannels().join("|"); const messageChannelOptions = listDeliverableMessageChannels().join("|");
const promptMode = params.promptMode ?? "full";
const isMinimal = promptMode === "minimal" || promptMode === "none";
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : []; const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
const skillsSection = skillsPrompt // Skip skills section for subagent/none modes
? [ const skillsSection =
"## Skills", skillsPrompt && !isMinimal
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`, ? [
...skillsLines, "## Skills",
"", `Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
] ...skillsLines,
: []; "",
]
: [];
// Skip memory section for subagent/none modes
const memorySection = const memorySection =
availableTools.has("memory_search") || availableTools.has("memory_get") !isMinimal && (availableTools.has("memory_search") || availableTools.has("memory_get"))
? [ ? [
"## Memory Recall", "## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.", "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
@@ -190,6 +212,11 @@ export function buildAgentSystemPrompt(params: {
] ]
: []; : [];
// For "none" mode, return just the basic identity line
if (promptMode === "none") {
return "You are a personal assistant running inside Clawdbot.";
}
const lines = [ const lines = [
"You are a personal assistant running inside Clawdbot.", "You are a personal assistant running inside Clawdbot.",
"", "",
@@ -228,8 +255,9 @@ export function buildAgentSystemPrompt(params: {
"", "",
...skillsSection, ...skillsSection,
...memorySection, ...memorySection,
hasGateway ? "## Clawdbot Self-Update" : "", // Skip self-update for subagent/none modes
hasGateway hasGateway && !isMinimal ? "## Clawdbot Self-Update" : "",
hasGateway && !isMinimal
? [ ? [
"Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.", "Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.",
"Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.", "Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.",
@@ -237,16 +265,19 @@ export function buildAgentSystemPrompt(params: {
"After restart, Clawdbot pings the last active session automatically.", "After restart, Clawdbot pings the last active session automatically.",
].join("\n") ].join("\n")
: "", : "",
hasGateway ? "" : "", hasGateway && !isMinimal ? "" : "",
"", "",
params.modelAliasLines && params.modelAliasLines.length > 0 ? "## Model Aliases" : "", // Skip model aliases for subagent/none modes
params.modelAliasLines && params.modelAliasLines.length > 0 params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? "## Model Aliases"
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? "Prefer aliases when specifying model overrides; full provider/model is also accepted." ? "Prefer aliases when specifying model overrides; full provider/model is also accepted."
: "", : "",
params.modelAliasLines && params.modelAliasLines.length > 0 params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? params.modelAliasLines.join("\n") ? params.modelAliasLines.join("\n")
: "", : "",
params.modelAliasLines && params.modelAliasLines.length > 0 ? "" : "", params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "",
"## Workspace", "## Workspace",
`Your working directory is: ${params.workspaceDir}`, `Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
@@ -304,52 +335,92 @@ export function buildAgentSystemPrompt(params: {
.join("\n") .join("\n")
: "", : "",
params.sandboxInfo?.enabled ? "" : "", params.sandboxInfo?.enabled ? "" : "",
ownerLine ? "## User Identity" : "", // Skip user identity for subagent/none modes
ownerLine ?? "", ownerLine && !isMinimal ? "## User Identity" : "",
ownerLine ? "" : "", ownerLine && !isMinimal ? ownerLine : "",
ownerLine && !isMinimal ? "" : "",
...(userTimezone || userTime
? [
"## Current Date & Time",
userTime
? `${userTime} (${userTimezone ?? "unknown"})`
: `Time zone: ${userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
]
: []),
"## Workspace Files (injected)", "## Workspace Files (injected)",
"These user-editable files are loaded by Clawdbot and included below in Project Context.", "These user-editable files are loaded by Clawdbot and included below in Project Context.",
"", "",
userTimezone || userTime // Skip reply tags for subagent/none modes
? `Time: assume UTC unless stated. User time zone: ${ ...(isMinimal
userTimezone ?? "unknown" ? []
}. Current user time (local, 24-hour): ${userTime ?? "unknown"} (${ : [
userTimezone ?? "unknown" "## Reply Tags",
}).` "To request a native reply/quote on supported surfaces, include one tag in your reply:",
: "", "- [[reply_to_current]] replies to the triggering message.",
userTimezone || userTime ? "" : "", "- [[reply_to:<id>]] replies to a specific message id when you have it.",
"## Reply Tags", "Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"To request a native reply/quote on supported surfaces, include one tag in your reply:", "Tags are stripped before sending; support depends on the current channel config.",
"- [[reply_to_current]] replies to the triggering message.",
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"Tags are stripped before sending; support depends on the current channel config.",
"",
"## Messaging",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
availableTools.has("message")
? [
"", "",
"### message tool", ]),
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).", // Skip messaging section for subagent/none modes
"- For `action=send`, include `to` and `message`.", ...(isMinimal
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`, ? []
inlineButtonsEnabled : [
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." "## Messaging",
: runtimeChannel "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.` "- Cross-session messaging → use sessions_send(sessionKey, message)",
: "", "- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
] availableTools.has("message")
.filter(Boolean) ? [
.join("\n") "",
: "", "### message tool",
"", "- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: runtimeChannel
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
: "",
]
.filter(Boolean)
.join("\n")
: "",
"",
]),
]; ];
if (extraSystemPrompt) { if (extraSystemPrompt) {
lines.push("## Group Chat Context", extraSystemPrompt, ""); // Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context"
const contextHeader = promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context";
lines.push(contextHeader, extraSystemPrompt, "");
}
if (params.reactionGuidance) {
const { level, channel } = params.reactionGuidance;
const guidanceText =
level === "minimal"
? [
`Reactions are enabled for ${channel} in MINIMAL mode.`,
"React ONLY when truly relevant:",
"- Acknowledge important user requests or confirmations",
"- Express genuine sentiment (humor, appreciation) sparingly",
"- Avoid reacting to routine messages or your own replies",
"Guideline: at most 1 reaction per 5-10 exchanges.",
].join("\n")
: [
`Reactions are enabled for ${channel} in EXTENSIVE mode.`,
"Feel free to react liberally:",
"- Acknowledge messages with appropriate emojis",
"- Express sentiment and personality through reactions",
"- React to interesting content, humor, or notable events",
"- Use reactions to confirm understanding or agreement",
"Guideline: react whenever it feels natural.",
].join("\n");
lines.push("## Reactions", guidanceText, "");
} }
if (reasoningHint) { if (reasoningHint) {
lines.push("## Reasoning Format", reasoningHint, ""); lines.push("## Reasoning Format", reasoningHint, "");
@@ -368,26 +439,38 @@ export function buildAgentSystemPrompt(params: {
} }
} }
// Skip silent replies for subagent/none modes
if (!isMinimal) {
lines.push(
"## Silent Replies",
`When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`,
"",
"⚠️ Rules:",
"- It must be your ENTIRE message — nothing else",
`- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`,
"- Never wrap it in markdown or code blocks",
"",
`❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`,
`❌ Wrong: "${SILENT_REPLY_TOKEN}"`,
`✅ Right: ${SILENT_REPLY_TOKEN}`,
"",
);
}
// Skip heartbeats for subagent/none modes
if (!isMinimal) {
lines.push(
"## Heartbeats",
heartbeatPromptLine,
"If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:",
"HEARTBEAT_OK",
'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"",
);
}
lines.push( lines.push(
"## Silent Replies",
`When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`,
"",
"⚠️ Rules:",
"- It must be your ENTIRE message — nothing else",
`- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`,
"- Never wrap it in markdown or code blocks",
"",
`❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`,
`❌ Wrong: "${SILENT_REPLY_TOKEN}"`,
`✅ Right: ${SILENT_REPLY_TOKEN}`,
"",
"## Heartbeats",
heartbeatPromptLine,
"If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:",
"HEARTBEAT_OK",
'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"",
"## Runtime", "## Runtime",
`Runtime: ${[ `Runtime: ${[
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "", runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
+37 -34
View File
@@ -27,32 +27,7 @@ import {
readStringArrayParam, readStringArrayParam,
readStringParam, readStringParam,
} from "./common.js"; } from "./common.js";
import { withNormalizedTimestamp } from "../date-time.js";
function formatDiscordTimestamp(ts?: string | null): string | undefined {
if (!ts) return undefined;
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return undefined;
const yyyy = String(date.getFullYear()).padStart(4, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
// getTimezoneOffset() is minutes *behind* UTC. Flip sign to get ISO offset.
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absOffsetMinutes = Math.abs(offsetMinutes);
const offsetH = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0");
const offsetM = String(absOffsetMinutes % 60).padStart(2, "0");
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const tzSuffix = tz ? `{${tz}}` : "";
// Compact ISO-like *local* timestamp with minutes precision.
// Example: 2025-01-02T03:04-08:00{America/Los_Angeles}
return `${yyyy}-${mm}-${dd}T${hh}:${min}${sign}${offsetH}:${offsetM}${tzSuffix}`;
}
function parseDiscordMessageLink(link: string) { function parseDiscordMessageLink(link: string) {
const normalized = link.trim(); const normalized = link.trim();
@@ -76,6 +51,13 @@ export async function handleDiscordMessagingAction(
params: Record<string, unknown>, params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>, isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> { ): Promise<AgentToolResult<unknown>> {
const normalizeMessage = (message: unknown) => {
if (!message || typeof message !== "object") return message;
return withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { timestamp?: unknown }).timestamp,
);
};
switch (action) { switch (action) {
case "react": { case "react": {
if (!isActionEnabled("reactions")) { if (!isActionEnabled("reactions")) {
@@ -189,7 +171,13 @@ export async function handleDiscordMessagingAction(
); );
} }
const message = await fetchMessageDiscord(channelId, messageId); const message = await fetchMessageDiscord(channelId, messageId);
return jsonResult({ ok: true, message, guildId, channelId, messageId }); return jsonResult({
ok: true,
message: normalizeMessage(message),
guildId,
channelId,
messageId,
});
} }
case "readMessages": { case "readMessages": {
if (!isActionEnabled("messages")) { if (!isActionEnabled("messages")) {
@@ -207,11 +195,10 @@ export async function handleDiscordMessagingAction(
after: readStringParam(params, "after"), after: readStringParam(params, "after"),
around: readStringParam(params, "around"), around: readStringParam(params, "around"),
}); });
const formattedMessages = messages.map((message) => ({ return jsonResult({
...message, ok: true,
timestamp: formatDiscordTimestamp(message.timestamp) ?? message.timestamp, messages: messages.map((message) => normalizeMessage(message)),
})); });
return jsonResult({ ok: true, messages: formattedMessages });
} }
case "sendMessage": { case "sendMessage": {
if (!isActionEnabled("messages")) { if (!isActionEnabled("messages")) {
@@ -357,7 +344,7 @@ export async function handleDiscordMessagingAction(
required: true, required: true,
}); });
const pins = await listPinsDiscord(channelId); const pins = await listPinsDiscord(channelId);
return jsonResult({ ok: true, pins }); return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
} }
case "searchMessages": { case "searchMessages": {
if (!isActionEnabled("search")) { if (!isActionEnabled("search")) {
@@ -386,7 +373,23 @@ export async function handleDiscordMessagingAction(
authorIds: authorIdList.length ? authorIdList : undefined, authorIds: authorIdList.length ? authorIdList : undefined,
limit, limit,
}); });
return jsonResult({ ok: true, results }); if (!results || typeof results !== "object") {
return jsonResult({ ok: true, results });
}
const resultsRecord = results as Record<string, unknown>;
const messages = resultsRecord.messages;
const normalizedMessages = Array.isArray(messages)
? messages.map((group) =>
Array.isArray(group) ? group.map((msg) => normalizeMessage(msg)) : group,
)
: messages;
return jsonResult({
ok: true,
results: {
...resultsRecord,
messages: normalizedMessages,
},
});
} }
default: default:
throw new Error(`Unknown action: ${action}`); throw new Error(`Unknown action: ${action}`);
+76
View File
@@ -17,6 +17,7 @@ const editChannelDiscord = vi.fn(async () => ({
name: "edited", name: "edited",
})); }));
const editMessageDiscord = vi.fn(async () => ({})); const editMessageDiscord = vi.fn(async () => ({}));
const fetchMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({})); const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({})); const fetchReactionsDiscord = vi.fn(async () => ({}));
const listPinsDiscord = vi.fn(async () => ({})); const listPinsDiscord = vi.fn(async () => ({}));
@@ -42,6 +43,7 @@ vi.mock("../../discord/send.js", () => ({
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args), deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args), editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args), editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args), fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args), fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args), listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
@@ -134,6 +136,80 @@ describe("handleDiscordMessagingAction", () => {
), ),
).rejects.toThrow(/Discord reactions are disabled/); ).rejects.toThrow(/Discord reactions are disabled/);
}); });
it("adds normalized timestamps to readMessages payloads", async () => {
readMessagesDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" }]);
const result = await handleDiscordMessagingAction(
"readMessages",
{ channelId: "C1" },
enableAllActions,
);
const payload = result.details as {
messages: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Date.parse("2026-01-15T10:00:00.000Z");
expect(payload.messages[0].timestampMs).toBe(expectedMs);
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to fetchMessage payloads", async () => {
fetchMessageDiscord.mockResolvedValueOnce({
id: "1",
timestamp: "2026-01-15T11:00:00.000Z",
});
const result = await handleDiscordMessagingAction(
"fetchMessage",
{ guildId: "G1", channelId: "C1", messageId: "M1" },
enableAllActions,
);
const payload = result.details as { message?: { timestampMs?: number; timestampUtc?: string } };
const expectedMs = Date.parse("2026-01-15T11:00:00.000Z");
expect(payload.message?.timestampMs).toBe(expectedMs);
expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to listPins payloads", async () => {
listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]);
const result = await handleDiscordMessagingAction(
"listPins",
{ channelId: "C1" },
enableAllActions,
);
const payload = result.details as {
pins: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Date.parse("2026-01-15T12:00:00.000Z");
expect(payload.pins[0].timestampMs).toBe(expectedMs);
expect(payload.pins[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to searchMessages payloads", async () => {
searchMessagesDiscord.mockResolvedValueOnce({
total_results: 1,
messages: [[{ id: "1", timestamp: "2026-01-15T13:00:00.000Z" }]],
});
const result = await handleDiscordMessagingAction(
"searchMessages",
{ guildId: "G1", content: "hi" },
enableAllActions,
);
const payload = result.details as {
results?: { messages?: Array<Array<{ timestampMs?: number; timestampUtc?: string }>> };
};
const expectedMs = Date.parse("2026-01-15T13:00:00.000Z");
expect(payload.results?.messages?.[0]?.[0]?.timestampMs).toBe(expectedMs);
expect(payload.results?.messages?.[0]?.[0]?.timestampUtc).toBe(
new Date(expectedMs).toISOString(),
);
});
}); });
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels"; const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
+4 -2
View File
@@ -25,7 +25,7 @@ import {
loadSessionStore, loadSessionStore,
resolveStorePath, resolveStorePath,
type SessionEntry, type SessionEntry,
saveSessionStore, updateSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { import {
formatUsageSummaryLine, formatUsageSummaryLine,
@@ -263,7 +263,9 @@ export function createSessionStatusTool(opts?: {
delete nextEntry.authProfileOverride; delete nextEntry.authProfileOverride;
} }
store[resolved.key] = nextEntry; store[resolved.key] = nextEntry;
await saveSessionStore(storePath, store); await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = nextEntry;
});
resolved.entry = nextEntry; resolved.entry = nextEntry;
changedModel = true; changedModel = true;
} }
@@ -32,6 +32,7 @@ export async function resolveAnnounceTarget(params: {
const match = const match =
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 channel = typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
const to = typeof match?.lastTo === "string" ? match.lastTo : undefined; const to = typeof match?.lastTo === "string" ? match.lastTo : undefined;
const accountId = typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined; const accountId = typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined;
+36
View File
@@ -325,4 +325,40 @@ describe("handleSlackAction", () => {
threadTs: "1111111111.111111", threadTs: "1111111111.111111",
}); });
}); });
it("adds normalized timestamps to readMessages payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
readSlackMessages.mockResolvedValueOnce({
messages: [{ ts: "1735689600.456", text: "hi" }],
hasMore: false,
});
const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const payload = result.details as {
messages: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Math.round(1735689600.456 * 1000);
expect(payload.messages[0].timestampMs).toBe(expectedMs);
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to pin payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
listSlackPins.mockResolvedValueOnce([
{
type: "message",
message: { ts: "1735689600.789", text: "pinned" },
},
]);
const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
const payload = result.details as {
pins: Array<{ message?: { timestampMs?: number; timestampUtc?: string } }>;
};
const expectedMs = Math.round(1735689600.789 * 1000);
expect(payload.pins[0].message?.timestampMs).toBe(expectedMs);
expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
});
}); });
+18 -2
View File
@@ -17,6 +17,7 @@ import {
sendSlackMessage, sendSlackMessage,
unpinSlackMessage, unpinSlackMessage,
} from "../../slack/actions.js"; } from "../../slack/actions.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js"; import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
@@ -197,7 +198,13 @@ export async function handleSlackAction(
before: before ?? undefined, before: before ?? undefined,
after: after ?? undefined, after: after ?? undefined,
}); });
return jsonResult({ ok: true, ...result }); const messages = result.messages.map((message) =>
withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { ts?: unknown }).ts,
),
);
return jsonResult({ ok: true, messages, hasMore: result.hasMore });
} }
default: default:
break; break;
@@ -234,7 +241,16 @@ export async function handleSlackAction(
const pins = accountOpts const pins = accountOpts
? await listSlackPins(channelId, accountOpts) ? await listSlackPins(channelId, accountOpts)
: await listSlackPins(channelId); : await listSlackPins(channelId);
return jsonResult({ ok: true, pins }); const normalizedPins = pins.map((pin) => {
const message = pin.message
? withNormalizedTimestamp(
pin.message as Record<string, unknown>,
(pin.message as { ts?: unknown }).ts,
)
: pin.message;
return message ? { ...pin, message } : pin;
});
return jsonResult({ ok: true, pins: normalizedPins });
} }
if (action === "memberInfo") { if (action === "memberInfo") {
+66 -7
View File
@@ -33,9 +33,30 @@ describe("handleTelegramAction", () => {
} }
}); });
it("adds reactions", async () => { it("adds reactions when reactionLevel is minimal", async () => {
const cfg = { const cfg = {
channels: { telegram: { botToken: "tok" } }, channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
});
it("adds reactions when reactionLevel is extensive", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as ClawdbotConfig; } as ClawdbotConfig;
await handleTelegramAction( await handleTelegramAction(
{ {
@@ -56,7 +77,7 @@ describe("handleTelegramAction", () => {
it("removes reactions on empty emoji", async () => { it("removes reactions on empty emoji", async () => {
const cfg = { const cfg = {
channels: { telegram: { botToken: "tok" } }, channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as ClawdbotConfig; } as ClawdbotConfig;
await handleTelegramAction( await handleTelegramAction(
{ {
@@ -77,7 +98,7 @@ describe("handleTelegramAction", () => {
it("removes reactions when remove flag set", async () => { it("removes reactions when remove flag set", async () => {
const cfg = { const cfg = {
channels: { telegram: { botToken: "tok" } }, channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as ClawdbotConfig; } as ClawdbotConfig;
await handleTelegramAction( await handleTelegramAction(
{ {
@@ -97,10 +118,48 @@ describe("handleTelegramAction", () => {
); );
}); });
it("respects reaction gating", async () => { it("blocks reactions when reactionLevel is off", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "off" } },
} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
),
).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="off"/);
});
it("blocks reactions when reactionLevel is ack (default)", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "ack" } },
} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
),
).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="ack"/);
});
it("also respects legacy actions.reactions gating", async () => {
const cfg = { const cfg = {
channels: { channels: {
telegram: { botToken: "tok", actions: { reactions: false } }, telegram: {
botToken: "tok",
reactionLevel: "minimal",
actions: { reactions: false },
},
}, },
} as ClawdbotConfig; } as ClawdbotConfig;
await expect( await expect(
@@ -113,7 +172,7 @@ describe("handleTelegramAction", () => {
}, },
cfg, cfg,
), ),
).rejects.toThrow(/Telegram reactions are disabled/); ).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/);
}); });
it("sends a text message", async () => { it("sends a text message", async () => {
+14 -1
View File
@@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import { import {
deleteMessageTelegram, deleteMessageTelegram,
reactMessageTelegram, reactMessageTelegram,
@@ -82,8 +83,20 @@ export async function handleTelegramAction(
const isActionEnabled = createActionGate(cfg.channels?.telegram?.actions); const isActionEnabled = createActionGate(cfg.channels?.telegram?.actions);
if (action === "react") { if (action === "react") {
// Check reaction level first
const reactionLevelInfo = resolveTelegramReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error(
`Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
`Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`,
);
}
// Also check the existing action gate for backward compatibility
if (!isActionEnabled("reactions")) { if (!isActionEnabled("reactions")) {
throw new Error("Telegram reactions are disabled."); throw new Error("Telegram reactions are disabled via actions.reactions.");
} }
const chatId = readStringOrNumberParam(params, "chatId", { const chatId = readStringOrNumberParam(params, "chatId", {
required: true, required: true,
BIN
View File
Binary file not shown.
+13 -1
View File
@@ -4,7 +4,19 @@ export type CommandArgsFormatter = (values: CommandArgValues) => string | undefi
function normalizeArgValue(value: unknown): string | undefined { function normalizeArgValue(value: unknown): string | undefined {
if (value == null) return undefined; if (value == null) return undefined;
const text = typeof value === "string" ? value.trim() : String(value).trim(); let text: string;
if (typeof value === "string") {
text = value.trim();
} else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
text = String(value).trim();
} else if (typeof value === "symbol") {
text = value.toString().trim();
} else if (typeof value === "function") {
text = value.toString().trim();
} else {
// Objects and arrays
text = JSON.stringify(value);
}
return text ? text : undefined; return text ? text : undefined;
} }
+6 -1
View File
@@ -137,7 +137,12 @@ function formatPositionalArgs(
for (const definition of definitions) { for (const definition of definitions) {
const value = values[definition.name]; const value = values[definition.name];
if (value == null) continue; if (value == null) continue;
const rendered = typeof value === "string" ? value.trim() : String(value); let rendered: string;
if (typeof value === "string") {
rendered = value.trim();
} else {
rendered = String(value);
}
if (!rendered) continue; if (!rendered) continue;
parts.push(rendered); parts.push(rendered);
if (definition.captureRemaining) break; if (definition.captureRemaining) break;
+2 -1
View File
@@ -28,7 +28,8 @@ export type CommandArgMenuSpec = {
title?: string; title?: string;
}; };
export type CommandArgValues = Record<string, unknown>; export type CommandArgValue = string | number | boolean | bigint;
export type CommandArgValues = Record<string, CommandArgValue>;
export type CommandArgs = { export type CommandArgs = {
raw?: string; raw?: string;
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { createInboundDebouncer } from "./inbound-debounce.js";
describe("createInboundDebouncer", () => {
it("debounces and combines items", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
debounceMs: 10,
buildKey: (item) => item.key,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1" });
await debouncer.enqueue({ key: "a", id: "2" });
expect(calls).toEqual([]);
await vi.advanceTimersByTimeAsync(10);
expect(calls).toEqual([["1", "2"]]);
vi.useRealTimers();
});
it("flushes buffered items before non-debounced item", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
debounceMs: 50,
buildKey: (item) => item.key,
shouldDebounce: (item) => item.debounce,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
expect(calls).toEqual([["1"], ["2"]]);
vi.useRealTimers();
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { InboundDebounceByProvider } from "../config/types.messages.js";
const resolveMs = (value: unknown): number | undefined => {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return Math.max(0, Math.trunc(value));
};
const resolveChannelOverride = (params: {
byChannel?: InboundDebounceByProvider;
channel: string;
}): number | undefined => {
if (!params.byChannel) return undefined;
const channelKey = params.channel as keyof InboundDebounceByProvider;
return resolveMs(params.byChannel[channelKey]);
};
export function resolveInboundDebounceMs(params: {
cfg: ClawdbotConfig;
channel: string;
overrideMs?: number;
}): number {
const inbound = params.cfg.messages?.inbound;
const override = resolveMs(params.overrideMs);
const byChannel = resolveChannelOverride({
byChannel: inbound?.byChannel,
channel: params.channel,
});
const base = resolveMs(inbound?.debounceMs);
return override ?? byChannel ?? base ?? 0;
}
type DebounceBuffer<T> = {
items: T[];
timeout: ReturnType<typeof setTimeout> | null;
};
export function createInboundDebouncer<T>(params: {
debounceMs: number;
buildKey: (item: T) => string | null | undefined;
shouldDebounce?: (item: T) => boolean;
onFlush: (items: T[]) => Promise<void>;
onError?: (err: unknown, items: T[]) => void;
}) {
const buffers = new Map<string, DebounceBuffer<T>>();
const debounceMs = Math.max(0, Math.trunc(params.debounceMs));
const flushBuffer = async (key: string, buffer: DebounceBuffer<T>) => {
buffers.delete(key);
if (buffer.timeout) {
clearTimeout(buffer.timeout);
buffer.timeout = null;
}
if (buffer.items.length === 0) return;
try {
await params.onFlush(buffer.items);
} catch (err) {
params.onError?.(err, buffer.items);
}
};
const flushKey = async (key: string) => {
const buffer = buffers.get(key);
if (!buffer) return;
await flushBuffer(key, buffer);
};
const scheduleFlush = (key: string, buffer: DebounceBuffer<T>) => {
if (buffer.timeout) clearTimeout(buffer.timeout);
buffer.timeout = setTimeout(() => {
void flushBuffer(key, buffer);
}, debounceMs);
buffer.timeout.unref?.();
};
const enqueue = async (item: T) => {
const key = params.buildKey(item);
const canDebounce = debounceMs > 0 && (params.shouldDebounce?.(item) ?? true);
if (!canDebounce || !key) {
if (key && buffers.has(key)) {
await flushKey(key);
}
await params.onFlush([item]);
return;
}
const existing = buffers.get(key);
if (existing) {
existing.items.push(item);
scheduleFlush(key, existing);
return;
}
const buffer: DebounceBuffer<T> = { items: [item], timeout: null };
buffers.set(key, buffer);
scheduleFlush(key, buffer);
};
return { enqueue, flushKey };
}
@@ -85,8 +85,8 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic"); expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("gpt-4.1-mini — openai"); expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -116,8 +116,8 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic"); expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("gpt-4.1-mini — openai"); expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -166,9 +166,9 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("claude-opus-4-5 — anthropic"); expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("gpt-4.1-mini — openai"); expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("MiniMax-M2.1 — minimax"); expect(text).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@@ -213,7 +213,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("gpt-4.1-mini — openai"); expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1"); expect(text).not.toContain("claude-sonnet-4-1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
@@ -1,4 +1,3 @@
import fs from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { normalizeTestText } from "../../test/helpers/normalize-text.js";
@@ -96,7 +95,7 @@ afterEach(() => {
}); });
describe("trigger handling", () => { describe("trigger handling", () => {
it("shows a quick /model picker grouped by model with providers", async () => { it("shows a quick /model picker listing provider/model pairs", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
@@ -116,13 +115,103 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? ""); const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>"); expect(normalized).toContain("Pick: /model <#> or /model <provider/model>");
expect(normalized).toContain("1) claude-opus-4-5 — anthropic, openrouter"); // Each provider/model combo is listed separately for clear selection
expect(normalized).toContain("3) gpt-5.2 — openai, openai-codex"); expect(normalized).toContain("anthropic/claude-opus-4-5");
expect(normalized).toContain("openrouter/anthropic/claude-opus-4-5");
expect(normalized).toContain("openai/gpt-5.2");
expect(normalized).toContain("openai-codex/gpt-5.2");
expect(normalized).toContain("More: /model status"); expect(normalized).toContain("More: /model status");
expect(normalized).not.toContain("reasoning"); expect(normalized).not.toContain("reasoning");
expect(normalized).not.toContain("image"); expect(normalized).not.toContain("image");
}); });
}); });
it("orders provider/model pairs by provider preference", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
{
Body: "/model",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: "telegram:slash:111",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
const anthropicIndex = normalized.indexOf("anthropic/claude-opus-4-5");
const openrouterIndex = normalized.indexOf("openrouter/anthropic/claude-opus-4-5");
const openaiIndex = normalized.indexOf("openai/gpt-4.1-mini");
const codexIndex = normalized.indexOf("openai-codex/gpt-5.2");
expect(anthropicIndex).toBeGreaterThanOrEqual(0);
expect(openrouterIndex).toBeGreaterThanOrEqual(0);
expect(openaiIndex).toBeGreaterThanOrEqual(0);
expect(codexIndex).toBeGreaterThanOrEqual(0);
expect(anthropicIndex).toBeLessThan(openrouterIndex);
expect(openaiIndex).toBeLessThan(codexIndex);
});
});
it("selects the exact provider/model pair for openrouter by index", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
const list = await getReplyFromConfig(
{
Body: "/model",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: sessionKey,
},
{},
cfg,
);
const listText = Array.isArray(list) ? list[0]?.text : list?.text;
const lines = normalizeTestText(listText ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const targetLine = lines.find((line) =>
line.includes("openrouter/anthropic/claude-opus-4-5"),
);
expect(targetLine).toBeDefined();
const match = targetLine?.match(/^(\d+)\)/);
expect(match?.[1]).toBeDefined();
const index = Number.parseInt(match?.[1] ?? "", 10);
expect(Number.isFinite(index)).toBe(true);
const res = await getReplyFromConfig(
{
Body: `/model ${index}`,
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: sessionKey,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
"Model set to openrouter/anthropic/claude-opus-4-5",
);
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBe("openrouter");
expect(store[sessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5");
});
});
it("rejects invalid /model <#> selections", async () => { it("rejects invalid /model <#> selections", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
@@ -152,27 +241,12 @@ describe("trigger handling", () => {
expect(store[sessionKey]?.modelOverride).toBeUndefined(); expect(store[sessionKey]?.modelOverride).toBeUndefined();
}); });
}); });
it("prefers the current provider when selecting /model <#>", async () => { it("selects exact provider/model combo by index via /model <#>", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111"; const sessionKey = "telegram:slash:111";
await fs.writeFile( // /model 1 should select the first item (anthropic/claude-opus-4-5)
cfg.session.store,
JSON.stringify(
{
[sessionKey]: {
sessionId: "session-openrouter",
updatedAt: Date.now(),
providerOverride: "openrouter",
modelOverride: "anthropic/claude-opus-4-5",
},
},
null,
2,
),
);
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/model 1", Body: "/model 1",
@@ -188,13 +262,13 @@ describe("trigger handling", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain( // Selecting the default model shows "reset to default" instead of "set to"
"Model set to openrouter/anthropic/claude-opus-4-5", expect(normalizeTestText(text ?? "")).toContain("anthropic/claude-opus-4-5");
);
const store = loadSessionStore(cfg.session.store); const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBe("openrouter"); // When selecting the default, overrides are cleared
expect(store[sessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5"); expect(store[sessionKey]?.providerOverride).toBeUndefined();
expect(store[sessionKey]?.modelOverride).toBeUndefined();
}); });
}); });
it("selects a model by index via /model <#>", async () => { it("selects a model by index via /model <#>", async () => {
+8 -2
View File
@@ -5,7 +5,7 @@ import {
loadSessionStore, loadSessionStore,
resolveStorePath, resolveStorePath,
type SessionEntry, type SessionEntry,
saveSessionStore, updateSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { parseAgentSessionKey } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js"; import { resolveCommandAuthorization } from "../command-auth.js";
@@ -90,7 +90,13 @@ export async function tryFastAbortFromMessage(params: {
entry.abortedLastRun = true; entry.abortedLastRun = true;
entry.updatedAt = Date.now(); entry.updatedAt = Date.now();
store[key] = entry; store[key] = entry;
await saveSessionStore(storePath, store); await updateSessionStore(storePath, (nextStore) => {
const nextEntry = nextStore[key] ?? entry;
if (!nextEntry) return;
nextEntry.abortedLastRun = true;
nextEntry.updatedAt = Date.now();
nextStore[key] = nextEntry;
});
} else if (abortKey) { } else if (abortKey) {
setAbortMemory(abortKey, true); setAbortMemory(abortKey, true);
} }
@@ -14,7 +14,7 @@ import {
resolveAgentIdFromSessionKey, resolveAgentIdFromSessionKey,
resolveSessionTranscriptPath, resolveSessionTranscriptPath,
type SessionEntry, type SessionEntry,
saveSessionStore, updateSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
@@ -383,6 +383,7 @@ export async function runAgentTurnWithFallback(params: {
params.activeSessionStore && params.activeSessionStore &&
params.storePath params.storePath
) { ) {
const sessionKey = params.sessionKey;
const corruptedSessionId = params.getActiveSessionEntry()?.sessionId; const corruptedSessionId = params.getActiveSessionEntry()?.sessionId;
defaultRuntime.error( defaultRuntime.error(
`Session history corrupted (Gemini function call ordering). Resetting session: ${params.sessionKey}`, `Session history corrupted (Gemini function call ordering). Resetting session: ${params.sessionKey}`,
@@ -399,9 +400,13 @@ export async function runAgentTurnWithFallback(params: {
} }
} }
// Remove session entry from store // Keep the in-memory snapshot consistent with the on-disk store reset.
delete params.activeSessionStore[params.sessionKey]; delete params.activeSessionStore[sessionKey];
await saveSessionStore(params.storePath, params.activeSessionStore);
// Remove session entry from store using a fresh, locked snapshot.
await updateSessionStore(params.storePath, (store) => {
delete store[sessionKey];
});
} catch (cleanupErr) { } catch (cleanupErr) {
defaultRuntime.error( defaultRuntime.error(
`Failed to reset corrupted session ${params.sessionKey}: ${String(cleanupErr)}`, `Failed to reset corrupted session ${params.sessionKey}: ${String(cleanupErr)}`,
+13 -5
View File
@@ -10,7 +10,7 @@ import {
resolveAgentIdFromSessionKey, resolveAgentIdFromSessionKey,
resolveSessionTranscriptPath, resolveSessionTranscriptPath,
type SessionEntry, type SessionEntry,
saveSessionStore, updateSessionStore,
updateSessionStoreEntry, updateSessionStoreEntry,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js"; import type { TypingMode } from "../../config/types.js";
@@ -156,7 +156,9 @@ export async function runReplyAgent(params: {
activeSessionEntry.updatedAt = Date.now(); activeSessionEntry.updatedAt = Date.now();
activeSessionStore[sessionKey] = activeSessionEntry; activeSessionStore[sessionKey] = activeSessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, activeSessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = activeSessionEntry as SessionEntry;
});
} }
} }
typing.cleanup(); typing.cleanup();
@@ -170,7 +172,9 @@ export async function runReplyAgent(params: {
activeSessionEntry.updatedAt = Date.now(); activeSessionEntry.updatedAt = Date.now();
activeSessionStore[sessionKey] = activeSessionEntry; activeSessionStore[sessionKey] = activeSessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, activeSessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = activeSessionEntry as SessionEntry;
});
} }
} }
typing.cleanup(); typing.cleanup();
@@ -224,7 +228,9 @@ export async function runReplyAgent(params: {
nextEntry.sessionFile = nextSessionFile; nextEntry.sessionFile = nextSessionFile;
activeSessionStore[sessionKey] = nextEntry; activeSessionStore[sessionKey] = nextEntry;
try { try {
await saveSessionStore(storePath, activeSessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = nextEntry;
});
} catch (err) { } catch (err) {
defaultRuntime.error( defaultRuntime.error(
`Failed to persist session reset after compaction failure (${sessionKey}): ${String(err)}`, `Failed to persist session reset after compaction failure (${sessionKey}): ${String(err)}`,
@@ -280,7 +286,9 @@ export async function runReplyAgent(params: {
activeSessionEntry.updatedAt = Date.now(); activeSessionEntry.updatedAt = Date.now();
activeSessionStore[sessionKey] = activeSessionEntry; activeSessionStore[sessionKey] = activeSessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, activeSessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = activeSessionEntry as SessionEntry;
});
} }
} }
+11 -2
View File
@@ -1,5 +1,5 @@
import type { SessionEntry } from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js";
import { saveSessionStore } from "../../config/sessions.js"; import { updateSessionStore } from "../../config/sessions.js";
import { setAbortMemory } from "./abort.js"; import { setAbortMemory } from "./abort.js";
export async function applySessionHints(params: { export async function applySessionHints(params: {
@@ -23,7 +23,16 @@ export async function applySessionHints(params: {
params.sessionEntry.updatedAt = Date.now(); params.sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = params.sessionEntry; params.sessionStore[params.sessionKey] = params.sessionEntry;
if (params.storePath) { if (params.storePath) {
await saveSessionStore(params.storePath, params.sessionStore); const sessionKey = params.sessionKey;
await updateSessionStore(params.storePath, (store) => {
const entry = store[sessionKey] ?? params.sessionEntry;
if (!entry) return;
store[sessionKey] = {
...entry,
abortedLastRun: false,
updatedAt: Date.now(),
};
});
} }
} else if (params.abortKey) { } else if (params.abortKey) {
setAbortMemory(params.abortKey, false); setAbortMemory(params.abortKey, false);
+13 -5
View File
@@ -1,6 +1,6 @@
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import type { SessionEntry } from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js";
import { saveSessionStore } from "../../config/sessions.js"; import { updateSessionStore } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js"; import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js";
import { parseAgentSessionKey } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../routing/session-key.js";
@@ -71,7 +71,9 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
params.sessionEntry.updatedAt = Date.now(); params.sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = params.sessionEntry; params.sessionStore[params.sessionKey] = params.sessionEntry;
if (params.storePath) { if (params.storePath) {
await saveSessionStore(params.storePath, params.sessionStore); await updateSessionStore(params.storePath, (store) => {
store[params.sessionKey] = params.sessionEntry as SessionEntry;
});
} }
} }
return { return {
@@ -107,7 +109,9 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
params.sessionEntry.updatedAt = Date.now(); params.sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = params.sessionEntry; params.sessionStore[params.sessionKey] = params.sessionEntry;
if (params.storePath) { if (params.storePath) {
await saveSessionStore(params.storePath, params.sessionStore); await updateSessionStore(params.storePath, (store) => {
store[params.sessionKey] = params.sessionEntry as SessionEntry;
});
} }
} }
const label = const label =
@@ -190,7 +194,9 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
abortTarget.entry.updatedAt = Date.now(); abortTarget.entry.updatedAt = Date.now();
params.sessionStore[abortTarget.key] = abortTarget.entry; params.sessionStore[abortTarget.key] = abortTarget.entry;
if (params.storePath) { if (params.storePath) {
await saveSessionStore(params.storePath, params.sessionStore); await updateSessionStore(params.storePath, (store) => {
store[abortTarget.key] = abortTarget.entry as SessionEntry;
});
} }
} else if (params.command.abortKey) { } else if (params.command.abortKey) {
setAbortMemory(params.command.abortKey, true); setAbortMemory(params.command.abortKey, true);
@@ -215,7 +221,9 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
abortTarget.entry.updatedAt = Date.now(); abortTarget.entry.updatedAt = Date.now();
params.sessionStore[abortTarget.key] = abortTarget.entry; params.sessionStore[abortTarget.key] = abortTarget.entry;
if (params.storePath) { if (params.storePath) {
await saveSessionStore(params.storePath, params.sessionStore); await updateSessionStore(params.storePath, (store) => {
store[abortTarget.key] = abortTarget.entry as SessionEntry;
});
} }
} else if (params.command.abortKey) { } else if (params.command.abortKey) {
setAbortMemory(params.command.abortKey, true); setAbortMemory(params.command.abortKey, true);
@@ -2,7 +2,7 @@ import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope
import type { ModelAliasIndex } from "../../agents/model-selection.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js"; import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
@@ -288,7 +288,9 @@ export async function handleDirectiveOnly(params: {
sessionEntry.updatedAt = Date.now(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
} }
if (modelSelection) { if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
@@ -1,4 +1,4 @@
import { normalizeProviderId } from "../../agents/model-selection.js"; import { type ModelRef, normalizeProviderId } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
export type ModelPickerCatalogEntry = { export type ModelPickerCatalogEntry = {
@@ -7,11 +7,7 @@ export type ModelPickerCatalogEntry = {
name?: string; name?: string;
}; };
export type ModelPickerItem = { export type ModelPickerItem = ModelRef;
model: string;
providers: string[];
providerModels: Record<string, string>;
};
const MODEL_PICK_PROVIDER_PREFERENCE = [ const MODEL_PICK_PROVIDER_PREFERENCE = [
"anthropic", "anthropic",
@@ -31,68 +27,43 @@ const MODEL_PICK_PROVIDER_PREFERENCE = [
"lmstudio", "lmstudio",
] as const; ] as const;
function normalizeModelFamilyId(id: string): string { const PROVIDER_RANK = new Map<string, number>(
const trimmed = id.trim(); MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]),
if (!trimmed) return trimmed; );
const parts = trimmed.split("/").filter(Boolean);
return parts.length > 0 ? (parts[parts.length - 1] ?? trimmed) : trimmed;
}
function sortProvidersForPicker(providers: string[]): string[] { function compareProvidersForPicker(a: string, b: string): number {
const pref = new Map<string, number>( const pa = PROVIDER_RANK.get(a);
MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]), const pb = PROVIDER_RANK.get(b);
); if (pa !== undefined && pb !== undefined) return pa - pb;
return providers.sort((a, b) => { if (pa !== undefined) return -1;
const pa = pref.get(a); if (pb !== undefined) return 1;
const pb = pref.get(b); return a.localeCompare(b);
if (pa !== undefined && pb !== undefined) return pa - pb;
if (pa !== undefined) return -1;
if (pb !== undefined) return 1;
return a.localeCompare(b);
});
} }
export function buildModelPickerItems(catalog: ModelPickerCatalogEntry[]): ModelPickerItem[] { export function buildModelPickerItems(catalog: ModelPickerCatalogEntry[]): ModelPickerItem[] {
const byModel = new Map<string, { providerModels: Record<string, string> }>(); const seen = new Set<string>();
const out: ModelPickerItem[] = [];
for (const entry of catalog) { for (const entry of catalog) {
const provider = normalizeProviderId(entry.provider); const provider = normalizeProviderId(entry.provider);
const model = normalizeModelFamilyId(entry.id); const model = entry.id?.trim();
if (!provider || !model) continue; if (!provider || !model) continue;
const existing = byModel.get(model);
if (existing) {
existing.providerModels[provider] = entry.id;
continue;
}
byModel.set(model, { providerModels: { [provider]: entry.id } });
}
const out: ModelPickerItem[] = [];
for (const [model, data] of byModel.entries()) {
const providers = sortProvidersForPicker(Object.keys(data.providerModels));
out.push({ model, providers, providerModels: data.providerModels });
}
out.sort((a, b) => a.model.toLowerCase().localeCompare(b.model.toLowerCase()));
return out;
}
export function pickProviderForModel(params: { const key = `${provider}/${model}`;
item: ModelPickerItem; if (seen.has(key)) continue;
preferredProvider?: string; seen.add(key);
}): { provider: string; model: string } | null {
const preferred = params.preferredProvider out.push({ model, provider });
? normalizeProviderId(params.preferredProvider)
: undefined;
if (preferred && params.item.providerModels[preferred]) {
return {
provider: preferred,
model: params.item.providerModels[preferred],
};
} }
const first = params.item.providers[0];
if (!first) return null; // Sort by provider preference first, then by model name
return { out.sort((a, b) => {
provider: first, const providerOrder = compareProvidersForPicker(a.provider, b.provider);
model: params.item.providerModels[first] ?? params.item.model, if (providerOrder !== 0) return providerOrder;
}; return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
});
return out;
} }
export function resolveProviderEndpointLabel( export function resolveProviderEndpointLabel(
@@ -18,7 +18,6 @@ import {
import { import {
buildModelPickerItems, buildModelPickerItems,
type ModelPickerCatalogEntry, type ModelPickerCatalogEntry,
pickProviderForModel,
resolveProviderEndpointLabel, resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js"; } from "./directive-handling.model-picker.js";
import type { InlineDirectives } from "./directive-handling.parse.js"; import type { InlineDirectives } from "./directive-handling.parse.js";
@@ -126,7 +125,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
const current = `${params.provider}/${params.model}`; const current = `${params.provider}/${params.model}`;
const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"]; const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"];
for (const [idx, item] of items.entries()) { for (const [idx, item] of items.entries()) {
lines.push(`${idx + 1}) ${item.model} — ${item.providers.join(", ")}`); lines.push(`${idx + 1}) ${item.provider}/${item.model}`);
} }
lines.push("", "More: /model status"); lines.push("", "More: /model status");
return { text: lines.join("\n") }; return { text: lines.join("\n") };
@@ -236,22 +235,13 @@ export function resolveModelSelectionFromDirective(params: {
errorText: `Invalid model selection "${raw}". Use /model to list.`, errorText: `Invalid model selection "${raw}". Use /model to list.`,
}; };
} }
const picked = pickProviderForModel({ const key = `${item.provider}/${item.model}`;
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
errorText: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const aliases = params.aliasIndex.byKey.get(key); const aliases = params.aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined; const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = { modelSelection = {
provider: picked.provider, provider: item.provider,
model: picked.model, model: item.model,
isDefault: picked.provider === params.defaultProvider && picked.model === params.defaultModel, isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel,
...(alias ? { alias } : {}), ...(alias ? { alias } : {}),
}; };
} else { } else {
@@ -14,7 +14,7 @@ import {
resolveModelRefFromString, resolveModelRefFromString,
} from "../../agents/model-selection.js"; } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { resolveProfileOverride } from "./directive-handling.auth.js"; import { resolveProfileOverride } from "./directive-handling.auth.js";
@@ -184,7 +184,9 @@ export async function persistInlineDirectives(params: {
sessionEntry.updatedAt = Date.now(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
} }
if (elevatedChanged) { if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel; const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
+4 -2
View File
@@ -9,7 +9,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { import {
resolveSessionFilePath, resolveSessionFilePath,
type SessionEntry, type SessionEntry,
saveSessionStore, updateSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js"; import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
@@ -276,7 +276,9 @@ export async function runPreparedReply(
sessionEntry.updatedAt = Date.now(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
} }
} }
} }
+7 -3
View File
@@ -10,7 +10,7 @@ import {
resolveThinkingDefault, resolveThinkingDefault,
} from "../../agents/model-selection.js"; } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import type { ThinkLevel } from "./directives.js"; import type { ThinkLevel } from "./directives.js";
export type ModelDirectiveSelection = { export type ModelDirectiveSelection = {
@@ -189,7 +189,9 @@ export async function createModelSelectionState(params: {
sessionEntry.updatedAt = Date.now(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
} }
resetModelOverride = true; resetModelOverride = true;
} }
@@ -218,7 +220,9 @@ export async function createModelSelectionState(params: {
sessionEntry.updatedAt = Date.now(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
} }
} }
} }
+2 -10
View File
@@ -5,7 +5,7 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
import { prependSystemEvents } from "./session-updates.js"; import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => { describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events", async () => { it("adds a UTC timestamp to queued system events", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const timestamp = new Date("2026-01-12T20:19:17"); const timestamp = new Date("2026-01-12T20:19:17");
vi.setSystemTime(timestamp); vi.setSystemTime(timestamp);
@@ -20,15 +20,7 @@ describe("prependSystemEvents", () => {
prefixedBodyBase: "User: hi", prefixedBodyBase: "User: hi",
}); });
const expectedTimestamp = timestamp.toLocaleString("en-US", { const expectedTimestamp = "2026-01-12T20:19:17Z";
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
+25 -14
View File
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { buildChannelSummary } from "../../infra/channel-summary.js"; import { buildChannelSummary } from "../../infra/channel-summary.js";
import { drainSystemEventEntries } from "../../infra/system-events.js"; import { drainSystemEventEntries } from "../../infra/system-events.js";
@@ -25,16 +25,17 @@ export async function prependSystemEvents(params: {
return trimmed; return trimmed;
}; };
const formatSystemEventTimestamp = (ts: number) => const formatSystemEventTimestamp = (ts: number) => {
new Date(ts).toLocaleString("en-US", { const date = new Date(ts);
hour12: false, if (Number.isNaN(date.getTime())) return "unknown-time";
year: "numeric", const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
month: "2-digit", const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
day: "2-digit", const dd = String(date.getUTCDate()).padStart(2, "0");
hour: "2-digit", const hh = String(date.getUTCHours()).padStart(2, "0");
minute: "2-digit", const min = String(date.getUTCMinutes()).padStart(2, "0");
second: "2-digit", const sec = String(date.getUTCSeconds()).padStart(2, "0");
}); return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
};
const systemLines: string[] = []; const systemLines: string[] = [];
const queued = drainSystemEventEntries(params.sessionKey); const queued = drainSystemEventEntries(params.sessionKey);
@@ -110,7 +111,9 @@ export async function ensureSkillSnapshot(params: {
}; };
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = { ...store[sessionKey], ...nextEntry };
});
} }
systemSent = true; systemSent = true;
} }
@@ -142,7 +145,9 @@ export async function ensureSkillSnapshot(params: {
}; };
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = { ...store[sessionKey], ...nextEntry };
});
} }
} }
@@ -167,7 +172,13 @@ export async function incrementCompactionCount(params: {
updatedAt: now, updatedAt: now,
}; };
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = {
...store[sessionKey],
compactionCount: nextCount,
updatedAt: now,
};
});
} }
return nextCount; return nextCount;
} }
+19 -2
View File
@@ -20,7 +20,7 @@ import {
resolveStorePath, resolveStorePath,
type SessionEntry, type SessionEntry,
type SessionScope, type SessionScope,
saveSessionStore, updateSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { normalizeMainKey } from "../../routing/session-key.js"; import { normalizeMainKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js"; import { resolveCommandAuthorization } from "../command-auth.js";
@@ -188,6 +188,11 @@ export async function initSessionState(params: {
} }
const baseEntry = !isNewSession && freshEntry ? entry : undefined; const baseEntry = !isNewSession && freshEntry ? entry : undefined;
// Track the originating channel/to for announce routing (subagent announce-back).
const lastChannel =
(ctx.OriginatingChannel as string | undefined)?.trim() || baseEntry?.lastChannel;
const lastTo = ctx.OriginatingTo?.trim() || ctx.To?.trim() || baseEntry?.lastTo;
const lastAccountId = ctx.AccountId?.trim() || baseEntry?.lastAccountId;
sessionEntry = { sessionEntry = {
...baseEntry, ...baseEntry,
sessionId, sessionId,
@@ -212,6 +217,10 @@ export async function initSessionState(params: {
subject: baseEntry?.subject, subject: baseEntry?.subject,
room: baseEntry?.room, room: baseEntry?.room,
space: baseEntry?.space, space: baseEntry?.space,
// Track originating channel for subagent announce routing.
lastChannel,
lastTo,
lastAccountId,
}; };
if (groupResolution?.channel) { if (groupResolution?.channel) {
const channel = groupResolution.channel; const channel = groupResolution.channel;
@@ -270,7 +279,15 @@ export async function initSessionState(params: {
); );
} }
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
if (store[groupResolution.legacyKey] && !store[sessionKey]) {
store[sessionKey] = store[groupResolution.legacyKey];
}
delete store[groupResolution.legacyKey];
}
store[sessionKey] = { ...store[sessionKey], ...sessionEntry };
});
const sessionCtx: TemplateContext = { const sessionCtx: TemplateContext = {
...ctx, ...ctx,
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { applyTemplate, type TemplateContext } from "./templating.js";
describe("applyTemplate", () => {
it("renders primitive values", () => {
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
const overrides = ctx as Record<string, unknown>;
overrides.MessageSid = 42;
overrides.IsNewSession = true;
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
});
it("renders arrays of primitives", () => {
const ctx = { MediaPaths: ["a"] } as TemplateContext;
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
});
it("drops object values", () => {
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
});
it("renders missing placeholders as empty", () => {
const ctx: TemplateContext = {};
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
});
});
+31 -1
View File
@@ -24,6 +24,9 @@ export type MsgContext = {
AccountId?: string; AccountId?: string;
ParentSessionKey?: string; ParentSessionKey?: string;
MessageSid?: string; MessageSid?: string;
MessageSids?: string[];
MessageSidFirst?: string;
MessageSidLast?: string;
ReplyToId?: string; ReplyToId?: string;
ReplyToBody?: string; ReplyToBody?: string;
ReplyToSender?: string; ReplyToSender?: string;
@@ -78,11 +81,38 @@ export type TemplateContext = MsgContext & {
IsNewSession?: string; IsNewSession?: string;
}; };
function formatTemplateValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "symbol" || typeof value === "function") {
return value.toString();
}
if (Array.isArray(value)) {
return value
.flatMap((entry) => {
if (entry == null) return [];
if (typeof entry === "string") return [entry];
if (typeof entry === "number" || typeof entry === "boolean" || typeof entry === "bigint") {
return [String(entry)];
}
return [];
})
.join(",");
}
if (typeof value === "object") {
return "";
}
return "";
}
// Simple {{Placeholder}} interpolation using inbound message context. // Simple {{Placeholder}} interpolation using inbound message context.
export function applyTemplate(str: string | undefined, ctx: TemplateContext) { export function applyTemplate(str: string | undefined, ctx: TemplateContext) {
if (!str) return ""; if (!str) return "";
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
const value = ctx[key as keyof TemplateContext]; const value = ctx[key as keyof TemplateContext];
return value == null ? "" : String(value); return formatTemplateValue(value);
}); });
} }
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+4 -3
View File
@@ -46,10 +46,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
} }
const service = resolveGatewayService(); const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false; let loaded = false;
try { try {
loaded = await service.isLoaded({ profile }); loaded = await service.isLoaded({ env: process.env });
} catch (err) { } catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -85,7 +84,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
port, port,
token: opts.token || cfg.gateway?.auth?.token || process.env.CLAWDBOT_GATEWAY_TOKEN, token: opts.token || cfg.gateway?.auth?.token || process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel: launchdLabel:
process.platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined, process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
}); });
try { try {
+6 -9
View File
@@ -21,10 +21,9 @@ export async function runDaemonUninstall() {
export async function runDaemonStart() { export async function runDaemonStart() {
const service = resolveGatewayService(); const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false; let loaded = false;
try { try {
loaded = await service.isLoaded({ profile }); loaded = await service.isLoaded({ env: process.env });
} catch (err) { } catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -38,7 +37,7 @@ export async function runDaemonStart() {
return; return;
} }
try { try {
await service.restart({ profile, stdout: process.stdout }); await service.restart({ env: process.env, stdout: process.stdout });
} catch (err) { } catch (err) {
defaultRuntime.error(`Gateway start failed: ${String(err)}`); defaultRuntime.error(`Gateway start failed: ${String(err)}`);
for (const hint of renderGatewayServiceStartHints()) { for (const hint of renderGatewayServiceStartHints()) {
@@ -50,10 +49,9 @@ export async function runDaemonStart() {
export async function runDaemonStop() { export async function runDaemonStop() {
const service = resolveGatewayService(); const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false; let loaded = false;
try { try {
loaded = await service.isLoaded({ profile }); loaded = await service.isLoaded({ env: process.env });
} catch (err) { } catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -64,7 +62,7 @@ export async function runDaemonStop() {
return; return;
} }
try { try {
await service.stop({ profile, stdout: process.stdout }); await service.stop({ env: process.env, stdout: process.stdout });
} catch (err) { } catch (err) {
defaultRuntime.error(`Gateway stop failed: ${String(err)}`); defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -78,10 +76,9 @@ export async function runDaemonStop() {
*/ */
export async function runDaemonRestart(): Promise<boolean> { export async function runDaemonRestart(): Promise<boolean> {
const service = resolveGatewayService(); const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false; let loaded = false;
try { try {
loaded = await service.isLoaded({ profile }); loaded = await service.isLoaded({ env: process.env });
} catch (err) { } catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -95,7 +92,7 @@ export async function runDaemonRestart(): Promise<boolean> {
return false; return false;
} }
try { try {
await service.restart({ profile, stdout: process.stdout }); await service.restart({ env: process.env, stdout: process.stdout });
return true; return true;
} catch (err) { } catch (err) {
defaultRuntime.error(`Gateway restart failed: ${String(err)}`); defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
+1 -1
View File
@@ -112,7 +112,7 @@ export async function gatherDaemonStatus(
): Promise<DaemonStatus> { ): Promise<DaemonStatus> {
const service = resolveGatewayService(); const service = resolveGatewayService();
const [loaded, command, runtime] = await Promise.all([ const [loaded, command, runtime] = await Promise.all([
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false), service.isLoaded({ env: process.env }).catch(() => false),
service.readCommand(process.env).catch(() => null), service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch(() => undefined), service.readRuntime(process.env).catch(() => undefined),
]); ]);
+2 -2
View File
@@ -262,12 +262,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
if (legacyServices.length > 0 || extraServices.length > 0) { if (legacyServices.length > 0 || extraServices.length > 0) {
defaultRuntime.error( defaultRuntime.error(
errorText( errorText(
"Recommendation: run a single gateway per machine. One gateway supports multiple agents.", "Recommendation: run a single gateway per machine for most setups. One gateway supports multiple agents (see docs: /gateway#multiple-gateways-same-host).",
), ),
); );
defaultRuntime.error( defaultRuntime.error(
errorText( errorText(
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", "If you need multiple gateways (e.g., a rescue bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
), ),
); );
spacer(); spacer();
+1 -1
View File
@@ -89,7 +89,7 @@ export async function maybeExplainGatewayServiceStop() {
const service = resolveGatewayService(); const service = resolveGatewayService();
let loaded: boolean | null = null; let loaded: boolean | null = null;
try { try {
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); loaded = await service.isLoaded({ env: process.env });
} catch { } catch {
loaded = null; loaded = null;
} }
BIN
View File
Binary file not shown.
+37 -21
View File
@@ -36,7 +36,7 @@ import {
resolveAgentIdFromSessionKey, resolveAgentIdFromSessionKey,
resolveSessionFilePath, resolveSessionFilePath,
type SessionEntry, type SessionEntry,
saveSessionStore, updateSessionStore,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { import {
clearAgentRunContext, clearAgentRunContext,
@@ -173,7 +173,9 @@ export async function agentCommand(
skillsSnapshot, skillsSnapshot,
}; };
sessionStore[sessionKey] = next; sessionStore[sessionKey] = next;
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = next;
});
sessionEntry = next; sessionEntry = next;
} }
@@ -188,7 +190,9 @@ export async function agentCommand(
} }
applyVerboseOverride(next, verboseOverride); applyVerboseOverride(next, verboseOverride);
sessionStore[sessionKey] = next; sessionStore[sessionKey] = next;
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = next;
});
} }
const agentModelPrimary = resolveAgentModelPrimary(cfg, sessionAgentId); const agentModelPrimary = resolveAgentModelPrimary(cfg, sessionAgentId);
@@ -239,6 +243,7 @@ export async function agentCommand(
} }
if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) { if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) {
const entry = sessionEntry;
const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider; const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider;
const overrideModel = sessionEntry.modelOverride?.trim(); const overrideModel = sessionEntry.modelOverride?.trim();
if (overrideModel) { if (overrideModel) {
@@ -248,11 +253,13 @@ export async function agentCommand(
allowedModelKeys.size > 0 && allowedModelKeys.size > 0 &&
!allowedModelKeys.has(key) !allowedModelKeys.has(key)
) { ) {
delete sessionEntry.providerOverride; delete entry.providerOverride;
delete sessionEntry.modelOverride; delete entry.modelOverride;
sessionEntry.updatedAt = Date.now(); entry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = entry;
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = entry;
});
} }
} }
} }
@@ -271,15 +278,21 @@ export async function agentCommand(
model = storedModelOverride; model = storedModelOverride;
} }
} }
if (sessionEntry?.authProfileOverride) { if (sessionEntry) {
const store = ensureAuthProfileStore(); const authProfileId = sessionEntry.authProfileOverride;
const profile = store.profiles[sessionEntry.authProfileOverride]; if (authProfileId) {
if (!profile || profile.provider !== provider) { const entry = sessionEntry;
delete sessionEntry.authProfileOverride; const store = ensureAuthProfileStore();
sessionEntry.updatedAt = Date.now(); const profile = store.profiles[authProfileId];
if (sessionStore && sessionKey) { if (!profile || profile.provider !== provider) {
sessionStore[sessionKey] = sessionEntry; delete entry.authProfileOverride;
await saveSessionStore(storePath, sessionStore); entry.updatedAt = Date.now();
if (sessionStore && sessionKey) {
sessionStore[sessionKey] = entry;
await updateSessionStore(storePath, (store) => {
store[sessionKey] = entry;
});
}
} }
} }
} }
@@ -304,10 +317,13 @@ export async function agentCommand(
} }
resolvedThinkLevel = "high"; resolvedThinkLevel = "high";
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") { if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") {
sessionEntry.thinkingLevel = "high"; const entry = sessionEntry;
sessionEntry.updatedAt = Date.now(); entry.thinkingLevel = "high";
sessionStore[sessionKey] = sessionEntry; entry.updatedAt = Date.now();
await saveSessionStore(storePath, sessionStore); sessionStore[sessionKey] = entry;
await updateSessionStore(storePath, (store) => {
store[sessionKey] = entry;
});
} }
} }
const sessionFile = resolveSessionFilePath(sessionId, sessionEntry, { const sessionFile = resolveSessionFilePath(sessionId, sessionEntry, {
+4 -2
View File
@@ -4,7 +4,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { isCliProvider } from "../../agents/model-selection.js"; import { isCliProvider } from "../../agents/model-selection.js";
import { hasNonzeroUsage } from "../../agents/usage.js"; import { hasNonzeroUsage } from "../../agents/usage.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
type RunResult = Awaited< type RunResult = Awaited<
ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]> ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]>
@@ -68,5 +68,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
next.totalTokens = promptTokens > 0 ? promptTokens : (usage.total ?? input); next.totalTokens = promptTokens > 0 ? promptTokens : (usage.total ?? input);
} }
sessionStore[sessionKey] = next; sessionStore[sessionKey] = next;
await saveSessionStore(storePath, sessionStore); await updateSessionStore(storePath, (store) => {
store[sessionKey] = next;
});
} }
+2 -2
View File
@@ -27,7 +27,7 @@ export async function maybeInstallDaemon(params: {
daemonRuntime?: GatewayDaemonRuntime; daemonRuntime?: GatewayDaemonRuntime;
}) { }) {
const service = resolveGatewayService(); const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); const loaded = await service.isLoaded({ env: process.env });
let shouldCheckLinger = false; let shouldCheckLinger = false;
let shouldInstall = true; let shouldInstall = true;
let daemonRuntime = params.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; let daemonRuntime = params.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
@@ -49,7 +49,7 @@ export async function maybeInstallDaemon(params: {
async (progress) => { async (progress) => {
progress.setLabel("Restarting Gateway daemon…"); progress.setLabel("Restarting Gateway daemon…");
await service.restart({ await service.restart({
profile: process.env.CLAWDBOT_PROFILE, env: process.env,
stdout: process.stdout, stdout: process.stdout,
}); });
progress.setLabel("Gateway daemon restarted."); progress.setLabel("Gateway daemon restarted.");
+3 -3
View File
@@ -37,7 +37,7 @@ export async function maybeRepairGatewayDaemon(params: {
if (params.healthOk) return; if (params.healthOk) return;
const service = resolveGatewayService(); const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); const loaded = await service.isLoaded({ env: process.env });
let serviceRuntime: Awaited<ReturnType<typeof service.readRuntime>> | undefined; let serviceRuntime: Awaited<ReturnType<typeof service.readRuntime>> | undefined;
if (loaded) { if (loaded) {
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined); serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
@@ -129,7 +129,7 @@ export async function maybeRepairGatewayDaemon(params: {
}); });
if (start) { if (start) {
await service.restart({ await service.restart({
profile: process.env.CLAWDBOT_PROFILE, env: process.env,
stdout: process.stdout, stdout: process.stdout,
}); });
await sleep(1500); await sleep(1500);
@@ -151,7 +151,7 @@ export async function maybeRepairGatewayDaemon(params: {
}); });
if (restart) { if (restart) {
await service.restart({ await service.restart({
profile: process.env.CLAWDBOT_PROFILE, env: process.env,
stdout: process.stdout, stdout: process.stdout,
}); });
await sleep(1500); await sleep(1500);
+3 -3
View File
@@ -89,7 +89,7 @@ export async function maybeMigrateLegacyGatewayService(
} }
const service = resolveGatewayService(); const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); const loaded = await service.isLoaded({ env: process.env });
if (loaded) { if (loaded) {
note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway"); note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway");
return; return;
@@ -280,9 +280,9 @@ export async function maybeScanExtraGatewayServices(options: DoctorOptions) {
note( note(
[ [
"Recommendation: run a single gateway per machine.", "Recommendation: run a single gateway per machine for most setups.",
"One gateway supports multiple agents.", "One gateway supports multiple agents.",
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", "If you need multiple gateways (e.g., a rescue bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
].join("\n"), ].join("\n"),
"Gateway recommendation", "Gateway recommendation",
); );
+1 -1
View File
@@ -209,7 +209,7 @@ export async function doctorCommand(
const service = resolveGatewayService(); const service = resolveGatewayService();
let loaded = false; let loaded = false;
try { try {
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); loaded = await service.isLoaded({ env: process.env });
} catch { } catch {
loaded = false; loaded = false;
} }
+1 -1
View File
@@ -183,7 +183,7 @@ export async function gatewayStatusCommand(
warnings.push({ warnings.push({
code: "multiple_gateways", code: "multiple_gateways",
message: message:
"Unconventional setup: multiple reachable gateways detected. Usually only one gateway should exist on a network.", "Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).",
targetIds: reachable.map((p) => p.target.id), targetIds: reachable.map((p) => p.target.id),
}); });
} }
+2 -3
View File
@@ -38,17 +38,16 @@ const selectStyled = <T>(params: Parameters<typeof select<T>>[0]) =>
async function stopGatewayIfRunning(runtime: RuntimeEnv) { async function stopGatewayIfRunning(runtime: RuntimeEnv) {
if (isNixMode) return; if (isNixMode) return;
const service = resolveGatewayService(); const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false; let loaded = false;
try { try {
loaded = await service.isLoaded({ profile }); loaded = await service.isLoaded({ env: process.env });
} catch (err) { } catch (err) {
runtime.error(`Gateway service check failed: ${String(err)}`); runtime.error(`Gateway service check failed: ${String(err)}`);
return; return;
} }
if (!loaded) return; if (!loaded) return;
try { try {
await service.stop({ profile, stdout: process.stdout }); await service.stop({ env: process.env, stdout: process.stdout });
} catch (err) { } catch (err) {
runtime.error(`Gateway stop failed: ${String(err)}`); runtime.error(`Gateway stop failed: ${String(err)}`);
} }
+1 -1
View File
@@ -133,7 +133,7 @@ export async function statusAllCommand(
try { try {
const service = resolveGatewayService(); const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([ const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false), service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined), service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null), service.readCommand(process.env).catch(() => null),
]); ]);
+1 -1
View File
@@ -10,7 +10,7 @@ export async function getDaemonStatusSummary(): Promise<{
try { try {
const service = resolveGatewayService(); const service = resolveGatewayService();
const [loaded, runtime, command] = await Promise.all([ const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false), service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined), service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null), service.readCommand(process.env).catch(() => null),
]); ]);
+2 -3
View File
@@ -55,10 +55,9 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
return false; return false;
} }
const service = resolveGatewayService(); const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false; let loaded = false;
try { try {
loaded = await service.isLoaded({ profile }); loaded = await service.isLoaded({ env: process.env });
} catch (err) { } catch (err) {
runtime.error(`Gateway service check failed: ${String(err)}`); runtime.error(`Gateway service check failed: ${String(err)}`);
return false; return false;
@@ -68,7 +67,7 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
return true; return true;
} }
try { try {
await service.stop({ profile, stdout: process.stdout }); await service.stop({ env: process.env, stdout: process.stdout });
} catch (err) { } catch (err) {
runtime.error(`Gateway stop failed: ${String(err)}`); runtime.error(`Gateway stop failed: ${String(err)}`);
} }
BIN
View File
Binary file not shown.
+6
View File
@@ -168,6 +168,7 @@ const FIELD_LABELS: Record<string, string> = {
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"messages.ackReaction": "Ack Reaction Emoji", "messages.ackReaction": "Ack Reaction Emoji",
"messages.ackReactionScope": "Ack Reaction Scope", "messages.ackReactionScope": "Ack Reaction Scope",
"messages.inbound.debounceMs": "Inbound Message Debounce (ms)",
"talk.apiKey": "Talk API Key", "talk.apiKey": "Talk API Key",
"channels.whatsapp": "WhatsApp", "channels.whatsapp": "WhatsApp",
"channels.telegram": "Telegram", "channels.telegram": "Telegram",
@@ -189,6 +190,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
"channels.signal.dmPolicy": "Signal DM Policy", "channels.signal.dmPolicy": "Signal DM Policy",
"channels.imessage.dmPolicy": "iMessage DM Policy", "channels.imessage.dmPolicy": "iMessage DM Policy",
"channels.discord.dm.policy": "Discord DM Policy", "channels.discord.dm.policy": "Discord DM Policy",
@@ -327,6 +329,8 @@ const FIELD_HELP: Record<string, string> = {
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
"messages.ackReactionScope": "messages.ackReactionScope":
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
"messages.inbound.debounceMs":
"Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).",
"channels.telegram.dmPolicy": "channels.telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
"channels.telegram.streamMode": "channels.telegram.streamMode":
@@ -348,6 +352,8 @@ const FIELD_HELP: Record<string, string> = {
"channels.whatsapp.dmPolicy": "channels.whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].',
"channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).",
"channels.whatsapp.debounceMs":
"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).",
"channels.signal.dmPolicy": "channels.signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].',
"channels.imessage.dmPolicy": "channels.imessage.dmPolicy":
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more