Compare commits

..
Author SHA1 Message Date
Peter Steinberger c51724f39b fix: cleanup suspended CLI processes (#978) (thanks @Nachx639) 2026-01-16 01:37:50 +00:00
Peter Steinberger a70fcc8ae0 chore(cli): set process title 2026-01-16 01:33:28 +00:00
Peter Steinberger fa521154ff fix: show disabled channels in onboarding picker 2026-01-16 01:29:25 +00:00
Peter Steinberger dffc1a4dcd fix: switch channel onboarding to single-select loop 2026-01-16 01:21:17 +00:00
Peter Steinberger 61e385b331 feat: add per-agent heartbeat config 2026-01-16 01:17:34 +00:00
Peter Steinberger f8f319713f fix: show provider/model labels in TUI 2026-01-16 01:13:14 +00:00
Peter Steinberger bb665bf22c fix: persist local gateway mode in configure wizard 2026-01-16 01:05:55 +00:00
Peter Steinberger f17dcb6213 fix: refine channel onboarding selection flow 2026-01-16 01:05:50 +00:00
Peter Steinberger d54c101100 docs: update release checklist 2026-01-16 00:42:48 +00:00
Peter Steinberger 96daa51d45 docs: document provider auth plugins 2026-01-16 00:42:48 +00:00
Peter Steinberger 6084421ec6 feat: add provider auth plugins 2026-01-16 00:42:28 +00:00
Peter Steinberger bca5c0d569 refactor: system prompt sections + docs/tests 2026-01-16 00:28:43 +00:00
Peter Steinberger 8c3cdba21c feat: sticky auth profile rotation + usage headers 2026-01-16 00:25:49 +00:00
voidandvoid e274b5a040 fix: heartbeat prompt + dedupe (#980) (thanks @voidserf)
- tighten default heartbeat prompt guidance
- suppress duplicate heartbeat alerts within 24h

Co-authored-by: void <voidserf@users.noreply.github.com>
2026-01-16 00:24:52 +00:00
Josh Lehman a139d35fa2 feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes

User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.

Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-16 00:11:33 +00:00
Peter Steinberger 8312a19f02 fix: handle Telegram General topic thread params (#848) (thanks @azade-c) 2026-01-16 00:08:56 +00:00
Peter Steinberger fe8b28cdd9 Merge pull request #848 from azade-c/fix/telegram-general-topic-messages
fix(telegram): skip message_thread_id for General topic messages
2026-01-16 00:08:07 +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
Azade 6146acbb69 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-15 23:32:30 +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
240 changed files with 9635 additions and 1962 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.
- 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)
- **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!
+60 -115
View File
@@ -2,7 +2,33 @@
## 2026.1.15 (unreleased)
- Agents: add CLI quick reference to the system prompt to avoid invented commands. (#953) — thanks @roshanasingh4.
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
- TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
- 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.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- 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.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- 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
@@ -13,144 +39,63 @@
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
### 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: 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: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
- Security: add `SECURITY.md` reporting policy.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- 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: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`.
- Docs: add multi-gateway guide (sidebar + nav).
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- 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
#### 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.
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- 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.
- 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.
- 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.
- 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.
- 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.
- Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.
- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- 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
- 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.
## 2026.1.14
#### Control UI / TUI
- Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)
- UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.
- UI: always apply `?token=` from URL (fixes unauthorized after re-onboard).
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
### Changes
- Usage: add MiniMax coding plan usage tracking.
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- 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: 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)`.
#### 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.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
- 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: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- 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: 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: 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.
- 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: 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
Submodule Peekaboo updated: 95ad7532c1...c1243a7978.
+19 -18
View File
@@ -474,22 +474,23 @@ Core contributors:
Thanks to all clawtributors:
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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>
+3 -1
View File
@@ -191,7 +191,7 @@ Notes:
- Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user.
- **`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.
- **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"`).
@@ -352,6 +352,7 @@ Allowlist matching notes:
- Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported.
- Use `*` to allow any sender/channel.
- 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:
- 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)
- `sticker`, `poll`, `permissions`
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
- Read/search/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Discord `timestamp`.
- `threadCreate`, `threadList`, `threadReply`
- `pinMessage`, `unpinMessage`, `listPins`
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
+88 -25
View File
@@ -27,17 +27,17 @@ Minimal config:
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
4) **Event Subscriptions** → enable events and subscribe to:
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
5) **Event Subscriptions** → enable events and subscribe to:
- `message.*` (includes edits/deletes/thread broadcasts)
- `app_mention`
- `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel`
- `channel_id_changed`
- `channel_rename`
- `pin_added`, `pin_removed`
5) Invite the bot to channels you want it to read.
6) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
7) App Home → enable the **Messages Tab** so users can DM the bot.
6) Invite the bot to channels you want it to read.
7) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
8) App Home → enable the **Messages Tab** so users can DM the bot.
Use the manifest below so scopes and events stay in sync.
@@ -63,27 +63,59 @@ Or via config:
}
```
## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
## User token (optional)
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
pins, reactions, emoji, member info). By default this stays read-only: reads
prefer the user token when present, and writes still use the bot token unless
you explicitly opt in. Even with `userTokenReadOnly: false`, the bot token stays
preferred for writes when it is available.
## Config writes
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
User tokens are configured in the config file (no env var support). For
multi-account, set `channels.slack.accounts.<id>.userToken`.
This happens when:
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
Disable with:
Example with bot + app + user tokens:
```json5
{
channels: { slack: { configWrites: false } }
channels: {
slack: {
enabled: true,
appToken: "xapp-...",
botToken: "xoxb-...",
userToken: "xoxp-..."
}
}
}
```
Example with userTokenReadOnly explicitly set (allow user token writes):
```json5
{
channels: {
slack: {
enabled: true,
appToken: "xapp-...",
botToken: "xoxb-...",
userToken: "xoxp-...",
userTokenReadOnly: false
}
}
}
```
### Token usage
- Read operations (history, reactions list, pins list, emoji list, member info,
search) prefer the user token when configured, otherwise the bot token.
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
no bot token is available, Clawdbot falls back to the user token.
## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
user scopes if you plan to configure a user token.
```json
{
@@ -133,6 +165,21 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
"commands",
"files:read",
"files:write"
],
"user": [
"channels:history",
"channels:read",
"groups:history",
"groups:read",
"im:history",
"im:read",
"mpim:history",
"mpim:read",
"users:read",
"reactions:read",
"pins:read",
"emoji:read",
"search:read"
]
}
},
@@ -149,7 +196,6 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
"reaction_removed",
"member_joined_channel",
"member_left_channel",
"channel_id_changed",
"channel_rename",
"pin_added",
"pin_removed"
@@ -166,7 +212,7 @@ Slack's Conversations API is type-scoped: you only need the scopes for the
conversation types you actually touch (channels, groups, im, mpim). See
https://api.channels.slack.com/docs/conversations-api for the overview.
### Required scopes
### Bot token scopes (required)
- `chat:write` (send/update/delete messages via `chat.postMessage`)
https://api.channels.slack.com/methods/chat.postMessage
- `im:write` (open DMs via `conversations.open` for user DMs)
@@ -188,6 +234,17 @@ https://api.channels.slack.com/docs/conversations-api for the overview.
- `files:write` (uploads via `files.uploadV2`)
https://api.channels.slack.com/messaging/files/uploading
### User token scopes (optional, read-only by default)
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
- `users:read`
- `reactions:read`
- `pins:read`
- `emoji:read`
- `search:read`
### Not needed today (but likely future)
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
@@ -269,11 +326,6 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Thread session isolation
Slack thread sessions are isolated by default. Configure with:
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
### Manual threading tags
For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
@@ -320,6 +372,17 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
| memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list |
## Security notes
- Writes default to the bot token so state-changing actions stay scoped to the
app's bot permissions and identity.
- Setting `userTokenReadOnly: false` allows the user token to be used for write
operations when a bot token is unavailable, which means actions run with the
installing user's access. Treat the user token as highly privileged and keep
action gates and allowlists tight.
- If you enable user-token writes, make sure the user token includes the write
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
`files:write`) or those operations will fail.
## Notes
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
+46
View File
@@ -188,6 +188,7 @@ Disable with:
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.
- 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.
- 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).
- 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)
- Use a chat id (`123456789`) or a username (`@name`) as the target.
- 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.sendMessage`: gate Telegram tool message sends.
- `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:
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
+4 -2
View File
@@ -296,8 +296,9 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
## Heartbeats
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
- **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session.
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior.
- **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally
via `agents.defaults.heartbeat` (fallback when no per-agent entries are set).
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`) + `HEARTBEAT_OK` skip behavior.
- Delivery defaults to the last used channel (or configured target).
## Reconnect behavior
@@ -330,6 +331,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- `agents.defaults.heartbeat.model` (optional override)
- `agents.defaults.heartbeat.target`
- `agents.defaults.heartbeat.to`
- `agents.list[].heartbeat.*` (per-agent overrides)
- `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable channel startup when false)
- `web.heartbeatSeconds`
+3 -1
View File
@@ -11,10 +11,12 @@ Interactive prompt to set up credentials, devices, and agent defaults.
Related:
- Gateway configuration reference: [Configuration](/gateway/configuration)
Notes:
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
## Examples
```bash
clawdbot configure
clawdbot configure --section models --section channels
```
+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
- 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
clawdbot gateway status
+7 -1
View File
@@ -22,6 +22,10 @@ clawdbot models set <model-or-alias>
clawdbot models scan
```
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
## Aliases + fallbacks
```bash
@@ -33,7 +37,9 @@ clawdbot models fallbacks list
```bash
clawdbot models auth add
clawdbot models auth login --provider <id>
clawdbot models auth setup-token
clawdbot models auth paste-token
```
`models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use
`clawdbot plugins list` to see which providers are installed.
+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
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 are owned by the gateway, not by clients.
+11
View File
@@ -48,6 +48,17 @@ If no explicit order is configured, Clawdbot uses a round‑robin order:
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
### Session stickiness (cache-friendly)
Clawdbot **pins the chosen auth profile per session** to keep provider caches warm.
It does **not** rotate on every request. The pinned profile is reused until:
- the session is reset (`/new` / `/reset`)
- a compaction completes (compaction count increments)
- the profile is in cooldown/disabled
Manual selection via `/model …@<profileId>` sets a **user override** for that session
and is not auto‑rotated until a new session starts.
### Why OAuth can “look lost”
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
+7
View File
@@ -15,6 +15,13 @@ Clawdbot supports “subscription auth” via OAuth for providers that offer it
- how we **reuse external CLI tokens** (Claude Code / Codex CLI)
- how to handle **multiple accounts** (profiles + per-session overrides)
Clawdbot also supports **provider plugins** that ship their own OAuth or API‑key
flows. Run them via:
```bash
clawdbot models auth login --provider <id>
```
## The token sink (why it exists)
OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app.
+28 -5
View File
@@ -20,12 +20,28 @@ The prompt is intentionally compact and uses fixed sections:
- **Workspace**: working directory (`agents.defaults.workspace`).
- **Workspace Files (injected)**: indicates bootstrap files are included below.
- **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.
- **Heartbeats**: heartbeat prompt and ack behavior.
- **Runtime**: host, OS, node, model, thinking level (one line).
- **Reasoning**: current visibility level + /reasoning toggle hint.
## Prompt modes
Clawdbot can render smaller system prompts for sub-agents. The runtime sets a
`promptMode` for each run (not a user-facing config):
- `full` (default): includes all sections above.
- `minimal`: used for sub-agents; omits **Skills**, **Memory Recall**, **Clawdbot
Self-Update**, **Model Aliases**, **User Identity**, **Reply Tags**,
**Messaging**, **Silent Replies**, and **Heartbeats**. Tooling, Workspace,
Sandbox, Current Date & Time (when known), Runtime, and injected context stay
available.
- `none`: returns only the base identity line.
When `promptMode=minimal`, extra injected prompts are labeled **Subagent
Context** instead of **Group Chat Context**.
## Workspace bootstrap injection
Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads:
@@ -46,12 +62,19 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
## 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 listed **user time** is already converted to `agents.defaults.userTimezone` (if set).
- The user’s **local time** (already converted).
- 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
+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.
## 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**.
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
@@ -31,10 +36,14 @@ unset, Clawdbot resolves the **host timezone at runtime** (no config write).
```json5
{
agent: { userTimezone: "America/Chicago" }
agents: { defaults: { userTimezone: "America/Chicago" } }
}
```
The system prompt includes:
- `User timezone: America/Chicago`
- `Current user time: 2026-01-05 15:26`
- `Current Date & Time` section with local time and timezone
- `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.
+1 -1
View File
@@ -44,7 +44,7 @@ Common methods + events:
| --- | --- | --- |
| Core | `connect`, `health`, `status` | `connect` must be first |
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
| Chat | `chat.history`, `chat.send`, `chat.abort` | WebChat uses these |
| Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these |
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
- No estimated costs; only the provider-reported windows.
## Where it shows up
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available.
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
+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)
+38
View File
@@ -455,6 +455,44 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
}
```
### Anthropic subscription + API key, MiniMax fallback
```json5
{
auth: {
profiles: {
"anthropic:subscription": {
provider: "anthropic",
mode: "oauth",
email: "user@example.com"
},
"anthropic:api": {
provider: "anthropic",
mode: "api_key"
}
},
order: {
anthropic: ["anthropic:subscription", "anthropic:api"]
}
},
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
apiKey: "${MINIMAX_API_KEY}"
}
}
},
agent: {
workspace: "~/clawd",
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["minimax/MiniMax-M2.1"]
}
}
}
```
### Work bot (restricted access)
```json5
{
+43 -2
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)
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`
Controls inbound/outbound prefixes and optional ack reactions.
@@ -1639,9 +1675,14 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
Per-agent heartbeats:
- Set `agents.list[].heartbeat` to enable or override heartbeat settings for a specific agent.
- If any agent entry defines `heartbeat`, **only those agents** run heartbeats; defaults
become the shared baseline for those agents.
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
@@ -2512,7 +2553,7 @@ Requires full Gateway restart:
### 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_STATE_DIR` (sessions/creds)
- `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
- **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`.
- **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.
+1 -1
View File
@@ -9,7 +9,7 @@ read_when:
Last updated: 2025-12-11
## 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.
- Fail fast with a clear error when the control port is already occupied.
+43 -6
View File
@@ -33,9 +33,9 @@ Example config:
## Defaults
- Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable).
- Interval: `30m` (set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable).
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
- The heartbeat prompt is sent **verbatim** as the user message. The system
prompt includes a “Heartbeat” section and the run is flagged internally.
@@ -49,8 +49,8 @@ The default prompt is intentionally broad:
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` to a
custom body (sent verbatim).
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` (or
`agents.list[].heartbeat.prompt`) to a custom body (sent verbatim).
## Response contract
@@ -77,7 +77,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
to: "+15551234567", // optional channel-specific override
prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.",
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
}
}
@@ -85,6 +85,39 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
}
```
### Per-agent heartbeats
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat`
(so you can set shared defaults once and override per agent).
Example: two agents, only the second agent runs heartbeats.
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m",
target: "last"
}
},
list: [
{ id: "main", default: true },
{
id: "ops",
heartbeat: {
every: "1h",
target: "whatsapp",
to: "+15551234567",
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."
}
}
]
}
}
```
### Field notes
- `every`: heartbeat interval (duration string; default unit = minutes).
@@ -100,7 +133,8 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
## Delivery behavior
- Heartbeats run in the **main session** (`main`, or `global` when scope is global).
- Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global`
when `session.scope = "global"`.
- If the main queue is busy, the heartbeat is skipped and retried later.
- If `target` resolves to no external destination, the run still happens but no
outbound message is sent.
@@ -149,6 +183,9 @@ You can enqueue a system event and trigger an immediate heartbeat with:
clawdbot wake --text "Check for urgent follow-ups" --mode now
```
If multiple agents have `heartbeat` configured, a manual wake runs each of those
agent heartbeats immediately.
Use `--mode next-heartbeat` to wait for the next scheduled tick.
## Reasoning delivery (optional)
+3 -1
View File
@@ -63,6 +63,8 @@ Install metadata is embedded in the service config:
- `CLAWDBOT_SERVICE_KIND=gateway`
- `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`)
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.
- `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.
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).
- `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)
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)
- `CLAWDBOT_CONFIG_PATH` — per-instance config file
@@ -37,6 +37,38 @@ clawdbot --profile main 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)
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.
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.
## 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
- **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`.
- 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).
+2 -2
View File
@@ -24,8 +24,8 @@ agent (with a session switcher for other sessions).
## How it’s wired
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort` and
events `chat`, `agent`, `presence`, `tick`, `health`.
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`,
`chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`.
- Session: defaults to the primary session (`main`, or `global` when scope is
global). The UI can switch between sessions.
- Onboarding uses a dedicated session to keep first‑run setup separate.
+50
View File
@@ -172,6 +172,56 @@ Plugins export either:
- A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }`
## Provider plugins (model auth)
Plugins can register **model provider auth** flows so users can run OAuth or
API-key setup inside Clawdbot (no external scripts needed).
Register a provider via `api.registerProvider(...)`. Each provider exposes one
or more auth methods (OAuth, API key, device code, etc.). These methods power:
- `clawdbot models auth login --provider <id> [--method <id>]`
Example:
```ts
api.registerProvider({
id: "acme",
label: "AcmeAI",
auth: [
{
id: "oauth",
label: "OAuth",
kind: "oauth",
run: async (ctx) => {
// Run OAuth flow and return auth profiles.
return {
profiles: [
{
profileId: "acme:default",
credential: {
type: "oauth",
provider: "acme",
access: "...",
refresh: "...",
expires: Date.now() + 3600 * 1000,
},
},
],
defaultModel: "acme/opus-1",
};
},
},
],
});
```
Notes:
- `run` receives a `ProviderAuthContext` with `prompter`, `runtime`,
`openUrl`, and `oauth.createVpsAwareHandlers` helpers.
- Return `configPatch` when you need to add default models or provider config.
- Return `defaultModel` so `--set-default` can update agent defaults.
### Register a messaging channel
Plugins can register **channel plugins** that behave like built‑in channels
+1 -1
View File
@@ -63,7 +63,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
7) **GitHub release + appcast**
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**.
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
- [ ] Attach artifacts: `npm pack` tarball (optional), `Clawdbot-X.Y.Z.zip`, and `Clawdbot-X.Y.Z.dSYM.zip` (if generated).
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
+1 -1
View File
@@ -108,7 +108,7 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
Default heartbeat prompt:
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
+1 -1
View File
@@ -175,7 +175,7 @@ Example:
## Heartbeats (proactive mode)
By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
Set `agents.defaults.heartbeat.every: "0m"` to disable.
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
+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?
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).
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?
`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:
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`.
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
Top‑level `channels.discord.requireMention` is not a supported key.
+1 -1
View File
@@ -54,7 +54,7 @@ They run immediately, are stripped before the model sees the message, and the re
Text + native (when enabled):
- `/help`
- `/commands`
- `/status` (show current status; includes a short provider usage/quota line when available)
- `/status` (show current status; includes provider usage/quota when available, plus OAuth/token status block when OAuth profiles exist)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/usage` (alias: `/status`)
- `/whoami` (show your sender id; alias: `/id`)
+2 -2
View File
@@ -50,8 +50,8 @@ read_when:
- Elevated mode docs live in [Elevated mode](/tools/elevated).
## Heartbeats
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true`.
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.list[].heartbeat.includeReasoning: true`.
## Web chat UI
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
+2 -1
View File
@@ -28,7 +28,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
The onboarding wizard generates a gateway token by default, so paste it here on first connect.
## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
- Stream tool calls + live tool output cards in Chat (agent events)
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`)
@@ -60,6 +60,7 @@ Notes:
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
- Stop:
- Click **Stop** (calls `chat.abort`)
- Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band
+2 -1
View File
@@ -19,7 +19,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
3) Ensure gateway auth is configured if you are not on loopback.
## How it works (behavior)
- The UI connects to the Gateway WebSocket and uses `chat.history` + `chat.send`.
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
- History is always fetched from the gateway (no local file watching).
- If the gateway is unreachable, WebChat is read-only.
+3 -1
View File
@@ -169,7 +169,6 @@
"json5": "^2.2.3",
"long": "5.3.2",
"markdown-it": "^14.1.0",
"node-llama-cpp": "3.14.5",
"osc-progress": "^0.2.0",
"playwright-core": "1.57.0",
"proper-lockfile": "^4.1.2",
@@ -181,6 +180,9 @@
"ws": "^8.19.0",
"zod": "^4.3.5"
},
"optionalDependencies": {
"node-llama-cpp": "3.14.5"
},
"devDependencies": {
"@grammyjs/types": "^3.23.0",
"@lit-labs/signals": "^0.2.0",
+175 -60
View File
@@ -122,9 +122,6 @@ importers:
markdown-it:
specifier: ^14.1.0
version: 14.1.0
node-llama-cpp:
specifier: 3.14.5
version: 3.14.5(typescript@5.9.3)
osc-progress:
specifier: ^0.2.0
version: 0.2.0
@@ -237,6 +234,10 @@ importers:
wireit:
specifier: ^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:
dependencies:
@@ -4942,7 +4943,8 @@ snapshots:
hono: 4.11.4
optional: true
'@huggingface/jinja@0.5.3': {}
'@huggingface/jinja@0.5.3':
optional: true
'@img/colour@1.0.0': {}
@@ -5081,8 +5083,10 @@ snapshots:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
optional: true
'@kwsites/promise-deferred@1.1.1': {}
'@kwsites/promise-deferred@1.1.1':
optional: true
'@lit-labs/signals@0.2.0':
dependencies:
@@ -5338,6 +5342,7 @@ snapshots:
'@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6)
'@octokit/types': 16.0.0
'@octokit/webhooks': 14.2.0
optional: true
'@octokit/auth-app@8.1.2':
dependencies:
@@ -5349,6 +5354,7 @@ snapshots:
toad-cache: 3.7.0
universal-github-app-jwt: 2.2.2
universal-user-agent: 7.0.3
optional: true
'@octokit/auth-oauth-app@9.0.3':
dependencies:
@@ -5357,6 +5363,7 @@ snapshots:
'@octokit/request': 10.0.7
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/auth-oauth-device@8.0.3':
dependencies:
@@ -5364,6 +5371,7 @@ snapshots:
'@octokit/request': 10.0.7
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/auth-oauth-user@6.0.2':
dependencies:
@@ -5372,13 +5380,16 @@ snapshots:
'@octokit/request': 10.0.7
'@octokit/types': 16.0.0
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':
dependencies:
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
optional: true
'@octokit/core@7.0.6':
dependencies:
@@ -5389,17 +5400,20 @@ snapshots:
'@octokit/types': 16.0.0
before-after-hook: 4.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/endpoint@11.0.2':
dependencies:
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/graphql@9.0.3':
dependencies:
'@octokit/request': 10.0.7
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/oauth-app@8.0.3':
dependencies:
@@ -5411,8 +5425,10 @@ snapshots:
'@octokit/oauth-methods': 6.0.2
'@types/aws-lambda': 8.10.159
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':
dependencies:
@@ -5420,24 +5436,30 @@ snapshots:
'@octokit/request': 10.0.7
'@octokit/request-error': 7.1.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)':
dependencies:
'@octokit/core': 7.0.6
optional: true
'@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)':
dependencies:
'@octokit/core': 7.0.6
'@octokit/types': 16.0.0
optional: true
'@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)':
dependencies:
'@octokit/core': 7.0.6
'@octokit/types': 16.0.0
optional: true
'@octokit/plugin-retry@8.0.3(@octokit/core@7.0.6)':
dependencies:
@@ -5445,16 +5467,19 @@ snapshots:
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
bottleneck: 2.19.5
optional: true
'@octokit/plugin-throttling@11.0.3(@octokit/core@7.0.6)':
dependencies:
'@octokit/core': 7.0.6
'@octokit/types': 16.0.0
bottleneck: 2.19.5
optional: true
'@octokit/request-error@7.1.0':
dependencies:
'@octokit/types': 16.0.0
optional: true
'@octokit/request@10.0.7':
dependencies:
@@ -5463,18 +5488,22 @@ snapshots:
'@octokit/types': 16.0.0
fast-content-type-parse: 3.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/types@16.0.0':
dependencies:
'@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':
dependencies:
'@octokit/openapi-webhooks-types': 12.1.0
'@octokit/request-error': 7.1.0
'@octokit/webhooks-methods': 6.0.0
optional: true
'@oxc-project/types@0.107.0': {}
@@ -6111,7 +6140,8 @@ snapshots:
'@thi.ng/errors@2.6.0':
optional: true
'@tinyhttp/content-disposition@2.2.2': {}
'@tinyhttp/content-disposition@2.2.2':
optional: true
'@tokenizer/inflate@0.4.1':
dependencies:
@@ -6127,7 +6157,8 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/aws-lambda@8.10.159': {}
'@types/aws-lambda@8.10.159':
optional: true
'@types/body-parser@1.19.6':
dependencies:
@@ -6426,7 +6457,8 @@ snapshots:
another-json@0.2.0: {}
ansi-escapes@6.2.1: {}
ansi-escapes@6.2.1:
optional: true
ansi-regex@5.0.1: {}
@@ -6445,12 +6477,14 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
aproba@2.1.0: {}
aproba@2.1.0:
optional: true
are-we-there-yet@3.0.1:
dependencies:
delegates: 1.0.0
readable-stream: 3.6.2
optional: true
argparse@2.0.1: {}
@@ -6469,6 +6503,7 @@ snapshots:
async-retry@1.3.3:
dependencies:
retry: 0.13.1
optional: true
asynckit@0.4.0: {}
@@ -6508,7 +6543,8 @@ snapshots:
base64-js@1.5.1: {}
before-after-hook@4.0.0: {}
before-after-hook@4.0.0:
optional: true
bignumber.js@9.3.1: {}
@@ -6593,7 +6629,8 @@ snapshots:
chalk@5.6.2: {}
chmodrp@1.0.2: {}
chmodrp@1.0.2:
optional: true
chokidar@3.6.0:
dependencies:
@@ -6611,7 +6648,8 @@ snapshots:
dependencies:
readdirp: 5.0.0
chownr@2.0.0: {}
chownr@2.0.0:
optional: true
chownr@3.0.0: {}
@@ -6621,7 +6659,8 @@ snapshots:
mitt: 3.0.1
zod: 3.25.76
ci-info@4.3.1: {}
ci-info@4.3.1:
optional: true
class-variance-authority@0.7.1:
dependencies:
@@ -6630,6 +6669,7 @@ snapshots:
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
optional: true
cli-highlight@2.1.11:
dependencies:
@@ -6640,7 +6680,8 @@ snapshots:
parse5-htmlparser2-tree-adapter: 6.0.1
yargs: 16.2.0
cli-spinners@2.9.2: {}
cli-spinners@2.9.2:
optional: true
cliui@7.0.4:
dependencies:
@@ -6653,6 +6694,7 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
optional: true
clsx@2.1.1: {}
@@ -6672,6 +6714,7 @@ snapshots:
yargs: 17.7.2
transitivePeerDependencies:
- supports-color
optional: true
codec-parser@2.5.0:
optional: true
@@ -6684,19 +6727,22 @@ snapshots:
color-name@1.1.4: {}
color-support@1.1.3: {}
color-support@1.1.3:
optional: true
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@10.0.1: {}
commander@10.0.1:
optional: true
commander@14.0.2: {}
commander@8.3.0: {}
console-control-strings@1.1.0: {}
console-control-strings@1.1.0:
optional: true
content-disposition@1.0.1: {}
@@ -6730,11 +6776,13 @@ snapshots:
dependencies:
ms: 2.1.3
deep-extend@0.6.0: {}
deep-extend@0.6.0:
optional: true
delayed-stream@1.0.0: {}
delegates@1.0.0: {}
delegates@1.0.0:
optional: true
depd@2.0.0: {}
@@ -6772,7 +6820,8 @@ snapshots:
ee-first@1.1.1: {}
emoji-regex@10.6.0: {}
emoji-regex@10.6.0:
optional: true
emoji-regex@8.0.0: {}
@@ -6782,7 +6831,8 @@ snapshots:
entities@4.5.0: {}
env-var@7.5.0: {}
env-var@7.5.0:
optional: true
es-define-property@1.0.1: {}
@@ -6885,7 +6935,8 @@ snapshots:
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: {}
@@ -6925,11 +6976,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
filename-reserved-regex@3.0.0: {}
filename-reserved-regex@3.0.0:
optional: true
filenamify@6.0.0:
dependencies:
filename-reserved-regex: 3.0.0
optional: true
fill-range@7.1.1:
dependencies:
@@ -6976,10 +7029,12 @@ snapshots:
graceful-fs: 4.2.11
jsonfile: 6.2.0
universalify: 2.0.1
optional: true
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
optional: true
fsevents@2.3.2:
optional: true
@@ -6999,6 +7054,7 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
optional: true
gaxios@7.1.3:
dependencies:
@@ -7108,7 +7164,8 @@ snapshots:
dependencies:
has-symbols: 1.1.0
has-unicode@2.0.1: {}
has-unicode@2.0.1:
optional: true
hashery@1.4.0:
dependencies:
@@ -7158,13 +7215,15 @@ snapshots:
ieee754@1.2.1: {}
ignore@7.0.5: {}
ignore@7.0.5:
optional: true
immediate@3.0.6: {}
inherits@2.0.4: {}
ini@1.3.8: {}
ini@1.3.8:
optional: true
ipaddr.js@1.9.1: {}
@@ -7191,6 +7250,7 @@ snapshots:
strip-ansi: 7.1.2
optionalDependencies:
'@reflink/reflink': 0.1.19
optional: true
is-binary-path@2.1.0:
dependencies:
@@ -7205,12 +7265,14 @@ snapshots:
is-fullwidth-code-point@5.1.0:
dependencies:
get-east-asian-width: 1.4.0
optional: true
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-interactive@2.0.0: {}
is-interactive@2.0.0:
optional: true
is-network-error@1.3.0: {}
@@ -7220,9 +7282,11 @@ snapshots:
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: {}
@@ -7230,7 +7294,8 @@ snapshots:
isexe@2.0.0: {}
isexe@3.1.1: {}
isexe@3.1.1:
optional: true
istanbul-lib-coverage@3.2.2: {}
@@ -7283,6 +7348,7 @@ snapshots:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
optional: true
jsonwebtoken@9.0.3:
dependencies:
@@ -7340,9 +7406,11 @@ snapshots:
dependencies:
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:
optional: true
@@ -7418,7 +7486,8 @@ snapshots:
lodash.clonedeep@4.5.0: {}
lodash.debounce@4.0.8: {}
lodash.debounce@4.0.8:
optional: true
lodash.includes@4.3.0: {}
@@ -7440,11 +7509,13 @@ snapshots:
dependencies:
chalk: 5.6.2
is-unicode-supported: 1.3.0
optional: true
log-symbols@7.0.1:
dependencies:
is-unicode-supported: 2.1.0
yoctocolors: 2.1.2
optional: true
loglevel@1.9.2: {}
@@ -7455,6 +7526,7 @@ snapshots:
lowdb@7.0.1:
dependencies:
steno: 4.0.2
optional: true
lru-cache@10.4.3: {}
@@ -7535,6 +7607,7 @@ snapshots:
memory-stream@1.0.0:
dependencies:
readable-stream: 3.6.2
optional: true
merge-descriptors@2.0.0: {}
@@ -7557,7 +7630,8 @@ snapshots:
dependencies:
mime-db: 1.54.0
mimic-function@5.0.1: {}
mimic-function@5.0.1:
optional: true
minimatch@10.1.1:
dependencies:
@@ -7567,13 +7641,16 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
minimist@1.2.8: {}
minimist@1.2.8:
optional: true
minipass@3.3.6:
dependencies:
yallist: 4.0.0
optional: true
minipass@5.0.0: {}
minipass@5.0.0:
optional: true
minipass@7.1.2: {}
@@ -7581,6 +7658,7 @@ snapshots:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
optional: true
minizlib@3.1.0:
dependencies:
@@ -7588,7 +7666,8 @@ snapshots:
mitt@3.0.1: {}
mkdirp@1.0.4: {}
mkdirp@1.0.4:
optional: true
mpg123-decoder@1.0.3:
dependencies:
@@ -7621,13 +7700,16 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.6: {}
nanoid@5.1.6:
optional: true
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: {}
@@ -7689,6 +7771,7 @@ snapshots:
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
optional: true
node-wav@0.0.2:
optional: true
@@ -7701,6 +7784,7 @@ snapshots:
console-control-strings: 1.1.0
gauge: 4.0.4
set-blocking: 2.0.0
optional: true
object-assign@4.1.1: {}
@@ -7723,6 +7807,7 @@ snapshots:
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
'@octokit/webhooks': 14.2.0
optional: true
ogg-opus-decoder@1.7.3:
dependencies:
@@ -7753,6 +7838,7 @@ snapshots:
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
optional: true
openai@6.10.0(ws@8.19.0)(zod@4.3.5):
optionalDependencies:
@@ -7775,6 +7861,7 @@ snapshots:
stdin-discarder: 0.2.2
string-width: 7.2.0
strip-ansi: 7.1.2
optional: true
osc-progress@0.2.0: {}
@@ -7845,9 +7932,11 @@ snapshots:
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:
dependencies:
@@ -7925,15 +8014,18 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
pretty-bytes@6.1.1: {}
pretty-bytes@6.1.1:
optional: true
pretty-ms@8.0.0:
dependencies:
parse-ms: 3.0.0
optional: true
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
optional: true
prism-media@1.3.5:
optional: true
@@ -8043,6 +8135,7 @@ snapshots:
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
optional: true
readable-stream@2.3.8:
dependencies:
@@ -8059,6 +8152,7 @@ snapshots:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
optional: true
readable-stream@4.5.2:
dependencies:
@@ -8086,6 +8180,7 @@ snapshots:
dependencies:
onetime: 7.0.0
signal-exit: 4.1.0
optional: true
retry@0.12.0: {}
@@ -8198,7 +8293,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-blocking@2.0.0:
optional: true
setimmediate@1.0.5: {}
@@ -8288,6 +8384,7 @@ snapshots:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
optional: true
simple-yenc@1.0.4:
optional: true
@@ -8300,12 +8397,14 @@ snapshots:
sisteransi@1.0.5: {}
sleep-promise@9.1.0: {}
sleep-promise@9.1.0:
optional: true
slice-ansi@7.1.2:
dependencies:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
optional: true
sonic-boom@4.2.0:
dependencies:
@@ -8328,7 +8427,8 @@ snapshots:
std-env@3.10.0: {}
stdin-discarder@0.2.2: {}
stdin-discarder@0.2.2:
optional: true
stdout-update@4.0.1:
dependencies:
@@ -8336,8 +8436,10 @@ snapshots:
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.1.2
optional: true
steno@4.0.2: {}
steno@4.0.2:
optional: true
string-width@4.2.3:
dependencies:
@@ -8356,6 +8458,7 @@ snapshots:
emoji-regex: 10.6.0
get-east-asian-width: 1.4.0
strip-ansi: 7.1.2
optional: true
string_decoder@1.1.1:
dependencies:
@@ -8373,7 +8476,8 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
strip-json-comments@2.0.1: {}
strip-json-comments@2.0.1:
optional: true
strnum@2.1.2: {}
@@ -8403,6 +8507,7 @@ snapshots:
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
optional: true
tar@7.5.2:
dependencies:
@@ -8443,7 +8548,8 @@ snapshots:
dependencies:
is-number: 7.0.0
toad-cache@3.7.0: {}
toad-cache@3.7.0:
optional: true
toidentifier@1.0.1: {}
@@ -8504,17 +8610,21 @@ snapshots:
pako: 0.2.9
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: {}
urijs@1.19.11: {}
url-join@4.0.1: {}
url-join@4.0.1:
optional: true
util-deprecate@1.0.2: {}
@@ -8524,7 +8634,8 @@ snapshots:
uuid@8.3.2: {}
validate-npm-package-name@6.0.2: {}
validate-npm-package-name@6.0.2:
optional: true
vary@1.1.2: {}
@@ -8602,6 +8713,7 @@ snapshots:
which@5.0.0:
dependencies:
isexe: 3.1.1
optional: true
why-is-node-running@2.3.0:
dependencies:
@@ -8611,6 +8723,7 @@ snapshots:
wide-align@1.1.5:
dependencies:
string-width: 4.2.3
optional: true
wireit@0.14.12:
dependencies:
@@ -8651,7 +8764,8 @@ snapshots:
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {}
yargs-parser@21.1.1:
optional: true
yargs@16.2.0:
dependencies:
@@ -8672,6 +8786,7 @@ snapshots:
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
optional: true
yoctocolors@2.1.2: {}
+2
View File
@@ -22,6 +22,7 @@ type ResolvedAgentConfig = {
model?: AgentEntry["model"];
memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"];
heartbeat?: AgentEntry["heartbeat"];
identity?: AgentEntry["identity"];
groupChat?: AgentEntry["groupChat"];
subagents?: AgentEntry["subagents"];
@@ -89,6 +90,7 @@ export function resolveAgentConfig(
: undefined,
memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay,
heartbeat: entry.heartbeat,
identity: entry.identity,
groupChat: entry.groupChat,
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
@@ -1,15 +1,22 @@
import { describe, expect, it, vi } from "vitest";
const loadSessionStoreMock = vi.fn();
const saveSessionStoreMock = vi.fn();
const updateSessionStoreMock = vi.fn();
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
...actual,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
saveSessionStore: (storePath: string, store: Record<string, unknown>) =>
saveSessionStoreMock(storePath, store),
updateSessionStore: async (
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",
};
});
@@ -73,7 +80,7 @@ import { createClawdbotTools } from "./clawdbot-tools.js";
describe("session_status tool", () => {
it("returns a status card for the current session", async () => {
loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
main: {
sessionId: "s1",
@@ -96,7 +103,7 @@ describe("session_status tool", () => {
it("errors for unknown session keys", async () => {
loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
main: { sessionId: "s1", updatedAt: 10 },
});
@@ -110,12 +117,12 @@ describe("session_status tool", () => {
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
"Unknown sessionKey",
);
expect(saveSessionStoreMock).not.toHaveBeenCalled();
expect(updateSessionStoreMock).not.toHaveBeenCalled();
});
it("resets per-session model override via model=default", async () => {
loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
main: {
sessionId: "s1",
@@ -133,8 +140,8 @@ describe("session_status tool", () => {
if (!tool) throw new Error("missing session_status tool");
await tool.execute("call3", { model: "default" });
expect(saveSessionStoreMock).toHaveBeenCalled();
const [, savedStore] = saveSessionStoreMock.mock.calls.at(-1) as [
expect(updateSessionStoreMock).toHaveBeenCalled();
const [, savedStore] = updateSessionStoreMock.mock.calls.at(-1) as [
string,
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();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let sendParams: { to?: string; channel?: string; message?: string } = {};
let deletedKey: string | undefined;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
const sessionLastAssistantText = new Map<string, string>();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
@@ -57,15 +55,12 @@ describe("clawdbot-tools: subagents", () => {
sessionKey?: string;
channel?: string;
timeout?: number;
lane?: string;
};
const message = params?.message ?? "";
const sessionKey = params?.sessionKey ?? "";
if (message === "Sub-agent announce step.") {
sessionLastAssistantText.set(sessionKey, "announce now");
} else {
// Only capture the first agent call (subagent spawn, not main agent trigger)
if (params?.lane === "subagent") {
childRunId = runId;
childSessionKey = sessionKey;
sessionLastAssistantText.set(sessionKey, "result");
childSessionKey = params?.sessionKey ?? "";
expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1);
}
@@ -85,24 +80,6 @@ describe("clawdbot-tools: subagents", () => {
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") {
const params = request.params as { key?: string } | undefined;
deletedKey = params?.key;
@@ -135,19 +112,24 @@ describe("clawdbot-tools: subagents", () => {
expect(childWait?.timeoutMs).toBe(1000);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
// Two agent calls: subagent spawn + main agent trigger
const agentCalls = calls.filter((call) => call.method === "agent");
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");
expect(sendParams.to).toBe("channel:req");
expect(sendParams.message ?? "").toContain("announce now");
expect(sendParams.message ?? "").toContain("Stats:");
// First call: subagent spawn
const first = agentCalls[0]?.params as { lane?: string } | undefined;
expect(first?.lane).toBe("subagent");
// 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);
});
});
@@ -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();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let sendParams: { to?: string; channel?: string; message?: string } = {};
let deletedKey: string | undefined;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
const sessionLastAssistantText = new Map<string, string>();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
@@ -58,15 +56,12 @@ describe("clawdbot-tools: subagents", () => {
sessionKey?: string;
channel?: string;
timeout?: number;
lane?: string;
};
const message = params?.message ?? "";
const sessionKey = params?.sessionKey ?? "";
if (message === "Sub-agent announce step.") {
sessionLastAssistantText.set(sessionKey, "announce now");
} else {
// Only capture the first agent call (subagent spawn, not main agent trigger)
if (params?.lane === "subagent") {
childRunId = runId;
childSessionKey = sessionKey;
sessionLastAssistantText.set(sessionKey, "result");
childSessionKey = params?.sessionKey ?? "";
expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1);
}
@@ -79,26 +74,8 @@ describe("clawdbot-tools: subagents", () => {
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {});
const status = params?.runId === childRunId ? "timeout" : "ok";
return { runId: params?.runId ?? "run-1", status };
}
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" };
// Return "ok" with timing info for the child run
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete") {
const params = request.params as { key?: string } | undefined;
@@ -141,8 +118,12 @@ describe("clawdbot-tools: subagents", () => {
const childWait = waitCalls.find((call) => call.runId === childRunId);
expect(childWait?.timeoutMs).toBe(1000);
// Two agent calls: subagent spawn + main agent trigger
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
// First call: subagent spawn
const first = agentCalls[0]?.params as
| {
lane?: string;
@@ -156,17 +137,24 @@ describe("clawdbot-tools: subagents", () => {
expect(first?.channel).toBe("discord");
expect(first?.sessionKey?.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");
expect(sendParams.to).toBe("channel:req");
expect(sendParams.message ?? "").toContain("announce now");
expect(sendParams.message ?? "").toContain("Stats:");
// Second call: main agent trigger with announce message
const second = agentCalls[1]?.params as
| {
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);
});
});
@@ -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();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let sendParams: { to?: string; channel?: string; message?: string } = {};
let childRunId: string | undefined;
let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
const sessionLastAssistantText = new Map<string, string>();
let patchParams: { key?: string; label?: string } = {};
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
@@ -66,15 +65,12 @@ describe("clawdbot-tools: subagents", () => {
const params = request.params as {
message?: string;
sessionKey?: string;
lane?: string;
};
const message = params?.message ?? "";
const sessionKey = params?.sessionKey ?? "";
if (message === "Sub-agent announce step.") {
sessionLastAssistantText.set(sessionKey, "hello from sub");
} else {
// Only capture the first agent call (subagent spawn, not main agent trigger)
if (params?.lane === "subagent") {
childRunId = runId;
childSessionKey = sessionKey;
sessionLastAssistantText.set(sessionKey, "done");
childSessionKey = params?.sessionKey ?? "";
}
return {
runId,
@@ -85,26 +81,12 @@ describe("clawdbot-tools: subagents", () => {
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {});
const status = params?.runId === childRunId ? "timeout" : "ok";
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: "m1" };
if (request.method === "sessions.patch") {
const params = request.params as { key?: string; label?: string } | undefined;
patchParams = { key: params?.key, label: params?.label };
return { ok: true };
}
if (request.method === "sessions.delete") {
return { ok: true };
@@ -121,6 +103,7 @@ describe("clawdbot-tools: subagents", () => {
const result = await tool.execute("call2", {
task: "do thing",
runTimeoutSeconds: 1,
label: "my-task",
});
expect(result.details).toMatchObject({
status: "accepted",
@@ -144,12 +127,29 @@ describe("clawdbot-tools: subagents", () => {
const childWait = waitCalls.find((call) => call.runId === childRunId);
expect(childWait?.timeoutMs).toBe(1000);
expect(sendParams.channel).toBe("whatsapp");
expect(sendParams.to).toBe("+123");
expect(sendParams.message ?? "").toContain("hello from sub");
expect(sendParams.message ?? "").toContain("Stats:");
// Cleanup should patch the label
expect(patchParams.key).toBe(childSessionKey);
expect(patchParams.label).toBe("my-task");
// 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);
});
it("sessions_spawn only allows same-agent by default", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
+102 -5
View File
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CliBackendConfig } from "../config/types.js";
import { runCliAgent } from "./cli-runner.js";
import { cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js";
const runCommandWithTimeoutMock = vi.fn();
const runExecMock = vi.fn();
@@ -17,7 +19,12 @@ describe("runCliAgent resume cleanup", () => {
});
it("kills stale resume processes for codex sessions", async () => {
runExecMock.mockResolvedValue({ stdout: "", stderr: "" });
runExecMock
.mockResolvedValueOnce({
stdout: " 1 S /bin/launchd\n",
stderr: "",
}) // cleanupSuspendedCliProcesses (ps)
.mockResolvedValueOnce({ stdout: "", stderr: "" }); // cleanupResumeProcesses (pkill)
runCommandWithTimeoutMock.mockResolvedValueOnce({
stdout: "ok",
stderr: "",
@@ -43,13 +50,103 @@ describe("runCliAgent resume cleanup", () => {
return;
}
expect(runExecMock).toHaveBeenCalledTimes(1);
const args = runExecMock.mock.calls[0] ?? [];
expect(args[0]).toBe("pkill");
const pkillArgs = args[1] as string[];
expect(runExecMock).toHaveBeenCalledTimes(2);
const pkillCall = runExecMock.mock.calls[1] ?? [];
expect(pkillCall[0]).toBe("pkill");
const pkillArgs = pkillCall[1] as string[];
expect(pkillArgs[0]).toBe("-f");
expect(pkillArgs[1]).toContain("codex");
expect(pkillArgs[1]).toContain("resume");
expect(pkillArgs[1]).toContain("thread-123");
});
});
describe("cleanupSuspendedCliProcesses", () => {
beforeEach(() => {
runExecMock.mockReset();
});
it("skips when no session tokens are configured", async () => {
await cleanupSuspendedCliProcesses(
{
command: "tool",
} as CliBackendConfig,
0,
);
if (process.platform === "win32") {
expect(runExecMock).not.toHaveBeenCalled();
return;
}
expect(runExecMock).not.toHaveBeenCalled();
});
it("matches sessionArg-based commands", async () => {
runExecMock
.mockResolvedValueOnce({
stdout: [
" 40 T+ claude --session-id thread-1 -p",
" 41 S claude --session-id thread-2 -p",
].join("\n"),
stderr: "",
})
.mockResolvedValueOnce({ stdout: "", stderr: "" });
await cleanupSuspendedCliProcesses(
{
command: "claude",
sessionArg: "--session-id",
} as CliBackendConfig,
0,
);
if (process.platform === "win32") {
expect(runExecMock).not.toHaveBeenCalled();
return;
}
expect(runExecMock).toHaveBeenCalledTimes(2);
const killCall = runExecMock.mock.calls[1] ?? [];
expect(killCall[0]).toBe("kill");
expect(killCall[1]).toEqual(["-9", "40"]);
});
it("matches resumeArgs with positional session id", async () => {
runExecMock
.mockResolvedValueOnce({
stdout: [
" 50 T codex exec resume thread-99 --color never --sandbox read-only",
" 51 T codex exec resume other --color never --sandbox read-only",
].join("\n"),
stderr: "",
})
.mockResolvedValueOnce({ stdout: "", stderr: "" });
await cleanupSuspendedCliProcesses(
{
command: "codex",
resumeArgs: [
"exec",
"resume",
"{sessionId}",
"--color",
"never",
"--sandbox",
"read-only",
],
} as CliBackendConfig,
1,
);
if (process.platform === "win32") {
expect(runExecMock).not.toHaveBeenCalled();
return;
}
expect(runExecMock).toHaveBeenCalledTimes(2);
const killCall = runExecMock.mock.calls[1] ?? [];
expect(killCall[0]).toBe("kill");
expect(killCall[1]).toEqual(["-9", "50", "51"]);
});
});
+2
View File
@@ -13,6 +13,7 @@ import {
buildCliArgs,
buildSystemPrompt,
cleanupResumeProcesses,
cleanupSuspendedCliProcesses,
enqueueCliRun,
normalizeCliModel,
parseCliJson,
@@ -206,6 +207,7 @@ export async function runCliAgent(params: {
return next;
})();
await cleanupSuspendedCliProcesses(backend);
if (useResume && cliSessionIdToSend) {
await cleanupResumeProcesses(backend, cliSessionIdToSend);
}
+80 -39
View File
@@ -9,6 +9,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { CliBackendConfig } from "../../config/types.js";
import { runExec } from "../../process/exec.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
@@ -43,6 +44,82 @@ export async function cleanupResumeProcesses(
}
}
function buildSessionMatchers(backend: CliBackendConfig): RegExp[] {
const commandToken = path.basename(backend.command ?? "").trim();
if (!commandToken) return [];
const matchers: RegExp[] = [];
const sessionArg = backend.sessionArg?.trim();
const sessionArgs = backend.sessionArgs ?? [];
const resumeArgs = backend.resumeArgs ?? [];
const addMatcher = (args: string[]) => {
if (args.length === 0) return;
const tokens = [commandToken, ...args];
const pattern = tokens
.map((token, index) => {
const tokenPattern = tokenToRegex(token);
return index === 0 ? `(?:^|\\s)${tokenPattern}` : `\\s+${tokenPattern}`;
})
.join("");
matchers.push(new RegExp(pattern));
};
if (sessionArgs.some((arg) => arg.includes("{sessionId}"))) {
addMatcher(sessionArgs);
} else if (sessionArg) {
addMatcher([sessionArg, "{sessionId}"]);
}
if (resumeArgs.some((arg) => arg.includes("{sessionId}"))) {
addMatcher(resumeArgs);
}
return matchers;
}
function tokenToRegex(token: string): string {
if (!token.includes("{sessionId}")) return escapeRegex(token);
const parts = token.split("{sessionId}").map((part) => escapeRegex(part));
return parts.join("\\S+");
}
/**
* Cleanup suspended Clawdbot CLI processes that have accumulated.
* Only cleans up if there are more than the threshold (default: 10).
*/
export async function cleanupSuspendedCliProcesses(
backend: CliBackendConfig,
threshold = 10,
): Promise<void> {
if (process.platform === "win32") return;
const matchers = buildSessionMatchers(backend);
if (matchers.length === 0) return;
try {
const { stdout } = await runExec("ps", ["-ax", "-o", "pid=,stat=,command="]);
const suspended: number[] = [];
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = /^(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed);
if (!match) continue;
const pid = Number(match[1]);
const stat = match[2] ?? "";
const command = match[3] ?? "";
if (!Number.isFinite(pid)) continue;
if (!stat.includes("T")) continue;
if (!matchers.some((matcher) => matcher.test(command))) continue;
suspended.push(pid);
}
if (suspended.length > threshold) {
// Verified locally: stopped (T) processes ignore SIGTERM, so use SIGKILL.
await runExec("kill", ["-9", ...suspended.map((pid) => String(pid))]);
}
} catch {
// ignore errors - best effort cleanup
}
}
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task);
@@ -69,44 +146,6 @@ export type CliOutput = {
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) {
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
@@ -134,7 +173,8 @@ export function buildSystemPrompt(params: {
modelDisplay: string;
}) {
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({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
@@ -153,6 +193,7 @@ export function buildSystemPrompt(params: {
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
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" }]);
});
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 () => {
const cfg = makeCfg();
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. */
fallbacksOverride?: string[];
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
const primary = params.cfg
? resolveConfiguredModelRef({
cfg: params.cfg,
@@ -128,11 +126,15 @@ function resolveFallbackCandidates(params: {
defaultModel: DEFAULT_MODEL,
})
: 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({
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 candidates: ModelCandidate[] = [];
@@ -160,7 +162,7 @@ function resolveFallbackCandidates(params: {
for (const raw of modelFallbacks) {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
defaultProvider,
aliasIndex,
});
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 };
/**
+9 -2
View File
@@ -30,7 +30,11 @@ function isEmptyAssistantErrorMessage(
export async function sanitizeSessionMessagesImages(
messages: AgentMessage[],
label: string,
options?: { sanitizeToolCallIds?: boolean; enforceToolCallLast?: boolean },
options?: {
sanitizeToolCallIds?: boolean;
enforceToolCallLast?: boolean;
preserveSignatures?: boolean;
},
): Promise<AgentMessage[]> {
// We sanitize historical session messages because Anthropic can reject a request
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
@@ -76,7 +80,10 @@ export async function sanitizeSessionMessagesImages(
}
const content = assistantMsg.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) => {
if (!block || typeof block !== "object") return true;
const rec = block as { type?: unknown; text?: unknown };
@@ -59,6 +59,34 @@ describe("sanitizeSessionHistory (google thinking)", () => {
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 () => {
const sessionManager = SessionManager.inMemory();
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 { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { resolveUserPath } from "../../utils.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 { splitSdkTools } from "./tool-split.js";
import type { EmbeddedPiCompactResult } from "./types.js";
import {
describeUnknownError,
formatUserTime,
mapThinkingLevel,
resolveExecToolDefaults,
resolveUserTimezone,
} from "./utils.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
export async function compactEmbeddedPiSession(params: {
sessionId: string;
@@ -228,12 +224,14 @@ export async function compactEmbeddedPiSession(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider);
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({
sessionKey: params.sessionKey,
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
@@ -245,12 +243,14 @@ export async function compactEmbeddedPiSession(params: {
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
promptMode,
runtimeInfo,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
});
const systemPrompt = createSystemPromptOverride(appendPrompt);
@@ -302,6 +302,7 @@ export async function compactEmbeddedPiSession(params: {
const prior = await sanitizeSessionHistory({
messages: session.messages,
modelApi: model.api,
modelId,
sessionManager,
sessionId: params.sessionId,
});
+7 -2
View File
@@ -13,6 +13,7 @@ import {
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
import { log } from "./logger.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_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
@@ -152,19 +153,23 @@ export function applyGoogleTurnOrderingFix(params: {
export async function sanitizeSessionHistory(params: {
messages: AgentMessage[];
modelApi?: string | null;
modelId?: string;
sessionManager: SessionManager;
sessionId: string;
}): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
});
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel;
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
const downgradedThinking = isGoogleModelApi(params.modelApi)
const downgradedThinking = shouldDowngradeGemini
? downgradeGeminiThinkingBlocks(repairedTools)
: repairedTools;
const downgraded = isGoogleModelApi(params.modelApi)
const downgraded = shouldDowngradeGemini
? downgradeGeminiHistory(downgradedThinking)
: downgradedThinking;
+22 -7
View File
@@ -9,8 +9,10 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveUserPath } from "../../../utils.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.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 { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
import { splitSdkTools } from "../tool-split.js";
import {
formatUserTime,
mapThinkingLevel,
resolveExecToolDefaults,
resolveUserTimezone,
} from "../utils.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
import { mapThinkingLevel, resolveExecToolDefaults } from "../utils.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
@@ -161,6 +159,17 @@ export async function runEmbeddedAttempt(
accountId: params.agentAccountId,
}) ?? [])
: 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 = {
host: machineName,
os: `${os.type()} ${os.release()}`,
@@ -174,12 +183,14 @@ export async function runEmbeddedAttempt(
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(params.provider);
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({
sessionKey: params.sessionKey,
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
@@ -192,12 +203,15 @@ export async function runEmbeddedAttempt(
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
reactionGuidance,
promptMode,
runtimeInfo,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
});
const systemPromptReport = buildSystemPromptReport({
@@ -303,6 +317,7 @@ export async function runEmbeddedAttempt(
const prior = await sanitizeSessionHistory({
messages: activeSession.messages,
modelApi: params.model.api,
modelId: params.modelId,
sessionManager,
sessionId: params.sessionId,
});
+12 -1
View File
@@ -1,6 +1,7 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ResolvedTimeFormat } from "../date-time.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 type { EmbeddedSandboxInfo } from "./types.js";
import type { ReasoningLevel, ThinkLevel } from "./utils.js";
@@ -14,6 +15,12 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: boolean;
heartbeatPrompt?: string;
skillsPrompt?: string;
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
};
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo: {
host: string;
os: string;
@@ -29,6 +36,7 @@ export function buildEmbeddedSystemPrompt(params: {
modelAliasLines: string[];
userTimezone: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
}): string {
return buildAgentSystemPrompt({
@@ -40,6 +48,8 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: params.reasoningTagHint,
heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt,
reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo,
sandboxInfo: params.sandboxInfo,
toolNames: params.tools.map((tool) => tool.name),
@@ -47,6 +57,7 @@ export function buildEmbeddedSystemPrompt(params: {
modelAliasLines: params.modelAliasLines,
userTimezone: params.userTimezone,
userTime: params.userTime,
userTimeFormat: params.userTimeFormat,
contextFiles: params.contextFiles,
});
}
-39
View File
@@ -17,45 +17,6 @@ export function resolveExecToolDefaults(config?: ClawdbotConfig): ExecToolDefaul
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 {
if (error instanceof Error) return error.message;
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";
const DEFAULT_SUBAGENT_TOOL_DENY = [
// Session management - main agent orchestrates
"sessions_list",
"sessions_history",
"sessions_send",
"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 {
@@ -45,6 +45,14 @@ vi.mock("node:child_process", async (importOriginal) => {
};
});
vi.mock("../skills.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../skills.js")>();
return {
...actual,
syncSkillsToWorkspace: vi.fn(async () => {}),
};
});
describe("Agent-specific sandbox config", () => {
beforeEach(() => {
spawnCalls.length = 0;
@@ -46,6 +46,14 @@ vi.mock("node:child_process", async (importOriginal) => {
};
});
vi.mock("../skills.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../skills.js")>();
return {
...actual,
syncSkillsToWorkspace: vi.fn(async () => {}),
};
});
describe("Agent-specific sandbox config", () => {
beforeEach(() => {
spawnCalls.length = 0;
+23 -37
View File
@@ -1,12 +1,12 @@
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", () => ({
callGateway: vi.fn(async (req: unknown) => {
const typed = req as { method?: string; params?: { message?: string } };
if (typed.method === "send") {
return await sendSpy(typed);
const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } };
if (typed.method === "agent") {
return await agentSpy(typed);
}
if (typed.method === "agent.wait") {
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
@@ -18,24 +18,11 @@ vi.mock("../gateway/call.js", () => ({
}));
vi.mock("./tools/agent-step.js", () => ({
runAgentStep: vi.fn(async () => "did some stuff"),
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", () => ({
loadSessionStore: vi.fn(async () => ({ entries: {} })),
loadSessionStore: vi.fn(() => ({})),
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions.json",
}));
@@ -48,10 +35,10 @@ vi.mock("../config/config.js", () => ({
describe("subagent announce formatting", () => {
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");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
@@ -66,22 +53,21 @@ describe("subagent announce formatting", () => {
endedAt: 20,
});
expect(sendSpy).toHaveBeenCalled();
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
expect(msg).toContain("Status:");
expect(msg).toContain("Status: error");
expect(msg).toContain("Result:");
expect(msg).toContain("Notes:");
expect(agentSpy).toHaveBeenCalled();
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string; sessionKey?: string } };
const msg = call?.params?.message as string;
expect(call?.params?.sessionKey).toBe("agent:main:main");
expect(msg).toContain("background task");
expect(msg).toContain("failed");
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 () => {
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",
);
it("includes success status when outcome is ok", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
// Use waitForCompletion: false so it uses the provided outcome instead of calling agent.wait
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-456",
@@ -90,14 +76,14 @@ describe("subagent announce formatting", () => {
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: true,
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
expect(msg).toContain("Status: error");
expect(msg).toContain("Result:");
expect(msg).toContain("Notes:");
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
const msg = call?.params?.message as string;
expect(msg).toContain("completed successfully");
});
});
+44 -158
View File
@@ -8,11 +8,7 @@ import {
resolveStorePath,
} from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.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";
import { readLatestAssistantReply } from "./tools/agent-step.js";
function formatDurationShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return undefined;
@@ -149,27 +145,27 @@ export function buildSubagentSystemPrompt(params: {
"",
"## Your Role",
`- 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.",
"",
"## Rules",
"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",
"4. **Ask the spawner** - If blocked or confused, report back rather than improvising",
"5. **Be ephemeral** - You may be terminated after task completion. That's fine.",
"4. **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",
"- NO user conversations (that's main agent's job)",
"- NO external messages (email, tweets, etc.) unless explicitly tasked",
"- NO cron jobs or persistent state",
"- NO pretending to be the main agent",
"",
"## Output Format",
"When complete, respond with:",
"- **Status:** success | failed | blocked",
"- **Result:** [what you accomplished]",
"- **Notes:** [anything the main agent should know] - discuss gimme options",
"- NO using the `message` tool directly",
"",
"## Session Context",
params.label ? `- Label: ${params.label}` : undefined,
@@ -177,8 +173,6 @@ export function buildSubagentSystemPrompt(params: {
params.requesterChannel ? `- Requester channel: ${params.requesterChannel}.` : undefined,
`- 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);
return lines.join("\n");
}
@@ -188,109 +182,6 @@ export type SubagentRunOutcome = {
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: {
childSessionKey: string;
childRunId: string;
@@ -340,8 +231,6 @@ export async function runSubagentAnnounceFlow(params: {
params.endedAt = wait.endedAt;
}
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" };
}
reply = await readLatestAssistantReply({
@@ -357,53 +246,50 @@ export async function runSubagentAnnounceFlow(params: {
if (!outcome) outcome = { status: "unknown" };
const announceTarget = await resolveAnnounceTarget({
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;
// Build stats
const statsLine = await buildSubagentStatsLine({
sessionKey: params.childSessionKey,
startedAt: params.startedAt,
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({
method: "send",
method: "agent",
params: {
to: announceTarget.to,
message,
channel: announceTarget.channel,
accountId: announceTarget.accountId,
sessionKey: params.requesterSessionKey,
message: triggerMessage,
deliver: true,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
timeoutMs: 60_000,
});
didAnnounce = true;
} catch {
// 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");
mod2.initSubagentRegistry();
// allow queued async wait/announce to execute
// allow queued async wait/cleanup to execute
await new Promise((r) => setTimeout(r, 0));
expect(announceSpy).toHaveBeenCalled();
type AnnounceParams = {
childRunId: string;
childSessionKey: string;
childRunId: string;
requesterSessionKey: string;
task: string;
cleanup: string;
label?: string;
};
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");
});
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-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
@@ -100,7 +103,7 @@ describe("subagent registry persistence", () => {
createdAt: 1,
startedAt: 1,
endedAt: 2,
announceHandled: true,
cleanupHandled: true, // Already handled - should be skipped
},
},
};
@@ -113,10 +116,61 @@ describe("subagent registry persistence", () => {
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 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();
});
it("retries cleanup announce after a failed announce", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 1,
runs: {
"run-3": {
runId: "run-3",
childSessionKey: "agent:main:subagent:three",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "retry announce",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
},
},
};
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
announceSpy.mockResolvedValueOnce(false);
vi.resetModules();
const mod1 = await import("./subagent-registry.js");
mod1.initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
expect(announceSpy).toHaveBeenCalledTimes(1);
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs: Record<string, { cleanupHandled?: boolean; cleanupCompletedAt?: number }>;
};
expect(afterFirst.runs["run-3"].cleanupHandled).toBe(false);
expect(afterFirst.runs["run-3"].cleanupCompletedAt).toBeUndefined();
announceSpy.mockResolvedValueOnce(true);
vi.resetModules();
const mod2 = await import("./subagent-registry.js");
mod2.initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
expect(announceSpy).toHaveBeenCalledTimes(2);
const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs: Record<string, { cleanupCompletedAt?: number }>;
};
expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeDefined();
});
});
+19 -2
View File
@@ -13,7 +13,9 @@ type PersistedSubagentRegistry = {
const REGISTRY_VERSION = 1 as const;
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled">;
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled"> & {
announceHandled?: boolean;
};
export function resolveSubagentRegistryPath(): string {
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;
const typed = entry as PersistedSubagentRunRecord;
if (!typed.runId || typeof typed.runId !== "string") continue;
// Back-compat: map legacy announce fields into cleanup fields.
const announceCompletedAt =
typeof typed.announceCompletedAt === "number" ? typed.announceCompletedAt : undefined;
const 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, {
...typed,
announceCompletedAt,
announceHandled: Boolean(announceCompletedAt),
announceHandled,
cleanupCompletedAt,
cleanupHandled,
});
}
return out;
+39 -26
View File
@@ -22,8 +22,12 @@ export type SubagentRunRecord = {
endedAt?: number;
outcome?: SubagentRunOutcome;
archiveAtMs?: number;
/** @deprecated Use cleanupCompletedAt instead */
announceCompletedAt?: number;
announceHandled: boolean;
/** @deprecated Use cleanupHandled instead */
announceHandled?: boolean;
cleanupCompletedAt?: number;
cleanupHandled?: boolean;
};
const subagentRuns = new Map<string, SubagentRunRecord>();
@@ -46,11 +50,11 @@ function resumeSubagentRun(runId: string) {
if (!runId || resumedRuns.has(runId)) return;
const entry = subagentRuns.get(runId);
if (!entry) return;
if (entry.announceCompletedAt) return;
if (entry.cleanupCompletedAt) return;
if (typeof entry.endedAt === "number" && entry.endedAt > 0) {
if (!beginSubagentAnnounce(runId)) return;
const announce = runSubagentAnnounceFlow({
if (!beginSubagentCleanup(runId)) return;
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
@@ -64,9 +68,8 @@ function resumeSubagentRun(runId: string) {
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
}).then((didAnnounce) => {
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
});
resumedRuns.add(runId);
return;
@@ -156,7 +159,9 @@ async function sweepSubagentRuns() {
}
function ensureListener() {
if (listenerStarted) return;
if (listenerStarted) {
return;
}
listenerStarted = true;
listenerStop = onAgentEvent((evt) => {
if (!evt || evt.stream !== "lifecycle") return;
@@ -186,10 +191,10 @@ function ensureListener() {
}
persistSubagentRuns();
if (!beginSubagentAnnounce(evt.runId)) {
if (!beginSubagentCleanup(evt.runId)) {
return;
}
const announce = runSubagentAnnounceFlow({
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
@@ -203,14 +208,17 @@ function ensureListener() {
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(evt.runId, entry.cleanup, didAnnounce);
}).then((didAnnounce) => {
finalizeSubagentCleanup(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);
if (!entry) return;
if (cleanup === "delete") {
@@ -218,17 +226,23 @@ function finalizeSubagentAnnounce(runId: string, cleanup: "delete" | "keep", did
persistSubagentRuns();
return;
}
if (!didAnnounce) return;
entry.announceCompletedAt = Date.now();
if (!didAnnounce) {
// Allow retry on the next wake if the announce failed.
entry.cleanupHandled = false;
persistSubagentRuns();
return;
}
entry.cleanupCompletedAt = Date.now();
persistSubagentRuns();
}
export function beginSubagentAnnounce(runId: string) {
function beginSubagentCleanup(runId: string) {
const entry = subagentRuns.get(runId);
if (!entry) return false;
if (entry.announceCompletedAt) return false;
if (entry.announceHandled) return false;
entry.announceHandled = true;
// Support legacy field names for backward compatibility
if (entry.cleanupCompletedAt || entry.announceCompletedAt) return false;
if (entry.cleanupHandled || entry.announceHandled) return false;
entry.cleanupHandled = true;
persistSubagentRuns();
return true;
}
@@ -261,7 +275,7 @@ export function registerSubagentRun(params: {
createdAt: now,
startedAt: now,
archiveAtMs,
announceHandled: false,
cleanupHandled: false,
});
ensureListener();
persistSubagentRuns();
@@ -302,8 +316,8 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
wait.status === "error" ? { status: "error", error: wait.error } : { status: "ok" };
mutated = true;
if (mutated) persistSubagentRuns();
if (!beginSubagentAnnounce(runId)) return;
const announce = runSubagentAnnounceFlow({
if (!beginSubagentCleanup(runId)) return;
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
@@ -317,9 +331,8 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
}).then((didAnnounce) => {
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
});
} catch {
// ignore
+67 -3
View File
@@ -23,6 +23,30 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).not.toContain("Owner numbers:");
});
it("omits extended sections in minimal prompt mode", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
promptMode: "minimal",
ownerNumbers: ["+123"],
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping",
toolNames: ["message", "memory_search"],
extraSystemPrompt: "Subagent details",
});
expect(prompt).not.toContain("## User Identity");
expect(prompt).not.toContain("## Skills");
expect(prompt).not.toContain("## Memory Recall");
expect(prompt).not.toContain("## Reply Tags");
expect(prompt).not.toContain("## Messaging");
expect(prompt).not.toContain("## Silent Replies");
expect(prompt).not.toContain("## Heartbeats");
expect(prompt).toContain("## Subagent Context");
expect(prompt).not.toContain("## Group Chat Context");
expect(prompt).toContain("Subagent details");
});
it("adds reasoning tag hint when enabled", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
@@ -71,15 +95,42 @@ describe("buildAgentSystemPrompt", () => {
);
});
it("includes user time when provided", () => {
it("includes user time when provided (12-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
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(
"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 +259,17 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("User can toggle with /elevated on|off.");
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.");
});
});
+212 -83
View File
@@ -1,8 +1,116 @@
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
import type { ResolvedTimeFormat } from "./date-time.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";
function buildSkillsSection(params: {
skillsPrompt?: string;
isMinimal: boolean;
readToolName: string;
}) {
const trimmed = params.skillsPrompt?.trim();
if (!trimmed || params.isMinimal) return [];
return [
"## Skills",
`Skills provide task-specific instructions. Use \`${params.readToolName}\` to load the SKILL.md at the location listed for that skill.`,
trimmed,
"",
];
}
function buildMemorySection(params: {
isMinimal: boolean;
availableTools: Set<string>;
}) {
if (params.isMinimal) return [];
if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) {
return [];
}
return [
"## 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.",
"",
];
}
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
if (!ownerLine || isMinimal) return [];
return ["## User Identity", ownerLine, ""];
}
function buildTimeSection(params: {
userTimezone?: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
}) {
if (!params.userTimezone && !params.userTime) return [];
return [
"## Current Date & Time",
params.userTime
? `${params.userTime} (${params.userTimezone ?? "unknown"})`
: `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
];
}
function buildReplyTagsSection(isMinimal: boolean) {
if (isMinimal) return [];
return [
"## 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.",
"- [[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.",
"",
];
}
function buildMessagingSection(params: {
isMinimal: boolean;
availableTools: Set<string>;
messageChannelOptions: string;
inlineButtonsEnabled: boolean;
runtimeChannel?: string;
}) {
if (params.isMinimal) return [];
return [
"## 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.",
params.availableTools.has("message")
? [
"",
"### 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\` (${params.messageChannelOptions}).`,
params.inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: params.runtimeChannel
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to add "inlineButtons" to ${params.runtimeChannel}.capabilities or ${params.runtimeChannel}.accounts.<id>.capabilities.`
: "",
]
.filter(Boolean)
.join("\n")
: "",
"",
];
}
export function buildAgentSystemPrompt(params: {
workspaceDir: string;
defaultThinkLevel?: ThinkLevel;
@@ -15,9 +123,12 @@ export function buildAgentSystemPrompt(params: {
modelAliasLines?: string[];
userTimezone?: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
skillsPrompt?: string;
heartbeatPrompt?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo?: {
host?: string;
os?: string;
@@ -43,6 +154,11 @@ export function buildAgentSystemPrompt(params: {
defaultLevel: "on" | "off";
};
};
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
};
}) {
const coreToolSummaries: Record<string, string> = {
read: "Read file contents",
@@ -101,6 +217,7 @@ export function buildAgentSystemPrompt(params: {
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
const canonicalToolNames = rawToolNames.filter(Boolean);
// Preserve caller casing while deduping tool names by lowercase.
const canonicalByNormalized = new Map<string, string>();
for (const name of canonicalToolNames) {
const normalized = name.toLowerCase();
@@ -172,23 +289,19 @@ export function buildAgentSystemPrompt(params: {
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
const messageChannelOptions = listDeliverableMessageChannels().join("|");
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
const skillsSection = skillsPrompt
? [
"## Skills",
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
...skillsLines,
"",
]
: [];
const memorySection =
availableTools.has("memory_search") || availableTools.has("memory_get")
? [
"## 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.",
"",
]
: [];
const promptMode = params.promptMode ?? "full";
const isMinimal = promptMode === "minimal" || promptMode === "none";
const skillsSection = buildSkillsSection({
skillsPrompt,
isMinimal,
readToolName,
});
const memorySection = buildMemorySection({ isMinimal, availableTools });
// For "none" mode, return just the basic identity line
if (promptMode === "none") {
return "You are a personal assistant running inside Clawdbot.";
}
const lines = [
"You are a personal assistant running inside Clawdbot.",
@@ -228,8 +341,9 @@ export function buildAgentSystemPrompt(params: {
"",
...skillsSection,
...memorySection,
hasGateway ? "## Clawdbot Self-Update" : "",
hasGateway
// Skip self-update for subagent/none modes
hasGateway && !isMinimal ? "## Clawdbot Self-Update" : "",
hasGateway && !isMinimal
? [
"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.",
@@ -237,16 +351,19 @@ export function buildAgentSystemPrompt(params: {
"After restart, Clawdbot pings the last active session automatically.",
].join("\n")
: "",
hasGateway ? "" : "",
hasGateway && !isMinimal ? "" : "",
"",
params.modelAliasLines && params.modelAliasLines.length > 0 ? "## Model Aliases" : "",
params.modelAliasLines && params.modelAliasLines.length > 0
// Skip model aliases for subagent/none modes
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."
: "",
params.modelAliasLines && params.modelAliasLines.length > 0
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? params.modelAliasLines.join("\n")
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 ? "" : "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "",
"## Workspace",
`Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
@@ -304,52 +421,52 @@ export function buildAgentSystemPrompt(params: {
.join("\n")
: "",
params.sandboxInfo?.enabled ? "" : "",
ownerLine ? "## User Identity" : "",
ownerLine ?? "",
ownerLine ? "" : "",
...buildUserIdentitySection(ownerLine, isMinimal),
...buildTimeSection({
userTimezone,
userTime,
userTimeFormat: params.userTimeFormat,
}),
"## Workspace Files (injected)",
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
"",
userTimezone || userTime
? `Time: assume UTC unless stated. User time zone: ${
userTimezone ?? "unknown"
}. Current user time (local, 24-hour): ${userTime ?? "unknown"} (${
userTimezone ?? "unknown"
}).`
: "",
userTimezone || userTime ? "" : "",
"## 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.",
"- [[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.).",
"- 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")
: "",
"",
...buildReplyTagsSection(isMinimal),
...buildMessagingSection({
isMinimal,
availableTools,
messageChannelOptions,
inlineButtonsEnabled,
runtimeChannel,
}),
];
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) {
lines.push("## Reasoning Format", reasoningHint, "");
@@ -368,26 +485,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(
"## 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: ${[
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
+37 -34
View File
@@ -27,32 +27,7 @@ import {
readStringArrayParam,
readStringParam,
} from "./common.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}`;
}
import { withNormalizedTimestamp } from "../date-time.js";
function parseDiscordMessageLink(link: string) {
const normalized = link.trim();
@@ -76,6 +51,13 @@ export async function handleDiscordMessagingAction(
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): 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) {
case "react": {
if (!isActionEnabled("reactions")) {
@@ -189,7 +171,13 @@ export async function handleDiscordMessagingAction(
);
}
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": {
if (!isActionEnabled("messages")) {
@@ -207,11 +195,10 @@ export async function handleDiscordMessagingAction(
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
});
const formattedMessages = messages.map((message) => ({
...message,
timestamp: formatDiscordTimestamp(message.timestamp) ?? message.timestamp,
}));
return jsonResult({ ok: true, messages: formattedMessages });
return jsonResult({
ok: true,
messages: messages.map((message) => normalizeMessage(message)),
});
}
case "sendMessage": {
if (!isActionEnabled("messages")) {
@@ -357,7 +344,7 @@ export async function handleDiscordMessagingAction(
required: true,
});
const pins = await listPinsDiscord(channelId);
return jsonResult({ ok: true, pins });
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
}
case "searchMessages": {
if (!isActionEnabled("search")) {
@@ -386,7 +373,23 @@ export async function handleDiscordMessagingAction(
authorIds: authorIdList.length ? authorIdList : undefined,
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:
throw new Error(`Unknown action: ${action}`);
+76
View File
@@ -17,6 +17,7 @@ const editChannelDiscord = vi.fn(async () => ({
name: "edited",
}));
const editMessageDiscord = vi.fn(async () => ({}));
const fetchMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({}));
const listPinsDiscord = vi.fn(async () => ({}));
@@ -42,6 +43,7 @@ vi.mock("../../discord/send.js", () => ({
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
@@ -134,6 +136,80 @@ describe("handleDiscordMessagingAction", () => {
),
).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";
+85 -13
View File
@@ -1,5 +1,6 @@
import { Type } from "@sinclair/typebox";
import { resolveAgentDir } from "../../agents/agent-scope.js";
import { buildAuthHealthSummary, formatRemainingShort } from "../../agents/auth-health.js";
import {
ensureAuthProfileStore,
resolveAuthProfileDisplayLabel,
@@ -25,12 +26,13 @@ import {
loadSessionStore,
resolveStorePath,
type SessionEntry,
saveSessionStore,
updateSessionStore,
} from "../../config/sessions.js";
import {
formatUsageSummaryLine,
formatUsageWindowSummary,
loadProviderUsageSummary,
resolveUsageProviderId,
type UsageProviderId,
} from "../../infra/provider-usage.js";
import {
buildAgentMainSessionKey,
@@ -257,13 +259,19 @@ export function createSessionStatusTool(opts?: {
delete nextEntry.providerOverride;
delete nextEntry.modelOverride;
delete nextEntry.authProfileOverride;
delete nextEntry.authProfileOverrideSource;
delete nextEntry.authProfileOverrideCompactionCount;
} else {
nextEntry.providerOverride = selection.provider;
nextEntry.modelOverride = selection.model;
delete nextEntry.authProfileOverride;
delete nextEntry.authProfileOverrideSource;
delete nextEntry.authProfileOverrideCompactionCount;
}
store[resolved.key] = nextEntry;
await saveSessionStore(storePath, store);
await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = nextEntry;
});
resolved.entry = nextEntry;
changedModel = true;
}
@@ -275,24 +283,48 @@ export function createSessionStatusTool(opts?: {
defaultModel: DEFAULT_MODEL,
});
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
const usageProvider = resolveUsageProviderId(providerForCard);
let usageLine: string | undefined;
if (usageProvider) {
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const authHealth = buildAuthHealthSummary({
store: authStore,
cfg,
});
const oauthProfiles = authHealth.profiles.filter(
(profile) => profile.type === "oauth" || profile.type === "token",
);
const usageProviders = Array.from(
new Set(
oauthProfiles
.map((profile) => resolveUsageProviderId(profile.provider))
.filter((provider): provider is UsageProviderId => Boolean(provider)),
),
);
const usageByProvider = new Map<string, string>();
if (usageProviders.length > 0) {
try {
const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500,
providers: [usageProvider],
providers: usageProviders,
agentDir,
});
const formatted = formatUsageSummaryLine(usageSummary, {
now: Date.now(),
});
if (formatted) usageLine = formatted;
for (const snapshot of usageSummary.providers) {
const formatted = formatUsageWindowSummary(snapshot, {
now: Date.now(),
maxWindows: 2,
});
if (formatted) usageByProvider.set(snapshot.provider, formatted);
}
} catch {
// ignore
}
}
const usageProvider = resolveUsageProviderId(providerForCard);
const usageLine =
oauthProfiles.length === 0 && usageProvider && usageByProvider.has(usageProvider)
? `📊 Usage: ${usageByProvider.get(usageProvider)}`
: undefined;
const isGroup =
resolved.entry.chatType === "group" ||
resolved.entry.chatType === "room" ||
@@ -338,13 +370,53 @@ export function createSessionStatusTool(opts?: {
includeTranscriptUsage: false,
});
const authStatusLines = (() => {
if (oauthProfiles.length === 0) return [];
const formatStatus = (status: string) => {
if (status === "ok") return "ok";
if (status === "static") return "static";
if (status === "expiring") return "expiring";
if (status === "missing") return "unknown";
return "expired";
};
const profilesByProvider = new Map<string, typeof oauthProfiles>();
for (const profile of oauthProfiles) {
const current = profilesByProvider.get(profile.provider);
if (current) current.push(profile);
else profilesByProvider.set(profile.provider, [profile]);
}
const lines: string[] = ["OAuth/token status"];
for (const [provider, profiles] of profilesByProvider) {
const usageKey = resolveUsageProviderId(provider);
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
const usageSuffix = usage ? ` — usage: ${usage}` : "";
lines.push(`- ${provider}${usageSuffix}`);
for (const profile of profiles) {
const labelText = profile.label || profile.profileId;
const status = formatStatus(profile.status);
const expiry =
profile.status === "static"
? ""
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source = profile.source !== "store" ? ` (${profile.source})` : "";
lines.push(` - ${labelText} ${status}${expiry}${source}`);
}
}
return lines;
})();
const fullStatusText =
authStatusLines.length > 0 ? `${statusText}\n\n${authStatusLines.join("\n")}` : statusText;
return {
content: [{ type: "text", text: statusText }],
content: [{ type: "text", text: fullStatusText }],
details: {
ok: true,
sessionKey: resolved.key,
changedModel,
statusText,
statusText: fullStatusText,
},
};
},
@@ -32,6 +32,7 @@ export async function resolveAnnounceTarget(params: {
const match =
sessions.find((entry) => entry?.key === params.sessionKey) ??
sessions.find((entry) => entry?.key === params.displayKey);
const channel = typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
const to = typeof match?.lastTo === "string" ? match.lastTo : undefined;
const accountId = typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined;
+80
View File
@@ -325,4 +325,84 @@ describe("handleSlackAction", () => {
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());
});
it("uses user token for reads when available", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as ClawdbotConfig;
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1");
});
it("falls back to bot token for reads when user token missing", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1" } },
} as ClawdbotConfig;
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
expect(opts?.token).toBeUndefined();
});
it("uses bot token for writes when userTokenReadOnly is true", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
expect(opts?.token).toBeUndefined();
});
it("allows user token writes when bot token is missing", async () => {
const cfg = {
channels: {
slack: { userToken: "xoxp-1", userTokenReadOnly: false },
},
} as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1");
});
});
+64 -26
View File
@@ -17,6 +17,7 @@ import {
sendSlackMessage,
unpinSlackMessage,
} from "../../slack/actions.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
@@ -77,10 +78,32 @@ export async function handleSlackAction(
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const accountOpts = accountId ? { accountId } : undefined;
const account = resolveSlackAccount({ cfg, accountId });
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
const isActionEnabled = createActionGate(actionConfig);
const userToken = account.config.userToken?.trim() || undefined;
const botToken = account.botToken?.trim();
const allowUserWrites = account.config.userTokenReadOnly === false;
// Choose the most appropriate token for Slack read/write operations.
const getTokenForOperation = (operation: "read" | "write") => {
if (operation === "read") return userToken ?? botToken;
if (!allowUserWrites) return botToken;
return botToken ?? userToken;
};
const buildActionOpts = (operation: "read" | "write") => {
const token = getTokenForOperation(operation);
const tokenOverride = token && token !== botToken ? token : undefined;
if (!accountId && !tokenOverride) return undefined;
return {
...(accountId ? { accountId } : {}),
...(tokenOverride ? { token: tokenOverride } : {}),
};
};
const readOpts = buildActionOpts("read");
const writeOpts = buildActionOpts("write");
if (reactionsActions.has(action)) {
if (!isActionEnabled("reactions")) {
@@ -93,28 +116,28 @@ export async function handleSlackAction(
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
});
if (remove) {
if (accountOpts) {
await removeSlackReaction(channelId, messageId, emoji, accountOpts);
if (writeOpts) {
await removeSlackReaction(channelId, messageId, emoji, writeOpts);
} else {
await removeSlackReaction(channelId, messageId, emoji);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = accountOpts
? await removeOwnSlackReactions(channelId, messageId, accountOpts)
const removed = writeOpts
? await removeOwnSlackReactions(channelId, messageId, writeOpts)
: await removeOwnSlackReactions(channelId, messageId);
return jsonResult({ ok: true, removed });
}
if (accountOpts) {
await reactSlackMessage(channelId, messageId, emoji, accountOpts);
if (writeOpts) {
await reactSlackMessage(channelId, messageId, emoji, writeOpts);
} else {
await reactSlackMessage(channelId, messageId, emoji);
}
return jsonResult({ ok: true, added: emoji });
}
const reactions = accountOpts
? await listSlackReactions(channelId, messageId, accountOpts)
const reactions = readOpts
? await listSlackReactions(channelId, messageId, readOpts)
: await listSlackReactions(channelId, messageId);
return jsonResult({ ok: true, reactions });
}
@@ -134,7 +157,7 @@ export async function handleSlackAction(
context,
);
const result = await sendSlackMessage(to, content, {
accountId: accountId ?? undefined,
...writeOpts,
mediaUrl: mediaUrl ?? undefined,
threadTs: threadTs ?? undefined,
});
@@ -161,8 +184,8 @@ export async function handleSlackAction(
const content = readStringParam(params, "content", {
required: true,
});
if (accountOpts) {
await editSlackMessage(channelId, messageId, content, accountOpts);
if (writeOpts) {
await editSlackMessage(channelId, messageId, content, writeOpts);
} else {
await editSlackMessage(channelId, messageId, content);
}
@@ -175,8 +198,8 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (accountOpts) {
await deleteSlackMessage(channelId, messageId, accountOpts);
if (writeOpts) {
await deleteSlackMessage(channelId, messageId, writeOpts);
} else {
await deleteSlackMessage(channelId, messageId);
}
@@ -192,12 +215,18 @@ export async function handleSlackAction(
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const result = await readSlackMessages(channelId, {
accountId: accountId ?? undefined,
...readOpts,
limit,
before: before ?? 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:
break;
@@ -213,8 +242,8 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (accountOpts) {
await pinSlackMessage(channelId, messageId, accountOpts);
if (writeOpts) {
await pinSlackMessage(channelId, messageId, writeOpts);
} else {
await pinSlackMessage(channelId, messageId);
}
@@ -224,17 +253,26 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (accountOpts) {
await unpinSlackMessage(channelId, messageId, accountOpts);
if (writeOpts) {
await unpinSlackMessage(channelId, messageId, writeOpts);
} else {
await unpinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
const pins = accountOpts
? await listSlackPins(channelId, accountOpts)
const pins = writeOpts
? await listSlackPins(channelId, readOpts)
: 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") {
@@ -242,8 +280,8 @@ export async function handleSlackAction(
throw new Error("Slack member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
const info = accountOpts
? await getSlackMemberInfo(userId, accountOpts)
const info = writeOpts
? await getSlackMemberInfo(userId, readOpts)
: await getSlackMemberInfo(userId);
return jsonResult({ ok: true, info });
}
@@ -252,7 +290,7 @@ export async function handleSlackAction(
if (!isActionEnabled("emojiList")) {
throw new Error("Slack emoji list is disabled.");
}
const emojis = accountOpts ? await listSlackEmojis(accountOpts) : await listSlackEmojis();
const emojis = readOpts ? await listSlackEmojis(readOpts) : await listSlackEmojis();
return jsonResult({ ok: true, emojis });
}
+66 -7
View File
@@ -33,9 +33,30 @@ describe("handleTelegramAction", () => {
}
});
it("adds reactions", async () => {
it("adds reactions when reactionLevel is minimal", async () => {
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;
await handleTelegramAction(
{
@@ -56,7 +77,7 @@ describe("handleTelegramAction", () => {
it("removes reactions on empty emoji", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
@@ -77,7 +98,7 @@ describe("handleTelegramAction", () => {
it("removes reactions when remove flag set", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as ClawdbotConfig;
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 = {
channels: {
telegram: { botToken: "tok", actions: { reactions: false } },
telegram: {
botToken: "tok",
reactionLevel: "minimal",
actions: { reactions: false },
},
},
} as ClawdbotConfig;
await expect(
@@ -113,7 +172,7 @@ describe("handleTelegramAction", () => {
},
cfg,
),
).rejects.toThrow(/Telegram reactions are disabled/);
).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/);
});
it("sends a text message", async () => {
+14 -1
View File
@@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import {
deleteMessageTelegram,
reactMessageTelegram,
@@ -82,8 +83,20 @@ export async function handleTelegramAction(
const isActionEnabled = createActionGate(cfg.channels?.telegram?.actions);
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")) {
throw new Error("Telegram reactions are disabled.");
throw new Error("Telegram reactions are disabled via actions.reactions.");
}
const chatId = readStringOrNumberParam(params, "chatId", {
required: true,
+6 -1
View File
@@ -90,7 +90,12 @@ Add whatever else you want the assistant to know about your local toolchain.
const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md
Keep this file empty unless you want a tiny checklist. Keep it small.
Optional: keep a tiny checklist for heartbeat runs.
Guidance (to avoid nagging):
- Only report items that are truly new or changed.
- Do not invent tasks from old chat context.
- If nothing needs attention, reply HEARTBEAT_OK.
`;
const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after)
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 {
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;
}
+6 -1
View File
@@ -137,7 +137,12 @@ function formatPositionalArgs(
for (const definition of definitions) {
const value = values[definition.name];
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;
parts.push(rendered);
if (definition.captureRemaining) break;
+2 -1
View File
@@ -28,7 +28,8 @@ export type CommandArgMenuSpec = {
title?: string;
};
export type CommandArgValues = Record<string, unknown>;
export type CommandArgValue = string | number | boolean | bigint;
export type CommandArgValues = Record<string, CommandArgValue>;
export type CommandArgs = {
raw?: string;
+3 -1
View File
@@ -1,7 +1,9 @@
import { HEARTBEAT_TOKEN } from "./tokens.js";
// Default heartbeat prompt (used when config.agents.defaults.heartbeat.prompt is unset).
// Keep it tight and avoid encouraging the model to invent/rehash "open loops" from prior chat context.
export const HEARTBEAT_PROMPT =
"Consider outstanding tasks and HEARTBEAT.md guidance from the workspace context (if present). Checkup sometimes on your human during (user local) day time.";
"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.";
export const DEFAULT_HEARTBEAT_EVERY = "30m";
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
+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;
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -116,8 +116,8 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -166,9 +166,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("MiniMax-M2.1 — minimax");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -213,7 +213,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
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(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -1,4 +1,3 @@
import fs from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
@@ -96,7 +95,7 @@ afterEach(() => {
});
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) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
@@ -116,13 +115,103 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>");
expect(normalized).toContain("1) claude-opus-4-5 — anthropic, openrouter");
expect(normalized).toContain("3) gpt-5.2 — openai, openai-codex");
// Each provider/model combo is listed separately for clear selection
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).not.toContain("reasoning");
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 () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
@@ -152,27 +241,12 @@ describe("trigger handling", () => {
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) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
await fs.writeFile(
cfg.session.store,
JSON.stringify(
{
[sessionKey]: {
sessionId: "session-openrouter",
updatedAt: Date.now(),
providerOverride: "openrouter",
modelOverride: "anthropic/claude-opus-4-5",
},
},
null,
2,
),
);
// /model 1 should select the first item (anthropic/claude-opus-4-5)
const res = await getReplyFromConfig(
{
Body: "/model 1",
@@ -188,13 +262,13 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
"Model set to openrouter/anthropic/claude-opus-4-5",
);
// Selecting the default model shows "reset to default" instead of "set to"
expect(normalizeTestText(text ?? "")).toContain("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");
// When selecting the default, overrides are cleared
expect(store[sessionKey]?.providerOverride).toBeUndefined();
expect(store[sessionKey]?.modelOverride).toBeUndefined();
});
});
it("selects a model by index via /model <#>", async () => {
+8 -2
View File
@@ -5,7 +5,7 @@ import {
loadSessionStore,
resolveStorePath,
type SessionEntry,
saveSessionStore,
updateSessionStore,
} from "../../config/sessions.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js";
@@ -90,7 +90,13 @@ export async function tryFastAbortFromMessage(params: {
entry.abortedLastRun = true;
entry.updatedAt = Date.now();
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) {
setAbortMemory(abortKey, true);
}
@@ -14,7 +14,7 @@ import {
resolveAgentIdFromSessionKey,
resolveSessionTranscriptPath,
type SessionEntry,
saveSessionStore,
updateSessionStore,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
@@ -383,6 +383,7 @@ export async function runAgentTurnWithFallback(params: {
params.activeSessionStore &&
params.storePath
) {
const sessionKey = params.sessionKey;
const corruptedSessionId = params.getActiveSessionEntry()?.sessionId;
defaultRuntime.error(
`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
delete params.activeSessionStore[params.sessionKey];
await saveSessionStore(params.storePath, params.activeSessionStore);
// Keep the in-memory snapshot consistent with the on-disk store reset.
delete params.activeSessionStore[sessionKey];
// Remove session entry from store using a fresh, locked snapshot.
await updateSessionStore(params.storePath, (store) => {
delete store[sessionKey];
});
} catch (cleanupErr) {
defaultRuntime.error(
`Failed to reset corrupted session ${params.sessionKey}: ${String(cleanupErr)}`,
+13 -5
View File
@@ -10,7 +10,7 @@ import {
resolveAgentIdFromSessionKey,
resolveSessionTranscriptPath,
type SessionEntry,
saveSessionStore,
updateSessionStore,
updateSessionStoreEntry,
} from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
@@ -156,7 +156,9 @@ export async function runReplyAgent(params: {
activeSessionEntry.updatedAt = Date.now();
activeSessionStore[sessionKey] = activeSessionEntry;
if (storePath) {
await saveSessionStore(storePath, activeSessionStore);
await updateSessionStore(storePath, (store) => {
store[sessionKey] = activeSessionEntry as SessionEntry;
});
}
}
typing.cleanup();
@@ -170,7 +172,9 @@ export async function runReplyAgent(params: {
activeSessionEntry.updatedAt = Date.now();
activeSessionStore[sessionKey] = activeSessionEntry;
if (storePath) {
await saveSessionStore(storePath, activeSessionStore);
await updateSessionStore(storePath, (store) => {
store[sessionKey] = activeSessionEntry as SessionEntry;
});
}
}
typing.cleanup();
@@ -224,7 +228,9 @@ export async function runReplyAgent(params: {
nextEntry.sessionFile = nextSessionFile;
activeSessionStore[sessionKey] = nextEntry;
try {
await saveSessionStore(storePath, activeSessionStore);
await updateSessionStore(storePath, (store) => {
store[sessionKey] = nextEntry;
});
} catch (err) {
defaultRuntime.error(
`Failed to persist session reset after compaction failure (${sessionKey}): ${String(err)}`,
@@ -280,7 +286,9 @@ export async function runReplyAgent(params: {
activeSessionEntry.updatedAt = Date.now();
activeSessionStore[sessionKey] = activeSessionEntry;
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 { saveSessionStore } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
import { setAbortMemory } from "./abort.js";
export async function applySessionHints(params: {
@@ -23,7 +23,16 @@ export async function applySessionHints(params: {
params.sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = params.sessionEntry;
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) {
setAbortMemory(params.abortKey, false);
+13 -5
View File
@@ -1,6 +1,6 @@
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.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 { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.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.sessionStore[params.sessionKey] = params.sessionEntry;
if (params.storePath) {
await saveSessionStore(params.storePath, params.sessionStore);
await updateSessionStore(params.storePath, (store) => {
store[params.sessionKey] = params.sessionEntry as SessionEntry;
});
}
}
return {
@@ -107,7 +109,9 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
params.sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = params.sessionEntry;
if (params.storePath) {
await saveSessionStore(params.storePath, params.sessionStore);
await updateSessionStore(params.storePath, (store) => {
store[params.sessionKey] = params.sessionEntry as SessionEntry;
});
}
}
const label =
@@ -190,7 +194,9 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
abortTarget.entry.updatedAt = Date.now();
params.sessionStore[abortTarget.key] = abortTarget.entry;
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) {
setAbortMemory(params.command.abortKey, true);
@@ -215,7 +221,9 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
abortTarget.entry.updatedAt = Date.now();
params.sessionStore[abortTarget.key] = abortTarget.entry;
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) {
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 { resolveSandboxRuntimeStatus } from "../../agents/sandbox.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 { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
@@ -264,8 +264,12 @@ export async function handleDirectiveOnly(params: {
}
if (profileOverride) {
sessionEntry.authProfileOverride = profileOverride;
sessionEntry.authProfileOverrideSource = "user";
delete sessionEntry.authProfileOverrideCompactionCount;
} else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
}
}
if (directives.hasQueueDirective && directives.queueReset) {
@@ -288,7 +292,9 @@ export async function handleDirectiveOnly(params: {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (modelSelection) {
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";
export type ModelPickerCatalogEntry = {
@@ -7,11 +7,7 @@ export type ModelPickerCatalogEntry = {
name?: string;
};
export type ModelPickerItem = {
model: string;
providers: string[];
providerModels: Record<string, string>;
};
export type ModelPickerItem = ModelRef;
const MODEL_PICK_PROVIDER_PREFERENCE = [
"anthropic",
@@ -31,68 +27,43 @@ const MODEL_PICK_PROVIDER_PREFERENCE = [
"lmstudio",
] as const;
function normalizeModelFamilyId(id: string): string {
const trimmed = id.trim();
if (!trimmed) return trimmed;
const parts = trimmed.split("/").filter(Boolean);
return parts.length > 0 ? (parts[parts.length - 1] ?? trimmed) : trimmed;
}
const PROVIDER_RANK = new Map<string, number>(
MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]),
);
function sortProvidersForPicker(providers: string[]): string[] {
const pref = new Map<string, number>(
MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]),
);
return providers.sort((a, b) => {
const pa = pref.get(a);
const pb = pref.get(b);
if (pa !== undefined && pb !== undefined) return pa - pb;
if (pa !== undefined) return -1;
if (pb !== undefined) return 1;
return a.localeCompare(b);
});
function compareProvidersForPicker(a: string, b: string): number {
const pa = PROVIDER_RANK.get(a);
const pb = PROVIDER_RANK.get(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[] {
const byModel = new Map<string, { providerModels: Record<string, string> }>();
const seen = new Set<string>();
const out: ModelPickerItem[] = [];
for (const entry of catalog) {
const provider = normalizeProviderId(entry.provider);
const model = normalizeModelFamilyId(entry.id);
const model = entry.id?.trim();
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: {
item: ModelPickerItem;
preferredProvider?: string;
}): { provider: string; model: string } | null {
const preferred = params.preferredProvider
? normalizeProviderId(params.preferredProvider)
: undefined;
if (preferred && params.item.providerModels[preferred]) {
return {
provider: preferred,
model: params.item.providerModels[preferred],
};
const key = `${provider}/${model}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ model, provider });
}
const first = params.item.providers[0];
if (!first) return null;
return {
provider: first,
model: params.item.providerModels[first] ?? params.item.model,
};
// Sort by provider preference first, then by model name
out.sort((a, b) => {
const providerOrder = compareProvidersForPicker(a.provider, b.provider);
if (providerOrder !== 0) return providerOrder;
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
});
return out;
}
export function resolveProviderEndpointLabel(
@@ -18,7 +18,6 @@ import {
import {
buildModelPickerItems,
type ModelPickerCatalogEntry,
pickProviderForModel,
resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.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 lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"];
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");
return { text: lines.join("\n") };
@@ -236,22 +235,13 @@ export function resolveModelSelectionFromDirective(params: {
errorText: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
errorText: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const key = `${item.provider}/${item.model}`;
const aliases = params.aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault: picked.provider === params.defaultProvider && picked.model === params.defaultModel,
provider: item.provider,
model: item.model,
isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel,
...(alias ? { alias } : {}),
};
} else {
@@ -14,7 +14,7 @@ import {
resolveModelRefFromString,
} from "../../agents/model-selection.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 { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { resolveProfileOverride } from "./directive-handling.auth.js";
@@ -156,8 +156,12 @@ export async function persistInlineDirectives(params: {
}
if (profileOverride) {
sessionEntry.authProfileOverride = profileOverride;
sessionEntry.authProfileOverrideSource = "user";
delete sessionEntry.authProfileOverrideCompactionCount;
} else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
}
provider = resolved.ref.provider;
model = resolved.ref.model;
@@ -184,7 +188,9 @@ export async function persistInlineDirectives(params: {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
+99 -3
View File
@@ -5,11 +5,16 @@ import {
isEmbeddedPiRunStreaming,
resolveEmbeddedSessionLane,
} from "../../agents/pi-embedded.js";
import {
ensureAuthProfileStore,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveSessionFilePath,
type SessionEntry,
saveSessionStore,
updateSessionStore,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
@@ -97,6 +102,86 @@ type RunPreparedReplyParams = {
abortedLastRun: boolean;
};
async function resolveSessionAuthProfileOverride(params: {
cfg: ClawdbotConfig;
provider: string;
agentDir: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
isNewSession: boolean;
}): Promise<string | undefined> {
const {
cfg,
provider,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
storePath,
isNewSession,
} = params;
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const order = resolveAuthProfileOrder({ cfg, store, provider });
if (order.length === 0) return sessionEntry.authProfileOverride;
const pickFirstAvailable = () =>
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
const pickNextAvailable = (current: string) => {
const startIndex = order.indexOf(current);
if (startIndex < 0) return pickFirstAvailable();
for (let offset = 1; offset <= order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (!isProfileInCooldown(store, candidate)) return candidate;
}
return order[startIndex] ?? order[0];
};
const compactionCount = sessionEntry.compactionCount ?? 0;
const storedCompaction =
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? sessionEntry.authProfileOverrideCompactionCount
: compactionCount;
let current = sessionEntry.authProfileOverride?.trim();
if (current && !order.includes(current)) current = undefined;
const source = sessionEntry.authProfileOverrideSource ?? (current ? "user" : undefined);
if (source === "user" && current && !isNewSession) {
return current;
}
let next = current;
if (isNewSession) {
next = current ? pickNextAvailable(current) : pickFirstAvailable();
} else if (current && compactionCount > storedCompaction) {
next = pickNextAvailable(current);
} else if (!current || isProfileInCooldown(store, current)) {
next = pickFirstAvailable();
}
if (!next) return current;
const shouldPersist =
next !== sessionEntry.authProfileOverride ||
sessionEntry.authProfileOverrideSource !== "auto" ||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
if (shouldPersist) {
sessionEntry.authProfileOverride = next;
sessionEntry.authProfileOverrideSource = "auto";
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
return next;
}
export async function runPreparedReply(
params: RunPreparedReplyParams,
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
@@ -276,7 +361,9 @@ export async function runPreparedReply(
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
}
}
@@ -312,7 +399,16 @@ export async function runPreparedReply(
resolvedQueue.mode === "followup" ||
resolvedQueue.mode === "collect" ||
resolvedQueue.mode === "steer-backlog";
const authProfileId = sessionEntry?.authProfileOverride;
const authProfileId = await resolveSessionAuthProfileOverride({
cfg,
provider,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
storePath,
isNewSession,
});
const followupRun = {
prompt: queuedBody,
messageId: sessionCtx.MessageSid,
+9 -3
View File
@@ -10,7 +10,7 @@ import {
resolveThinkingDefault,
} from "../../agents/model-selection.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";
export type ModelDirectiveSelection = {
@@ -189,7 +189,9 @@ export async function createModelSelectionState(params: {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
resetModelOverride = true;
}
@@ -215,10 +217,14 @@ export async function createModelSelectionState(params: {
const profile = store.profiles[sessionEntry.authProfileOverride];
if (!profile || profile.provider !== provider) {
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
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";
describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events", async () => {
it("adds a UTC timestamp to queued system events", async () => {
vi.useFakeTimers();
const timestamp = new Date("2026-01-12T20:19:17");
vi.setSystemTime(timestamp);
@@ -20,15 +20,7 @@ describe("prependSystemEvents", () => {
prefixedBodyBase: "User: hi",
});
const expectedTimestamp = timestamp.toLocaleString("en-US", {
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const expectedTimestamp = "2026-01-12T20:19:17Z";
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff. Show more