Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c51724f39b | ||
|
|
a70fcc8ae0 | ||
|
|
fa521154ff | ||
|
|
dffc1a4dcd | ||
|
|
61e385b331 | ||
|
|
f8f319713f | ||
|
|
bb665bf22c | ||
|
|
f17dcb6213 | ||
|
|
d54c101100 | ||
|
|
96daa51d45 | ||
|
|
6084421ec6 | ||
|
|
bca5c0d569 | ||
|
|
8c3cdba21c | ||
|
|
e274b5a040 | ||
|
|
a139d35fa2 | ||
|
|
8312a19f02 | ||
|
|
fe8b28cdd9 | ||
|
|
10eb1beccf | ||
|
|
a4b347b454 | ||
|
|
688a0ce439 | ||
|
|
6146acbb69 | ||
|
|
35492f8513 | ||
|
|
db9be87d94 | ||
|
|
4a99b9b651 | ||
|
|
7dea403302 | ||
|
|
10731dfee3 | ||
|
|
d00f2d9c0c | ||
|
|
8b89980a89 | ||
|
|
634a429c50 | ||
|
|
bf90815b9e | ||
|
|
2b113c4d6c | ||
|
|
7f6a288bd3 | ||
|
|
daf471c450 | ||
|
|
cb78fa46a1 | ||
|
|
316e8b2eb2 | ||
|
|
4de81ed6c4 | ||
|
|
b6fb24f6d2 | ||
|
|
2b1c26f900 | ||
|
|
47ea9356d1 | ||
|
|
f12c1b391f | ||
|
|
05658b6609 | ||
|
|
dfb6630de1 | ||
|
|
eb7656d68c | ||
|
|
0e1dcf9cb4 | ||
|
|
d05c3d0659 | ||
|
|
36292d3fbb | ||
|
|
1ae344d8a6 | ||
|
|
01c43b0b0c | ||
|
|
fc4aa9a683 | ||
|
|
c043e9767f | ||
|
|
8b48299d8f | ||
|
|
0f27cff247 | ||
|
|
d0455f2683 |
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: 95ad7532c1...c1243a7978.
@@ -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>
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+175
-60
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
];
|
||||
|
||||
+20
-38
@@ -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);
|
||||
});
|
||||
});
|
||||
+28
-40
@@ -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);
|
||||
});
|
||||
});
|
||||
+33
-33
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}` : "",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
+7
-7
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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();
|
||||
});
|
||||
|
||||
+100
-26
@@ -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 () => {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user