Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1561b1c491 | ||
|
|
57d3c8572f | ||
|
|
c609be185e |
No files matched your search
@@ -60,9 +60,6 @@
|
||||
- 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!
|
||||
|
||||
+112
-36
@@ -2,17 +2,14 @@
|
||||
|
||||
## 2026.1.15 (unreleased)
|
||||
|
||||
- Slack: add optional user token routing for read access with safe write fallback. (#981) — thanks @jalehman.
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||
@@ -30,62 +27,141 @@
|
||||
- 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.
|
||||
- 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: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
|
||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
|
||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
||||
- 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`.
|
||||
- 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).
|
||||
|
||||
### Fixes
|
||||
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
|
||||
#### Gateway / Daemon / Sessions
|
||||
- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.
|
||||
- Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare `main` alias).
|
||||
- Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.
|
||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
||||
- Browser: 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.
|
||||
- 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).
|
||||
- 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`).
|
||||
|
||||
## 2026.1.14
|
||||
#### 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.
|
||||
|
||||
### 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.
|
||||
#### 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.
|
||||
- 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.
|
||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||
- 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.
|
||||
- Agents: preserve Antigravity Claude signatures and skip Gemini downgrades. (#959) — thanks @rdev.
|
||||
|
||||
#### macOS / Apps
|
||||
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
||||
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
||||
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
|
||||
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
||||
- 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.
|
||||
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
|
||||
- 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: 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)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -474,23 +474,22 @@ 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/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>
|
||||
<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>
|
||||
</p>
|
||||
+6
-91
@@ -27,8 +27,7 @@ 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) 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:
|
||||
4) **Event Subscriptions** → enable events and subscribe to:
|
||||
- `message.*` (includes edits/deletes/thread broadcasts)
|
||||
- `app_mention`
|
||||
- `reaction_added`, `reaction_removed`
|
||||
@@ -36,9 +35,9 @@ Minimal config:
|
||||
- `channel_id_changed`
|
||||
- `channel_rename`
|
||||
- `pin_added`, `pin_removed`
|
||||
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.
|
||||
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.
|
||||
|
||||
Use the manifest below so scopes and events stay in sync.
|
||||
|
||||
@@ -64,52 +63,6 @@ Or via config:
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
User tokens are configured in the config file (no env var support). For
|
||||
multi-account, set `channels.slack.accounts.<id>.userToken`.
|
||||
|
||||
Example with bot + app + user tokens:
|
||||
```json5
|
||||
{
|
||||
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).
|
||||
@@ -130,8 +83,7 @@ Disable with:
|
||||
```
|
||||
|
||||
## Manifest (optional)
|
||||
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.
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -181,21 +133,6 @@ user scopes if you plan to configure a user token.
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -229,7 +166,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.
|
||||
|
||||
### Bot token scopes (required)
|
||||
### Required scopes
|
||||
- `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)
|
||||
@@ -251,17 +188,6 @@ 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)
|
||||
@@ -394,17 +320,6 @@ 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`.
|
||||
|
||||
@@ -39,22 +39,6 @@ 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.
|
||||
|
||||
@@ -1052,8 +1052,6 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
enabled: true,
|
||||
botToken: "xoxb-...",
|
||||
appToken: "xapp-...",
|
||||
userToken: "xoxp-...", // optional (read-only by default)
|
||||
userTokenReadOnly: true, // default: true
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "pairing", // pairing | allowlist | open | disabled
|
||||
@@ -1104,7 +1102,6 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
||||
|
||||
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
|
||||
Optional user tokens (`xoxp-...`) are configured in the config file (no env var support). Reads prefer the user token when present; writes use the bot token unless you set `channels.slack.userTokenReadOnly: false` and no bot token is available.
|
||||
Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`).
|
||||
|
||||
Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadSessionStoreMock = vi.fn();
|
||||
const updateSessionStoreMock = vi.fn();
|
||||
const saveSessionStoreMock = 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),
|
||||
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;
|
||||
},
|
||||
saveSessionStore: (storePath: string, store: Record<string, unknown>) =>
|
||||
saveSessionStoreMock(storePath, store),
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
};
|
||||
});
|
||||
@@ -80,7 +73,7 @@ import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
describe("session_status tool", () => {
|
||||
it("returns a status card for the current session", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
saveSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
main: {
|
||||
sessionId: "s1",
|
||||
@@ -103,7 +96,7 @@ describe("session_status tool", () => {
|
||||
|
||||
it("errors for unknown session keys", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
saveSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
main: { sessionId: "s1", updatedAt: 10 },
|
||||
});
|
||||
@@ -117,12 +110,12 @@ describe("session_status tool", () => {
|
||||
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
|
||||
"Unknown sessionKey",
|
||||
);
|
||||
expect(updateSessionStoreMock).not.toHaveBeenCalled();
|
||||
expect(saveSessionStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets per-session model override via model=default", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
saveSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
main: {
|
||||
sessionId: "s1",
|
||||
@@ -140,8 +133,8 @@ describe("session_status tool", () => {
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
await tool.execute("call3", { model: "default" });
|
||||
expect(updateSessionStoreMock).toHaveBeenCalled();
|
||||
const [, savedStore] = updateSessionStoreMock.mock.calls.at(-1) as [
|
||||
expect(saveSessionStoreMock).toHaveBeenCalled();
|
||||
const [, savedStore] = saveSessionStoreMock.mock.calls.at(-1) as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
|
||||
+38
-20
@@ -34,15 +34,17 @@ describe("clawdbot-tools: subagents", () => {
|
||||
};
|
||||
});
|
||||
|
||||
it("sessions_spawn deletes session when cleanup=delete via agent.wait", async () => {
|
||||
it("sessions_spawn announces via agent.wait when lifecycle events are missing", 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 };
|
||||
@@ -55,12 +57,15 @@ describe("clawdbot-tools: subagents", () => {
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
timeout?: number;
|
||||
lane?: string;
|
||||
};
|
||||
// Only capture the first agent call (subagent spawn, not main agent trigger)
|
||||
if (params?.lane === "subagent") {
|
||||
const message = params?.message ?? "";
|
||||
const sessionKey = params?.sessionKey ?? "";
|
||||
if (message === "Sub-agent announce step.") {
|
||||
sessionLastAssistantText.set(sessionKey, "announce now");
|
||||
} else {
|
||||
childRunId = runId;
|
||||
childSessionKey = params?.sessionKey ?? "";
|
||||
childSessionKey = sessionKey;
|
||||
sessionLastAssistantText.set(sessionKey, "result");
|
||||
expect(params?.channel).toBe("discord");
|
||||
expect(params?.timeout).toBe(1);
|
||||
}
|
||||
@@ -80,6 +85,24 @@ 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;
|
||||
@@ -112,24 +135,19 @@ 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");
|
||||
|
||||
// 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(sendParams.channel).toBe("discord");
|
||||
expect(sendParams.to).toBe("channel:req");
|
||||
expect(sendParams.message ?? "").toContain("announce now");
|
||||
expect(sendParams.message ?? "").toContain("Stats:");
|
||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
});
|
||||
+38
-26
@@ -35,15 +35,17 @@ describe("clawdbot-tools: subagents", () => {
|
||||
};
|
||||
});
|
||||
|
||||
it("sessions_spawn runs cleanup via lifecycle events", async () => {
|
||||
it("sessions_spawn announces back to the requester group channel", 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 };
|
||||
@@ -56,12 +58,15 @@ describe("clawdbot-tools: subagents", () => {
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
timeout?: number;
|
||||
lane?: string;
|
||||
};
|
||||
// Only capture the first agent call (subagent spawn, not main agent trigger)
|
||||
if (params?.lane === "subagent") {
|
||||
const message = params?.message ?? "";
|
||||
const sessionKey = params?.sessionKey ?? "";
|
||||
if (message === "Sub-agent announce step.") {
|
||||
sessionLastAssistantText.set(sessionKey, "announce now");
|
||||
} else {
|
||||
childRunId = runId;
|
||||
childSessionKey = params?.sessionKey ?? "";
|
||||
childSessionKey = sessionKey;
|
||||
sessionLastAssistantText.set(sessionKey, "result");
|
||||
expect(params?.channel).toBe("discord");
|
||||
expect(params?.timeout).toBe(1);
|
||||
}
|
||||
@@ -74,8 +79,26 @@ describe("clawdbot-tools: subagents", () => {
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
waitCalls.push(params ?? {});
|
||||
// Return "ok" with timing info for the child run
|
||||
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||
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" };
|
||||
}
|
||||
if (request.method === "sessions.delete") {
|
||||
const params = request.params as { key?: string } | undefined;
|
||||
@@ -118,12 +141,8 @@ 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;
|
||||
@@ -137,24 +156,17 @@ 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);
|
||||
|
||||
// Second call: main agent trigger with announce message
|
||||
const second = agentCalls[1]?.params as
|
||||
| {
|
||||
sessionKey?: string;
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
}
|
||||
| { channel?: string; deliver?: boolean; lane?: string }
|
||||
| undefined;
|
||||
expect(second?.sessionKey).toBe("discord:group:req");
|
||||
expect(second?.deliver).toBe(true);
|
||||
expect(second?.message).toContain("background task");
|
||||
expect(second?.lane).toBe("nested");
|
||||
expect(second?.deliver).toBe(false);
|
||||
expect(second?.channel).toBe("webchat");
|
||||
|
||||
// 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(sendParams.channel).toBe("discord");
|
||||
expect(sendParams.to).toBe("channel:req");
|
||||
expect(sendParams.message ?? "").toContain("announce now");
|
||||
expect(sendParams.message ?? "").toContain("Stats:");
|
||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
});
|
||||
+33
-33
@@ -35,15 +35,16 @@ describe("clawdbot-tools: subagents", () => {
|
||||
};
|
||||
});
|
||||
|
||||
it("sessions_spawn runs cleanup flow after subagent completion", async () => {
|
||||
it("sessions_spawn resolves main announce target from sessions.list", 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 }> = [];
|
||||
let patchParams: { key?: string; label?: string } = {};
|
||||
const sessionLastAssistantText = new Map<string, string>();
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
@@ -65,12 +66,15 @@ describe("clawdbot-tools: subagents", () => {
|
||||
const params = request.params as {
|
||||
message?: string;
|
||||
sessionKey?: string;
|
||||
lane?: string;
|
||||
};
|
||||
// Only capture the first agent call (subagent spawn, not main agent trigger)
|
||||
if (params?.lane === "subagent") {
|
||||
const message = params?.message ?? "";
|
||||
const sessionKey = params?.sessionKey ?? "";
|
||||
if (message === "Sub-agent announce step.") {
|
||||
sessionLastAssistantText.set(sessionKey, "hello from sub");
|
||||
} else {
|
||||
childRunId = runId;
|
||||
childSessionKey = params?.sessionKey ?? "";
|
||||
childSessionKey = sessionKey;
|
||||
sessionLastAssistantText.set(sessionKey, "done");
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
@@ -81,12 +85,26 @@ describe("clawdbot-tools: subagents", () => {
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
waitCalls.push(params ?? {});
|
||||
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||
const status = params?.runId === childRunId ? "timeout" : "ok";
|
||||
return { runId: params?.runId ?? "run-1", status };
|
||||
}
|
||||
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 === "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.delete") {
|
||||
return { ok: true };
|
||||
@@ -103,7 +121,6 @@ describe("clawdbot-tools: subagents", () => {
|
||||
const result = await tool.execute("call2", {
|
||||
task: "do thing",
|
||||
runTimeoutSeconds: 1,
|
||||
label: "my-task",
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
status: "accepted",
|
||||
@@ -127,29 +144,12 @@ describe("clawdbot-tools: subagents", () => {
|
||||
|
||||
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
||||
expect(childWait?.timeoutMs).toBe(1000);
|
||||
// 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(sendParams.channel).toBe("whatsapp");
|
||||
expect(sendParams.to).toBe("+123");
|
||||
expect(sendParams.message ?? "").toContain("hello from sub");
|
||||
expect(sendParams.message ?? "").toContain("Stats:");
|
||||
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
|
||||
it("sessions_spawn only allows same-agent by default", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
@@ -231,7 +230,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
config: params.config,
|
||||
});
|
||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
@@ -243,7 +241,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||
: undefined,
|
||||
skillsPrompt,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
|
||||
@@ -12,7 +12,6 @@ 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";
|
||||
@@ -190,7 +189,6 @@ export async function runEmbeddedAttempt(
|
||||
config: params.config,
|
||||
});
|
||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||
|
||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
@@ -204,7 +202,6 @@ export async function runEmbeddedAttempt(
|
||||
: undefined,
|
||||
skillsPrompt,
|
||||
reactionGuidance,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
|
||||
@@ -1,7 +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, type PromptMode } from "../system-prompt.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
import { buildToolSummaryMap } from "../tool-summaries.js";
|
||||
import type { EmbeddedSandboxInfo } from "./types.js";
|
||||
import type { ReasoningLevel, ThinkLevel } from "./utils.js";
|
||||
@@ -19,8 +19,6 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||
level: "minimal" | "extensive";
|
||||
channel: string;
|
||||
};
|
||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||
promptMode?: PromptMode;
|
||||
runtimeInfo: {
|
||||
host: string;
|
||||
os: string;
|
||||
@@ -49,7 +47,6 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||
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),
|
||||
|
||||
@@ -5,22 +5,10 @@ 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 {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const agentSpy = vi.fn(async () => ({ runId: "run-main", status: "ok" }));
|
||||
const sendSpy = vi.fn(async () => ({}));
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(async (req: unknown) => {
|
||||
const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } };
|
||||
if (typed.method === "agent") {
|
||||
return await agentSpy(typed);
|
||||
const typed = req as { method?: string; params?: { message?: string } };
|
||||
if (typed.method === "send") {
|
||||
return await sendSpy(typed);
|
||||
}
|
||||
if (typed.method === "agent.wait") {
|
||||
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
|
||||
@@ -18,11 +18,24 @@ 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(() => ({})),
|
||||
loadSessionStore: vi.fn(async () => ({ entries: {} })),
|
||||
resolveAgentIdFromSessionKey: () => "main",
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
}));
|
||||
@@ -35,10 +48,10 @@ vi.mock("../config/config.js", () => ({
|
||||
|
||||
describe("subagent announce formatting", () => {
|
||||
beforeEach(() => {
|
||||
agentSpy.mockClear();
|
||||
sendSpy.mockClear();
|
||||
});
|
||||
|
||||
it("sends instructional message to main agent with status and findings", async () => {
|
||||
it("wraps unstructured announce into Status/Result/Notes", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
@@ -53,21 +66,22 @@ describe("subagent announce formatting", () => {
|
||||
endedAt: 20,
|
||||
});
|
||||
|
||||
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(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(msg).toContain("boom");
|
||||
expect(msg).toContain("Findings:");
|
||||
expect(msg).toContain("raw subagent reply");
|
||||
expect(msg).toContain("Stats:");
|
||||
});
|
||||
|
||||
it("includes success status when outcome is ok", async () => {
|
||||
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",
|
||||
);
|
||||
|
||||
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",
|
||||
@@ -76,14 +90,14 @@ describe("subagent announce formatting", () => {
|
||||
task: "do thing",
|
||||
timeoutMs: 1000,
|
||||
cleanup: "keep",
|
||||
waitForCompletion: false,
|
||||
waitForCompletion: true,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const msg = call?.params?.message as string;
|
||||
expect(msg).toContain("completed successfully");
|
||||
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
|
||||
expect(msg).toContain("Status: error");
|
||||
expect(msg).toContain("Result:");
|
||||
expect(msg).toContain("Notes:");
|
||||
});
|
||||
});
|
||||
+161
-47
@@ -8,7 +8,11 @@ import {
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { readLatestAssistantReply } from "./tools/agent-step.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";
|
||||
|
||||
function formatDurationShort(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return undefined;
|
||||
@@ -145,27 +149,27 @@ export function buildSubagentSystemPrompt(params: {
|
||||
"",
|
||||
"## Your Role",
|
||||
`- You were created to handle: ${taskText}`,
|
||||
"- Complete this task. That's your entire purpose.",
|
||||
"- Complete this task and report back. 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. **Complete the task** - Your final message will be automatically reported to the main agent",
|
||||
"2. **Report completion** - When done, summarize results clearly",
|
||||
"3. **Don't initiate** - No heartbeats, no proactive actions, no side quests",
|
||||
"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",
|
||||
"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.",
|
||||
"",
|
||||
"## 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",
|
||||
"- NO using the `message` tool directly",
|
||||
"",
|
||||
"## Output Format",
|
||||
"When complete, respond with:",
|
||||
"- **Status:** success | failed | blocked",
|
||||
"- **Result:** [what you accomplished]",
|
||||
"- **Notes:** [anything the main agent should know] - discuss gimme options",
|
||||
"",
|
||||
"## Session Context",
|
||||
params.label ? `- Label: ${params.label}` : undefined,
|
||||
@@ -173,6 +177,8 @@ 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");
|
||||
}
|
||||
@@ -182,6 +188,109 @@ 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;
|
||||
@@ -231,6 +340,8 @@ 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({
|
||||
@@ -246,50 +357,53 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
|
||||
if (!outcome) outcome = { status: "unknown" };
|
||||
|
||||
// Build stats
|
||||
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;
|
||||
|
||||
const statsLine = await buildSubagentStatsLine({
|
||||
sessionKey: params.childSessionKey,
|
||||
startedAt: params.startedAt,
|
||||
endedAt: params.endedAt,
|
||||
});
|
||||
|
||||
// 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)",
|
||||
"",
|
||||
const message = normalizeAnnounceBody({
|
||||
outcome,
|
||||
announceReply,
|
||||
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: "agent",
|
||||
params: {
|
||||
sessionKey: params.requesterSessionKey,
|
||||
message: triggerMessage,
|
||||
deliver: true,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
await callGateway({
|
||||
method: "send",
|
||||
params: {
|
||||
to: announceTarget.to,
|
||||
message,
|
||||
channel: announceTarget.channel,
|
||||
accountId: announceTarget.accountId,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
didAnnounce = true;
|
||||
} catch {
|
||||
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
|
||||
|
||||
@@ -68,24 +68,21 @@ describe("subagent registry persistence", () => {
|
||||
const mod2 = await import("./subagent-registry.js");
|
||||
mod2.initSubagentRegistry();
|
||||
|
||||
// allow queued async wait/cleanup to execute
|
||||
// allow queued async wait/announce to execute
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(announceSpy).toHaveBeenCalled();
|
||||
|
||||
type AnnounceParams = {
|
||||
childSessionKey: string;
|
||||
childRunId: string;
|
||||
requesterSessionKey: string;
|
||||
task: string;
|
||||
cleanup: string;
|
||||
label?: string;
|
||||
childSessionKey: 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("skips cleanup when cleanupHandled/announceHandled was persisted", async () => {
|
||||
it("retries announce even when announceHandled was persisted", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
||||
|
||||
@@ -103,7 +100,7 @@ describe("subagent registry persistence", () => {
|
||||
createdAt: 1,
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
cleanupHandled: true, // Already handled - should be skipped
|
||||
announceHandled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -116,11 +113,10 @@ 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 { childSessionKey?: string }).childSessionKey === "agent:main:subagent:two",
|
||||
(params) => (params as { childRunId?: string }).childRunId === "run-2",
|
||||
);
|
||||
expect(match).toBeFalsy();
|
||||
expect(match).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,7 @@ type PersistedSubagentRegistry = {
|
||||
|
||||
const REGISTRY_VERSION = 1 as const;
|
||||
|
||||
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled"> & {
|
||||
announceHandled?: boolean;
|
||||
};
|
||||
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled">;
|
||||
|
||||
export function resolveSubagentRegistryPath(): string {
|
||||
return path.join(STATE_DIR_CLAWDBOT, "subagents", "runs.json");
|
||||
@@ -34,27 +32,12 @@ 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,
|
||||
cleanupCompletedAt,
|
||||
cleanupHandled,
|
||||
announceHandled: Boolean(announceCompletedAt),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -22,12 +22,8 @@ export type SubagentRunRecord = {
|
||||
endedAt?: number;
|
||||
outcome?: SubagentRunOutcome;
|
||||
archiveAtMs?: number;
|
||||
/** @deprecated Use cleanupCompletedAt instead */
|
||||
announceCompletedAt?: number;
|
||||
/** @deprecated Use cleanupHandled instead */
|
||||
announceHandled?: boolean;
|
||||
cleanupCompletedAt?: number;
|
||||
cleanupHandled?: boolean;
|
||||
announceHandled: boolean;
|
||||
};
|
||||
|
||||
const subagentRuns = new Map<string, SubagentRunRecord>();
|
||||
@@ -50,11 +46,11 @@ function resumeSubagentRun(runId: string) {
|
||||
if (!runId || resumedRuns.has(runId)) return;
|
||||
const entry = subagentRuns.get(runId);
|
||||
if (!entry) return;
|
||||
if (entry.cleanupCompletedAt) return;
|
||||
if (entry.announceCompletedAt) return;
|
||||
|
||||
if (typeof entry.endedAt === "number" && entry.endedAt > 0) {
|
||||
if (!beginSubagentCleanup(runId)) return;
|
||||
void runSubagentAnnounceFlow({
|
||||
if (!beginSubagentAnnounce(runId)) return;
|
||||
const announce = runSubagentAnnounceFlow({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
childRunId: entry.runId,
|
||||
requesterSessionKey: entry.requesterSessionKey,
|
||||
@@ -68,8 +64,9 @@ function resumeSubagentRun(runId: string) {
|
||||
endedAt: entry.endedAt,
|
||||
label: entry.label,
|
||||
outcome: entry.outcome,
|
||||
}).then((didAnnounce) => {
|
||||
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
|
||||
});
|
||||
void announce.then((didAnnounce) => {
|
||||
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
|
||||
});
|
||||
resumedRuns.add(runId);
|
||||
return;
|
||||
@@ -159,9 +156,7 @@ async function sweepSubagentRuns() {
|
||||
}
|
||||
|
||||
function ensureListener() {
|
||||
if (listenerStarted) {
|
||||
return;
|
||||
}
|
||||
if (listenerStarted) return;
|
||||
listenerStarted = true;
|
||||
listenerStop = onAgentEvent((evt) => {
|
||||
if (!evt || evt.stream !== "lifecycle") return;
|
||||
@@ -191,10 +186,10 @@ function ensureListener() {
|
||||
}
|
||||
persistSubagentRuns();
|
||||
|
||||
if (!beginSubagentCleanup(evt.runId)) {
|
||||
if (!beginSubagentAnnounce(evt.runId)) {
|
||||
return;
|
||||
}
|
||||
void runSubagentAnnounceFlow({
|
||||
const announce = runSubagentAnnounceFlow({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
childRunId: entry.runId,
|
||||
requesterSessionKey: entry.requesterSessionKey,
|
||||
@@ -208,17 +203,14 @@ function ensureListener() {
|
||||
endedAt: entry.endedAt,
|
||||
label: entry.label,
|
||||
outcome: entry.outcome,
|
||||
}).then((didAnnounce) => {
|
||||
finalizeSubagentCleanup(evt.runId, entry.cleanup, didAnnounce);
|
||||
});
|
||||
void announce.then((didAnnounce) => {
|
||||
finalizeSubagentAnnounce(evt.runId, entry.cleanup, didAnnounce);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeSubagentCleanup(
|
||||
runId: string,
|
||||
cleanup: "delete" | "keep",
|
||||
didAnnounce: boolean,
|
||||
) {
|
||||
function finalizeSubagentAnnounce(runId: string, cleanup: "delete" | "keep", didAnnounce: boolean) {
|
||||
const entry = subagentRuns.get(runId);
|
||||
if (!entry) return;
|
||||
if (cleanup === "delete") {
|
||||
@@ -226,23 +218,17 @@ function finalizeSubagentCleanup(
|
||||
persistSubagentRuns();
|
||||
return;
|
||||
}
|
||||
if (!didAnnounce) {
|
||||
// Allow retry on the next wake if the announce failed.
|
||||
entry.cleanupHandled = false;
|
||||
persistSubagentRuns();
|
||||
return;
|
||||
}
|
||||
entry.cleanupCompletedAt = Date.now();
|
||||
if (!didAnnounce) return;
|
||||
entry.announceCompletedAt = Date.now();
|
||||
persistSubagentRuns();
|
||||
}
|
||||
|
||||
function beginSubagentCleanup(runId: string) {
|
||||
export function beginSubagentAnnounce(runId: string) {
|
||||
const entry = subagentRuns.get(runId);
|
||||
if (!entry) return false;
|
||||
// Support legacy field names for backward compatibility
|
||||
if (entry.cleanupCompletedAt || entry.announceCompletedAt) return false;
|
||||
if (entry.cleanupHandled || entry.announceHandled) return false;
|
||||
entry.cleanupHandled = true;
|
||||
if (entry.announceCompletedAt) return false;
|
||||
if (entry.announceHandled) return false;
|
||||
entry.announceHandled = true;
|
||||
persistSubagentRuns();
|
||||
return true;
|
||||
}
|
||||
@@ -275,7 +261,7 @@ export function registerSubagentRun(params: {
|
||||
createdAt: now,
|
||||
startedAt: now,
|
||||
archiveAtMs,
|
||||
cleanupHandled: false,
|
||||
announceHandled: false,
|
||||
});
|
||||
ensureListener();
|
||||
persistSubagentRuns();
|
||||
@@ -316,8 +302,8 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
||||
wait.status === "error" ? { status: "error", error: wait.error } : { status: "ok" };
|
||||
mutated = true;
|
||||
if (mutated) persistSubagentRuns();
|
||||
if (!beginSubagentCleanup(runId)) return;
|
||||
void runSubagentAnnounceFlow({
|
||||
if (!beginSubagentAnnounce(runId)) return;
|
||||
const announce = runSubagentAnnounceFlow({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
childRunId: entry.runId,
|
||||
requesterSessionKey: entry.requesterSessionKey,
|
||||
@@ -331,8 +317,9 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
||||
endedAt: entry.endedAt,
|
||||
label: entry.label,
|
||||
outcome: entry.outcome,
|
||||
}).then((didAnnounce) => {
|
||||
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
|
||||
});
|
||||
void announce.then((didAnnounce) => {
|
||||
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
+66
-115
@@ -4,14 +4,6 @@ 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";
|
||||
|
||||
export function buildAgentSystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
defaultThinkLevel?: ThinkLevel;
|
||||
@@ -28,8 +20,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
skillsPrompt?: string;
|
||||
heartbeatPrompt?: string;
|
||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||
promptMode?: PromptMode;
|
||||
runtimeInfo?: {
|
||||
host?: string;
|
||||
os?: string;
|
||||
@@ -189,22 +179,17 @@ export function buildAgentSystemPrompt(params: {
|
||||
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
|
||||
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
|
||||
const messageChannelOptions = listDeliverableMessageChannels().join("|");
|
||||
const promptMode = params.promptMode ?? "full";
|
||||
const isMinimal = promptMode === "minimal" || promptMode === "none";
|
||||
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
|
||||
// Skip skills section for subagent/none modes
|
||||
const skillsSection =
|
||||
skillsPrompt && !isMinimal
|
||||
? [
|
||||
"## Skills",
|
||||
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
|
||||
...skillsLines,
|
||||
"",
|
||||
]
|
||||
: [];
|
||||
// Skip memory section for subagent/none modes
|
||||
const 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 =
|
||||
!isMinimal && (availableTools.has("memory_search") || availableTools.has("memory_get"))
|
||||
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.",
|
||||
@@ -212,11 +197,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
]
|
||||
: [];
|
||||
|
||||
// For "none" mode, return just the basic identity line
|
||||
if (promptMode === "none") {
|
||||
return "You are a personal assistant running inside Clawdbot.";
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"You are a personal assistant running inside Clawdbot.",
|
||||
"",
|
||||
@@ -255,9 +235,8 @@ export function buildAgentSystemPrompt(params: {
|
||||
"",
|
||||
...skillsSection,
|
||||
...memorySection,
|
||||
// Skip self-update for subagent/none modes
|
||||
hasGateway && !isMinimal ? "## Clawdbot Self-Update" : "",
|
||||
hasGateway && !isMinimal
|
||||
hasGateway ? "## Clawdbot Self-Update" : "",
|
||||
hasGateway
|
||||
? [
|
||||
"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.",
|
||||
@@ -265,19 +244,16 @@ export function buildAgentSystemPrompt(params: {
|
||||
"After restart, Clawdbot pings the last active session automatically.",
|
||||
].join("\n")
|
||||
: "",
|
||||
hasGateway && !isMinimal ? "" : "",
|
||||
hasGateway ? "" : "",
|
||||
"",
|
||||
// Skip model aliases for subagent/none modes
|
||||
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
|
||||
? "## Model Aliases"
|
||||
: "",
|
||||
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
|
||||
params.modelAliasLines && params.modelAliasLines.length > 0 ? "## Model Aliases" : "",
|
||||
params.modelAliasLines && params.modelAliasLines.length > 0
|
||||
? "Prefer aliases when specifying model overrides; full provider/model is also accepted."
|
||||
: "",
|
||||
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
|
||||
params.modelAliasLines && params.modelAliasLines.length > 0
|
||||
? params.modelAliasLines.join("\n")
|
||||
: "",
|
||||
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "",
|
||||
params.modelAliasLines && params.modelAliasLines.length > 0 ? "" : "",
|
||||
"## Workspace",
|
||||
`Your working directory is: ${params.workspaceDir}`,
|
||||
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
||||
@@ -335,10 +311,9 @@ export function buildAgentSystemPrompt(params: {
|
||||
.join("\n")
|
||||
: "",
|
||||
params.sandboxInfo?.enabled ? "" : "",
|
||||
// Skip user identity for subagent/none modes
|
||||
ownerLine && !isMinimal ? "## User Identity" : "",
|
||||
ownerLine && !isMinimal ? ownerLine : "",
|
||||
ownerLine && !isMinimal ? "" : "",
|
||||
ownerLine ? "## User Identity" : "",
|
||||
ownerLine ?? "",
|
||||
ownerLine ? "" : "",
|
||||
...(userTimezone || userTime
|
||||
? [
|
||||
"## Current Date & Time",
|
||||
@@ -354,50 +329,38 @@ export function buildAgentSystemPrompt(params: {
|
||||
"## Workspace Files (injected)",
|
||||
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
|
||||
"",
|
||||
// Skip reply tags for subagent/none modes
|
||||
...(isMinimal
|
||||
? []
|
||||
: [
|
||||
"## 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.",
|
||||
"## 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")
|
||||
? [
|
||||
"",
|
||||
]),
|
||||
// Skip messaging section for subagent/none modes
|
||||
...(isMinimal
|
||||
? []
|
||||
: [
|
||||
"## 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")
|
||||
: "",
|
||||
"",
|
||||
]),
|
||||
"### message tool",
|
||||
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||
"- For `action=send`, include `to` and `message`.",
|
||||
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
|
||||
inlineButtonsEnabled
|
||||
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||
: runtimeChannel
|
||||
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: "",
|
||||
"",
|
||||
];
|
||||
|
||||
if (extraSystemPrompt) {
|
||||
// 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, "");
|
||||
lines.push("## Group Chat Context", extraSystemPrompt, "");
|
||||
}
|
||||
if (params.reactionGuidance) {
|
||||
const { level, channel } = params.reactionGuidance;
|
||||
@@ -439,38 +402,26 @@ 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}` : "",
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import {
|
||||
formatUsageSummaryLine,
|
||||
@@ -263,9 +263,7 @@ export function createSessionStatusTool(opts?: {
|
||||
delete nextEntry.authProfileOverride;
|
||||
}
|
||||
store[resolved.key] = nextEntry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
nextStore[resolved.key] = nextEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, store);
|
||||
resolved.entry = nextEntry;
|
||||
changedModel = true;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ 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;
|
||||
|
||||
@@ -361,82 +361,4 @@ describe("handleSlackAction", () => {
|
||||
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("uses user token for pin reads when available", async () => {
|
||||
const cfg = {
|
||||
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
|
||||
} as ClawdbotConfig;
|
||||
listSlackPins.mockClear();
|
||||
listSlackPins.mockResolvedValueOnce([]);
|
||||
await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
|
||||
const [, opts] = listSlackPins.mock.calls[0] ?? [];
|
||||
expect(opts?.token).toBe("xoxp-1");
|
||||
});
|
||||
|
||||
it("uses user token for member info reads when available", async () => {
|
||||
const cfg = {
|
||||
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
|
||||
} as ClawdbotConfig;
|
||||
getSlackMemberInfo.mockClear();
|
||||
getSlackMemberInfo.mockResolvedValueOnce({});
|
||||
await handleSlackAction({ action: "memberInfo", userId: "U1" }, cfg);
|
||||
const [, opts] = getSlackMemberInfo.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");
|
||||
});
|
||||
|
||||
it("prefers bot token for writes when both tokens are available", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: { botToken: "xoxb-1", 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).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { resolveSlackTokenOverride } from "../../slack/token.js";
|
||||
import {
|
||||
deleteSlackMessage,
|
||||
editSlackMessage,
|
||||
@@ -79,27 +78,11 @@ 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 buildActionOpts = (operation: "read" | "write") => {
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation,
|
||||
});
|
||||
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")) {
|
||||
throw new Error("Slack reactions are disabled.");
|
||||
@@ -111,28 +94,28 @@ export async function handleSlackAction(
|
||||
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
|
||||
});
|
||||
if (remove) {
|
||||
if (writeOpts) {
|
||||
await removeSlackReaction(channelId, messageId, emoji, writeOpts);
|
||||
if (accountOpts) {
|
||||
await removeSlackReaction(channelId, messageId, emoji, accountOpts);
|
||||
} else {
|
||||
await removeSlackReaction(channelId, messageId, emoji);
|
||||
}
|
||||
return jsonResult({ ok: true, removed: emoji });
|
||||
}
|
||||
if (isEmpty) {
|
||||
const removed = writeOpts
|
||||
? await removeOwnSlackReactions(channelId, messageId, writeOpts)
|
||||
const removed = accountOpts
|
||||
? await removeOwnSlackReactions(channelId, messageId, accountOpts)
|
||||
: await removeOwnSlackReactions(channelId, messageId);
|
||||
return jsonResult({ ok: true, removed });
|
||||
}
|
||||
if (writeOpts) {
|
||||
await reactSlackMessage(channelId, messageId, emoji, writeOpts);
|
||||
if (accountOpts) {
|
||||
await reactSlackMessage(channelId, messageId, emoji, accountOpts);
|
||||
} else {
|
||||
await reactSlackMessage(channelId, messageId, emoji);
|
||||
}
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
}
|
||||
const reactions = readOpts
|
||||
? await listSlackReactions(channelId, messageId, readOpts)
|
||||
const reactions = accountOpts
|
||||
? await listSlackReactions(channelId, messageId, accountOpts)
|
||||
: await listSlackReactions(channelId, messageId);
|
||||
return jsonResult({ ok: true, reactions });
|
||||
}
|
||||
@@ -152,7 +135,7 @@ export async function handleSlackAction(
|
||||
context,
|
||||
);
|
||||
const result = await sendSlackMessage(to, content, {
|
||||
...writeOpts,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
threadTs: threadTs ?? undefined,
|
||||
});
|
||||
@@ -179,8 +162,8 @@ export async function handleSlackAction(
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
if (writeOpts) {
|
||||
await editSlackMessage(channelId, messageId, content, writeOpts);
|
||||
if (accountOpts) {
|
||||
await editSlackMessage(channelId, messageId, content, accountOpts);
|
||||
} else {
|
||||
await editSlackMessage(channelId, messageId, content);
|
||||
}
|
||||
@@ -193,8 +176,8 @@ export async function handleSlackAction(
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
if (writeOpts) {
|
||||
await deleteSlackMessage(channelId, messageId, writeOpts);
|
||||
if (accountOpts) {
|
||||
await deleteSlackMessage(channelId, messageId, accountOpts);
|
||||
} else {
|
||||
await deleteSlackMessage(channelId, messageId);
|
||||
}
|
||||
@@ -210,7 +193,7 @@ export async function handleSlackAction(
|
||||
const before = readStringParam(params, "before");
|
||||
const after = readStringParam(params, "after");
|
||||
const result = await readSlackMessages(channelId, {
|
||||
...readOpts,
|
||||
accountId: accountId ?? undefined,
|
||||
limit,
|
||||
before: before ?? undefined,
|
||||
after: after ?? undefined,
|
||||
@@ -237,8 +220,8 @@ export async function handleSlackAction(
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
if (writeOpts) {
|
||||
await pinSlackMessage(channelId, messageId, writeOpts);
|
||||
if (accountOpts) {
|
||||
await pinSlackMessage(channelId, messageId, accountOpts);
|
||||
} else {
|
||||
await pinSlackMessage(channelId, messageId);
|
||||
}
|
||||
@@ -248,15 +231,15 @@ export async function handleSlackAction(
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
if (writeOpts) {
|
||||
await unpinSlackMessage(channelId, messageId, writeOpts);
|
||||
if (accountOpts) {
|
||||
await unpinSlackMessage(channelId, messageId, accountOpts);
|
||||
} else {
|
||||
await unpinSlackMessage(channelId, messageId);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
const pins = readOpts
|
||||
? await listSlackPins(channelId, readOpts)
|
||||
const pins = accountOpts
|
||||
? await listSlackPins(channelId, accountOpts)
|
||||
: await listSlackPins(channelId);
|
||||
const normalizedPins = pins.map((pin) => {
|
||||
const message = pin.message
|
||||
@@ -275,8 +258,8 @@ export async function handleSlackAction(
|
||||
throw new Error("Slack member info is disabled.");
|
||||
}
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
const info = readOpts
|
||||
? await getSlackMemberInfo(userId, readOpts)
|
||||
const info = accountOpts
|
||||
? await getSlackMemberInfo(userId, accountOpts)
|
||||
: await getSlackMemberInfo(userId);
|
||||
return jsonResult({ ok: true, info });
|
||||
}
|
||||
@@ -285,7 +268,7 @@ export async function handleSlackAction(
|
||||
if (!isActionEnabled("emojiList")) {
|
||||
throw new Error("Slack emoji list is disabled.");
|
||||
}
|
||||
const emojis = readOpts ? await listSlackEmojis(readOpts) : await listSlackEmojis();
|
||||
const emojis = accountOpts ? await listSlackEmojis(accountOpts) : await listSlackEmojis();
|
||||
return jsonResult({ ok: true, emojis });
|
||||
}
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -5,7 +5,7 @@ import {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
@@ -90,13 +90,7 @@ export async function tryFastAbortFromMessage(params: {
|
||||
entry.abortedLastRun = true;
|
||||
entry.updatedAt = Date.now();
|
||||
store[key] = entry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
const nextEntry = nextStore[key] ?? entry;
|
||||
if (!nextEntry) return;
|
||||
nextEntry.abortedLastRun = true;
|
||||
nextEntry.updatedAt = Date.now();
|
||||
nextStore[key] = nextEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, store);
|
||||
} else if (abortKey) {
|
||||
setAbortMemory(abortKey, true);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
@@ -383,7 +383,6 @@ 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}`,
|
||||
@@ -400,13 +399,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
});
|
||||
// Remove session entry from store
|
||||
delete params.activeSessionStore[params.sessionKey];
|
||||
await saveSessionStore(params.storePath, params.activeSessionStore);
|
||||
} catch (cleanupErr) {
|
||||
defaultRuntime.error(
|
||||
`Failed to reset corrupted session ${params.sessionKey}: ${String(cleanupErr)}`,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
@@ -156,9 +156,7 @@ export async function runReplyAgent(params: {
|
||||
activeSessionEntry.updatedAt = Date.now();
|
||||
activeSessionStore[sessionKey] = activeSessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = activeSessionEntry as SessionEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, activeSessionStore);
|
||||
}
|
||||
}
|
||||
typing.cleanup();
|
||||
@@ -172,9 +170,7 @@ export async function runReplyAgent(params: {
|
||||
activeSessionEntry.updatedAt = Date.now();
|
||||
activeSessionStore[sessionKey] = activeSessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = activeSessionEntry as SessionEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, activeSessionStore);
|
||||
}
|
||||
}
|
||||
typing.cleanup();
|
||||
@@ -228,9 +224,7 @@ export async function runReplyAgent(params: {
|
||||
nextEntry.sessionFile = nextSessionFile;
|
||||
activeSessionStore[sessionKey] = nextEntry;
|
||||
try {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = nextEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, activeSessionStore);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
`Failed to persist session reset after compaction failure (${sessionKey}): ${String(err)}`,
|
||||
@@ -286,9 +280,7 @@ export async function runReplyAgent(params: {
|
||||
activeSessionEntry.updatedAt = Date.now();
|
||||
activeSessionStore[sessionKey] = activeSessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = activeSessionEntry as SessionEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, activeSessionStore);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { updateSessionStore } from "../../config/sessions.js";
|
||||
import { saveSessionStore } from "../../config/sessions.js";
|
||||
import { setAbortMemory } from "./abort.js";
|
||||
|
||||
export async function applySessionHints(params: {
|
||||
@@ -23,16 +23,7 @@ export async function applySessionHints(params: {
|
||||
params.sessionEntry.updatedAt = Date.now();
|
||||
params.sessionStore[params.sessionKey] = params.sessionEntry;
|
||||
if (params.storePath) {
|
||||
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(),
|
||||
};
|
||||
});
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
} 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 { updateSessionStore } from "../../config/sessions.js";
|
||||
import { saveSessionStore } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
@@ -71,9 +71,7 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
|
||||
params.sessionEntry.updatedAt = Date.now();
|
||||
params.sessionStore[params.sessionKey] = params.sessionEntry;
|
||||
if (params.storePath) {
|
||||
await updateSessionStore(params.storePath, (store) => {
|
||||
store[params.sessionKey] = params.sessionEntry as SessionEntry;
|
||||
});
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -109,9 +107,7 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
|
||||
params.sessionEntry.updatedAt = Date.now();
|
||||
params.sessionStore[params.sessionKey] = params.sessionEntry;
|
||||
if (params.storePath) {
|
||||
await updateSessionStore(params.storePath, (store) => {
|
||||
store[params.sessionKey] = params.sessionEntry as SessionEntry;
|
||||
});
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
}
|
||||
const label =
|
||||
@@ -194,9 +190,7 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
|
||||
abortTarget.entry.updatedAt = Date.now();
|
||||
params.sessionStore[abortTarget.key] = abortTarget.entry;
|
||||
if (params.storePath) {
|
||||
await updateSessionStore(params.storePath, (store) => {
|
||||
store[abortTarget.key] = abortTarget.entry as SessionEntry;
|
||||
});
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
} else if (params.command.abortKey) {
|
||||
setAbortMemory(params.command.abortKey, true);
|
||||
@@ -221,9 +215,7 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
|
||||
abortTarget.entry.updatedAt = Date.now();
|
||||
params.sessionStore[abortTarget.key] = abortTarget.entry;
|
||||
if (params.storePath) {
|
||||
await updateSessionStore(params.storePath, (store) => {
|
||||
store[abortTarget.key] = abortTarget.entry as SessionEntry;
|
||||
});
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
} 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, updateSessionStore } from "../../config/sessions.js";
|
||||
import { type SessionEntry, saveSessionStore } 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";
|
||||
@@ -288,9 +288,7 @@ export async function handleDirectiveOnly(params: {
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
if (modelSelection) {
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { type SessionEntry, saveSessionStore } 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";
|
||||
@@ -184,9 +184,7 @@ export async function persistInlineDirectives(params: {
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
if (elevatedChanged) {
|
||||
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
|
||||
@@ -276,9 +276,7 @@ export async function runPreparedReply(
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolveThinkingDefault,
|
||||
} from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import type { ThinkLevel } from "./directives.js";
|
||||
|
||||
export type ModelDirectiveSelection = {
|
||||
@@ -189,9 +189,7 @@ export async function createModelSelectionState(params: {
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
resetModelOverride = true;
|
||||
}
|
||||
@@ -220,9 +218,7 @@ export async function createModelSelectionState(params: {
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
||||
|
||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import { buildChannelSummary } from "../../infra/channel-summary.js";
|
||||
import { drainSystemEventEntries } from "../../infra/system-events.js";
|
||||
|
||||
@@ -111,9 +111,7 @@ export async function ensureSkillSnapshot(params: {
|
||||
};
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = { ...store[sessionKey], ...nextEntry };
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
systemSent = true;
|
||||
}
|
||||
@@ -145,9 +143,7 @@ export async function ensureSkillSnapshot(params: {
|
||||
};
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = { ...store[sessionKey], ...nextEntry };
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,13 +168,7 @@ export async function incrementCompactionCount(params: {
|
||||
updatedAt: now,
|
||||
};
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = {
|
||||
...store[sessionKey],
|
||||
compactionCount: nextCount,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
return nextCount;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
@@ -188,11 +188,6 @@ export async function initSessionState(params: {
|
||||
}
|
||||
|
||||
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
|
||||
// Track the originating channel/to for announce routing (subagent announce-back).
|
||||
const lastChannel =
|
||||
(ctx.OriginatingChannel as string | undefined)?.trim() || baseEntry?.lastChannel;
|
||||
const lastTo = ctx.OriginatingTo?.trim() || ctx.To?.trim() || baseEntry?.lastTo;
|
||||
const lastAccountId = ctx.AccountId?.trim() || baseEntry?.lastAccountId;
|
||||
sessionEntry = {
|
||||
...baseEntry,
|
||||
sessionId,
|
||||
@@ -217,10 +212,6 @@ export async function initSessionState(params: {
|
||||
subject: baseEntry?.subject,
|
||||
room: baseEntry?.room,
|
||||
space: baseEntry?.space,
|
||||
// Track originating channel for subagent announce routing.
|
||||
lastChannel,
|
||||
lastTo,
|
||||
lastAccountId,
|
||||
};
|
||||
if (groupResolution?.channel) {
|
||||
const channel = groupResolution.channel;
|
||||
@@ -279,15 +270,7 @@ export async function initSessionState(params: {
|
||||
);
|
||||
}
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
|
||||
if (store[groupResolution.legacyKey] && !store[sessionKey]) {
|
||||
store[sessionKey] = store[groupResolution.legacyKey];
|
||||
}
|
||||
delete store[groupResolution.legacyKey];
|
||||
}
|
||||
store[sessionKey] = { ...store[sessionKey], ...sessionEntry };
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,6 +1,4 @@
|
||||
import { resolveSlackAccount } from "../../../slack/accounts.js";
|
||||
import { sendMessageSlack } from "../../../slack/send.js";
|
||||
import { resolveSlackTokenOverride } from "../../../slack/token.js";
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
|
||||
export const slackOutbound: ChannelOutboundAdapter = {
|
||||
@@ -17,36 +15,20 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
|
||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
mediaUrl,
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "../../slack/accounts.js";
|
||||
import { probeSlack } from "../../slack/probe.js";
|
||||
import { sendMessageSlack } from "../../slack/send.js";
|
||||
import { resolveSlackTokenOverride } from "../../slack/token.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
@@ -36,19 +35,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
pairing: {
|
||||
idLabel: "slackUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
await sendMessageSlack(
|
||||
`user:${id}`,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
tokenOverride ? { token: tokenOverride } : undefined,
|
||||
);
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -237,36 +225,20 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
|
||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
mediaUrl,
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+21
-37
@@ -36,7 +36,7 @@ import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionFilePath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import {
|
||||
clearAgentRunContext,
|
||||
@@ -173,9 +173,7 @@ export async function agentCommand(
|
||||
skillsSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = next;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = next;
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
sessionEntry = next;
|
||||
}
|
||||
|
||||
@@ -190,9 +188,7 @@ export async function agentCommand(
|
||||
}
|
||||
applyVerboseOverride(next, verboseOverride);
|
||||
sessionStore[sessionKey] = next;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = next;
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
|
||||
const agentModelPrimary = resolveAgentModelPrimary(cfg, sessionAgentId);
|
||||
@@ -243,7 +239,6 @@ export async function agentCommand(
|
||||
}
|
||||
|
||||
if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) {
|
||||
const entry = sessionEntry;
|
||||
const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider;
|
||||
const overrideModel = sessionEntry.modelOverride?.trim();
|
||||
if (overrideModel) {
|
||||
@@ -253,13 +248,11 @@ export async function agentCommand(
|
||||
allowedModelKeys.size > 0 &&
|
||||
!allowedModelKeys.has(key)
|
||||
) {
|
||||
delete entry.providerOverride;
|
||||
delete entry.modelOverride;
|
||||
entry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = entry;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = entry;
|
||||
});
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,21 +271,15 @@ export async function agentCommand(
|
||||
model = storedModelOverride;
|
||||
}
|
||||
}
|
||||
if (sessionEntry) {
|
||||
const authProfileId = sessionEntry.authProfileOverride;
|
||||
if (authProfileId) {
|
||||
const entry = sessionEntry;
|
||||
const store = ensureAuthProfileStore();
|
||||
const profile = store.profiles[authProfileId];
|
||||
if (!profile || profile.provider !== provider) {
|
||||
delete entry.authProfileOverride;
|
||||
entry.updatedAt = Date.now();
|
||||
if (sessionStore && sessionKey) {
|
||||
sessionStore[sessionKey] = entry;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = entry;
|
||||
});
|
||||
}
|
||||
if (sessionEntry?.authProfileOverride) {
|
||||
const store = ensureAuthProfileStore();
|
||||
const profile = store.profiles[sessionEntry.authProfileOverride];
|
||||
if (!profile || profile.provider !== provider) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
if (sessionStore && sessionKey) {
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,13 +304,10 @@ export async function agentCommand(
|
||||
}
|
||||
resolvedThinkLevel = "high";
|
||||
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") {
|
||||
const entry = sessionEntry;
|
||||
entry.thinkingLevel = "high";
|
||||
entry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = entry;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = entry;
|
||||
});
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
const sessionFile = resolveSessionFilePath(sessionId, sessionEntry, {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { isCliProvider } from "../../agents/model-selection.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
|
||||
type RunResult = Awaited<
|
||||
ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]>
|
||||
@@ -68,7 +68,5 @@ export async function updateSessionStoreAfterAgentRun(params: {
|
||||
next.totalTokens = promptTokens > 0 ? promptTokens : (usage.total ?? input);
|
||||
}
|
||||
sessionStore[sessionKey] = next;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = next;
|
||||
});
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -204,8 +204,6 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.discord.token": "Discord Bot Token",
|
||||
"channels.slack.botToken": "Slack Bot Token",
|
||||
"channels.slack.appToken": "Slack App Token",
|
||||
"channels.slack.userToken": "Slack User Token",
|
||||
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
|
||||
"channels.slack.thread.historyScope": "Slack Thread History Scope",
|
||||
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
|
||||
"channels.signal.account": "Signal Account",
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
resolveSessionTranscriptPath,
|
||||
resolveSessionTranscriptsDir,
|
||||
updateLastRoute,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
} from "./sessions.js";
|
||||
|
||||
@@ -138,56 +137,6 @@ describe("sessions", () => {
|
||||
expect(store[mainSessionKey]?.compactionCount).toBe(2);
|
||||
});
|
||||
|
||||
it("updateSessionStore preserves concurrent additions", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, "{}", "utf-8");
|
||||
|
||||
await Promise.all([
|
||||
updateSessionStore(storePath, (store) => {
|
||||
store["agent:main:one"] = { sessionId: "sess-1", updatedAt: 1 };
|
||||
}),
|
||||
updateSessionStore(storePath, (store) => {
|
||||
store["agent:main:two"] = { sessionId: "sess-2", updatedAt: 2 };
|
||||
}),
|
||||
]);
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store["agent:main:one"]?.sessionId).toBe("sess-1");
|
||||
expect(store["agent:main:two"]?.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("updateSessionStore keeps deletions when concurrent writes happen", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:old": { sessionId: "sess-old", updatedAt: 1 },
|
||||
"agent:main:keep": { sessionId: "sess-keep", updatedAt: 2 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
updateSessionStore(storePath, (store) => {
|
||||
delete store["agent:main:old"];
|
||||
}),
|
||||
updateSessionStore(storePath, (store) => {
|
||||
store["agent:main:new"] = { sessionId: "sess-new", updatedAt: 3 };
|
||||
}),
|
||||
]);
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store["agent:main:old"]).toBeUndefined();
|
||||
expect(store["agent:main:keep"]?.sessionId).toBe("sess-keep");
|
||||
expect(store["agent:main:new"]?.sessionId).toBe("sess-new");
|
||||
});
|
||||
|
||||
it("loadSessionStore auto-migrates legacy provider keys to channel keys", async () => {
|
||||
const mainSessionKey = "agent:main:main";
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
|
||||
@@ -45,16 +45,9 @@ export function clearSessionStoreCacheForTest(): void {
|
||||
SESSION_STORE_CACHE.clear();
|
||||
}
|
||||
|
||||
type LoadSessionStoreOptions = {
|
||||
skipCache?: boolean;
|
||||
};
|
||||
|
||||
export function loadSessionStore(
|
||||
storePath: string,
|
||||
opts: LoadSessionStoreOptions = {},
|
||||
): Record<string, SessionEntry> {
|
||||
export function loadSessionStore(storePath: string): Record<string, SessionEntry> {
|
||||
// Check cache first if enabled
|
||||
if (!opts.skipCache && isSessionStoreCacheEnabled()) {
|
||||
if (isSessionStoreCacheEnabled()) {
|
||||
const cached = SESSION_STORE_CACHE.get(storePath);
|
||||
if (cached && isSessionStoreCacheValid(cached)) {
|
||||
const currentMtimeMs = getFileMtimeMs(storePath);
|
||||
@@ -95,7 +88,7 @@ export function loadSessionStore(
|
||||
}
|
||||
|
||||
// Cache the result if caching is enabled
|
||||
if (!opts.skipCache && isSessionStoreCacheEnabled()) {
|
||||
if (isSessionStoreCacheEnabled()) {
|
||||
SESSION_STORE_CACHE.set(storePath, {
|
||||
store: structuredClone(store), // Store a copy to prevent external mutations
|
||||
loadedAt: Date.now(),
|
||||
@@ -175,19 +168,6 @@ export async function saveSessionStore(
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSessionStore<T>(
|
||||
storePath: string,
|
||||
mutator: (store: Record<string, SessionEntry>) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
return await withSessionStoreLock(storePath, async () => {
|
||||
// Always re-read inside the lock to avoid clobbering concurrent writers.
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const result = await mutator(store);
|
||||
await saveSessionStoreUnlocked(storePath, store);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
type SessionStoreLockOptions = {
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("Slack token config fields", () => {
|
||||
it("accepts user token config fields", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
userTokenReadOnly: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts account-level user token config", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
userTokenReadOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -80,9 +80,6 @@ export type SlackAccountConfig = {
|
||||
enabled?: boolean;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
userToken?: string;
|
||||
/** If true, restrict user token to read operations only. Default: true. */
|
||||
userTokenReadOnly?: boolean;
|
||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||
allowBots?: boolean;
|
||||
/** Default mention requirement for channel messages (default: true). */
|
||||
|
||||
@@ -220,8 +220,6 @@ export const SlackAccountSchema = z.object({
|
||||
configWrites: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
appToken: z.string().optional(),
|
||||
userToken: z.string().optional(),
|
||||
userTokenReadOnly: z.boolean().optional().default(true),
|
||||
allowBots: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -30,7 +30,7 @@ import {
|
||||
} from "../../auto-reply/thinking.js";
|
||||
import type { CliDeps } from "../../cli/deps.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js";
|
||||
import { resolveSessionTranscriptPath, saveSessionStore } from "../../config/sessions.js";
|
||||
import type { AgentDefaultsConfig } from "../../config/types.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||
@@ -217,17 +217,13 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
skillsSnapshot,
|
||||
};
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await updateSessionStore(cronSession.storePath, (store) => {
|
||||
store[agentSessionKey] = cronSession.sessionEntry;
|
||||
});
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
}
|
||||
|
||||
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
|
||||
cronSession.sessionEntry.systemSent = true;
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await updateSessionStore(cronSession.storePath, (store) => {
|
||||
store[agentSessionKey] = cronSession.sessionEntry;
|
||||
});
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = provider;
|
||||
@@ -320,9 +316,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input);
|
||||
}
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await updateSessionStore(cronSession.storePath, (store) => {
|
||||
store[agentSessionKey] = cronSession.sessionEntry;
|
||||
});
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
}
|
||||
const firstText = payloads[0]?.text ?? "";
|
||||
const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
GATEWAY_SYSTEMD_SERVICE_NAME,
|
||||
GATEWAY_WINDOWS_TASK_NAME,
|
||||
resolveGatewayLaunchAgentLabel,
|
||||
resolveGatewayProfileSuffix,
|
||||
resolveGatewaySystemdServiceName,
|
||||
resolveGatewayWindowsTaskName,
|
||||
} from "./constants.js";
|
||||
@@ -154,25 +153,6 @@ describe("resolveGatewayWindowsTaskName", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGatewayProfileSuffix", () => {
|
||||
it("returns empty string when no profile is set", () => {
|
||||
expect(resolveGatewayProfileSuffix()).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for default profiles", () => {
|
||||
expect(resolveGatewayProfileSuffix("default")).toBe("");
|
||||
expect(resolveGatewayProfileSuffix(" Default ")).toBe("");
|
||||
});
|
||||
|
||||
it("returns a hyphenated suffix for custom profiles", () => {
|
||||
expect(resolveGatewayProfileSuffix("dev")).toBe("-dev");
|
||||
});
|
||||
|
||||
it("trims whitespace from profiles", () => {
|
||||
expect(resolveGatewayProfileSuffix(" staging ")).toBe("-staging");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGatewayServiceDescription", () => {
|
||||
it("returns default description when no profile/version", () => {
|
||||
expect(formatGatewayServiceDescription()).toBe("Clawdbot Gateway");
|
||||
|
||||
+19
-20
@@ -8,35 +8,34 @@ export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gatewa
|
||||
export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = [];
|
||||
export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = [];
|
||||
|
||||
export function normalizeGatewayProfile(profile?: string): string | null {
|
||||
export function resolveGatewayLaunchAgentLabel(profile?: string): string {
|
||||
const trimmed = profile?.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "default") {
|
||||
return GATEWAY_LAUNCH_AGENT_LABEL;
|
||||
}
|
||||
return `com.clawdbot.${trimmed}`;
|
||||
}
|
||||
|
||||
function normalizeGatewayProfile(profile?: string): string | null {
|
||||
const trimmed = profile?.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "default") return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveGatewayProfileSuffix(profile?: string): string {
|
||||
const normalized = normalizeGatewayProfile(profile);
|
||||
return normalized ? `-${normalized}` : "";
|
||||
}
|
||||
|
||||
export function resolveGatewayLaunchAgentLabel(profile?: string): string {
|
||||
const normalized = normalizeGatewayProfile(profile);
|
||||
if (!normalized) {
|
||||
return GATEWAY_LAUNCH_AGENT_LABEL;
|
||||
}
|
||||
return `com.clawdbot.${normalized}`;
|
||||
}
|
||||
|
||||
export function resolveGatewaySystemdServiceName(profile?: string): string {
|
||||
const suffix = resolveGatewayProfileSuffix(profile);
|
||||
if (!suffix) return GATEWAY_SYSTEMD_SERVICE_NAME;
|
||||
return `clawdbot-gateway${suffix}`;
|
||||
const trimmed = profile?.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "default") {
|
||||
return GATEWAY_SYSTEMD_SERVICE_NAME;
|
||||
}
|
||||
return `clawdbot-gateway-${trimmed}`;
|
||||
}
|
||||
|
||||
export function resolveGatewayWindowsTaskName(profile?: string): string {
|
||||
const normalized = normalizeGatewayProfile(profile);
|
||||
if (!normalized) return GATEWAY_WINDOWS_TASK_NAME;
|
||||
return `Clawdbot Gateway (${normalized})`;
|
||||
const trimmed = profile?.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "default") {
|
||||
return GATEWAY_WINDOWS_TASK_NAME;
|
||||
}
|
||||
return `Clawdbot Gateway (${trimmed})`;
|
||||
}
|
||||
|
||||
export function formatGatewayServiceDescription(params?: {
|
||||
|
||||
+21
-2
@@ -16,7 +16,6 @@ import {
|
||||
} from "./launchd-plist.js";
|
||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -30,6 +29,11 @@ function resolveLaunchAgentLabel(args?: { env?: Record<string, string | undefine
|
||||
if (envLabel) return envLabel;
|
||||
return resolveGatewayLaunchAgentLabel(args?.env?.CLAWDBOT_PROFILE);
|
||||
}
|
||||
function resolveHomeDir(env: Record<string, string | undefined>): string {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
||||
if (!home) throw new Error("Missing HOME");
|
||||
return home;
|
||||
}
|
||||
|
||||
function resolveLaunchAgentPlistPathForLabel(
|
||||
env: Record<string, string | undefined>,
|
||||
@@ -49,7 +53,12 @@ export function resolveGatewayLogPaths(env: Record<string, string | undefined>):
|
||||
stdoutPath: string;
|
||||
stderrPath: string;
|
||||
} {
|
||||
const stateDir = resolveGatewayStateDir(env);
|
||||
const home = resolveHomeDir(env);
|
||||
const stateOverride = env.CLAWDBOT_STATE_DIR?.trim();
|
||||
const profile = env.CLAWDBOT_PROFILE?.trim();
|
||||
const suffix = profile && profile.toLowerCase() !== "default" ? `-${profile}` : "";
|
||||
const defaultStateDir = path.join(home, `.clawdbot${suffix}`);
|
||||
const stateDir = stateOverride ? resolveUserPathWithHome(stateOverride, home) : defaultStateDir;
|
||||
const logDir = path.join(stateDir, "logs");
|
||||
return {
|
||||
logDir,
|
||||
@@ -58,6 +67,16 @@ export function resolveGatewayLogPaths(env: Record<string, string | undefined>):
|
||||
};
|
||||
}
|
||||
|
||||
function resolveUserPathWithHome(input: string, home: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = trimmed.replace(/^~(?=$|[\\/])/, home);
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
export async function readLaunchAgentProgramArguments(
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<{
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveGatewayStateDir } from "./paths.js";
|
||||
|
||||
describe("resolveGatewayStateDir", () => {
|
||||
it("uses the default state dir when no overrides are set", () => {
|
||||
const env = { HOME: "/Users/test" };
|
||||
expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".clawdbot"));
|
||||
});
|
||||
|
||||
it("appends the profile suffix when set", () => {
|
||||
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "rescue" };
|
||||
expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".clawdbot-rescue"));
|
||||
});
|
||||
|
||||
it("treats default profiles as the base state dir", () => {
|
||||
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "Default" };
|
||||
expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".clawdbot"));
|
||||
});
|
||||
|
||||
it("uses CLAWDBOT_STATE_DIR when provided", () => {
|
||||
const env = { HOME: "/Users/test", CLAWDBOT_STATE_DIR: "/var/lib/clawdbot" };
|
||||
expect(resolveGatewayStateDir(env)).toBe(path.resolve("/var/lib/clawdbot"));
|
||||
});
|
||||
|
||||
it("expands ~ in CLAWDBOT_STATE_DIR", () => {
|
||||
const env = { HOME: "/Users/test", CLAWDBOT_STATE_DIR: "~/clawdbot-state" };
|
||||
expect(resolveGatewayStateDir(env)).toBe(path.resolve("/Users/test/clawdbot-state"));
|
||||
});
|
||||
|
||||
it("preserves Windows absolute paths without HOME", () => {
|
||||
const env = { CLAWDBOT_STATE_DIR: "C:\\State\\clawdbot" };
|
||||
expect(resolveGatewayStateDir(env)).toBe("C:\\State\\clawdbot");
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveGatewayProfileSuffix } from "./constants.js";
|
||||
|
||||
const windowsAbsolutePath = /^[a-zA-Z]:[\\/]/;
|
||||
const windowsUncPath = /^\\\\/;
|
||||
|
||||
export function resolveHomeDir(env: Record<string, string | undefined>): string {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
||||
if (!home) throw new Error("Missing HOME");
|
||||
return home;
|
||||
}
|
||||
|
||||
export function resolveUserPathWithHome(input: string, home?: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (trimmed.startsWith("~")) {
|
||||
if (!home) throw new Error("Missing HOME");
|
||||
const expanded = trimmed.replace(/^~(?=$|[\\/])/, home);
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
if (windowsAbsolutePath.test(trimmed) || windowsUncPath.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
export function resolveGatewayStateDir(env: Record<string, string | undefined>): string {
|
||||
const override = env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
const home = override.startsWith("~") ? resolveHomeDir(env) : undefined;
|
||||
return resolveUserPathWithHome(override, home);
|
||||
}
|
||||
const home = resolveHomeDir(env);
|
||||
const suffix = resolveGatewayProfileSuffix(env.CLAWDBOT_PROFILE);
|
||||
return path.join(home, `.clawdbot${suffix}`);
|
||||
}
|
||||
@@ -58,15 +58,6 @@ describe("resolveTaskScriptPath", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers CLAWDBOT_STATE_DIR over profile-derived defaults", () => {
|
||||
const env = {
|
||||
USERPROFILE: "C:\\Users\\test",
|
||||
CLAWDBOT_PROFILE: "rescue",
|
||||
CLAWDBOT_STATE_DIR: "C:\\State\\clawdbot",
|
||||
};
|
||||
expect(resolveTaskScriptPath(env)).toBe(path.join("C:\\State\\clawdbot", "gateway.cmd"));
|
||||
});
|
||||
|
||||
it("handles case-insensitive 'Default' profile", () => {
|
||||
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "Default" };
|
||||
expect(resolveTaskScriptPath(env)).toBe(
|
||||
|
||||
+10
-3
@@ -5,7 +5,6 @@ import { promisify } from "node:util";
|
||||
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js";
|
||||
import { resolveGatewayStateDir } from "./paths.js";
|
||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
@@ -16,9 +15,17 @@ const formatLine = (label: string, value: string) => {
|
||||
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
||||
};
|
||||
|
||||
function resolveHomeDir(env: Record<string, string | undefined>): string {
|
||||
const home = env.USERPROFILE?.trim() || env.HOME?.trim();
|
||||
if (!home) throw new Error("Missing HOME");
|
||||
return home;
|
||||
}
|
||||
|
||||
export function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
|
||||
const stateDir = resolveGatewayStateDir(env);
|
||||
return path.join(stateDir, "gateway.cmd");
|
||||
const home = resolveHomeDir(env);
|
||||
const profile = env.CLAWDBOT_PROFILE?.trim();
|
||||
const suffix = profile && profile.toLowerCase() !== "default" ? `-${profile}` : "";
|
||||
return path.join(home, `.clawdbot${suffix}`, "gateway.cmd");
|
||||
}
|
||||
|
||||
function quoteCmdArg(value: string): string {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "./constants.js";
|
||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
import { resolveHomeDir } from "./paths.js";
|
||||
import {
|
||||
enableSystemdUserLinger,
|
||||
readSystemdUserLingerStatus,
|
||||
@@ -29,6 +28,12 @@ const formatLine = (label: string, value: string) => {
|
||||
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
||||
};
|
||||
|
||||
function resolveHomeDir(env: Record<string, string | undefined>): string {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
||||
if (!home) throw new Error("Missing HOME");
|
||||
return home;
|
||||
}
|
||||
|
||||
function resolveSystemdUnitPathForName(
|
||||
env: Record<string, string | undefined>,
|
||||
name: string,
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -136,6 +136,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
publicKey: "a",
|
||||
token,
|
||||
autoDeploy: nativeEnabled,
|
||||
eventQueue: {
|
||||
// Auto-threading (create thread + generate reply + post) can exceed the default
|
||||
// 30s listener timeout in some environments.
|
||||
listenerTimeout: 120_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
commands,
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -22,8 +22,6 @@ import {
|
||||
type ChatEvent,
|
||||
ChatEventSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
type ChatInjectParams,
|
||||
ChatInjectParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
type ConfigApplyParams,
|
||||
ConfigApplyParamsSchema,
|
||||
@@ -234,7 +232,6 @@ export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParams
|
||||
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(ChatAbortParamsSchema);
|
||||
export const validateChatInjectParams = ajv.compile<ChatInjectParams>(ChatInjectParamsSchema);
|
||||
export const validateChatEvent = ajv.compile(ChatEventSchema);
|
||||
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(UpdateRunParamsSchema);
|
||||
export const validateWebLoginStartParams =
|
||||
@@ -313,7 +310,6 @@ export {
|
||||
LogsTailResultSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
ChatInjectParamsSchema,
|
||||
UpdateRunParamsSchema,
|
||||
TickEventSchema,
|
||||
ShutdownEventSchema,
|
||||
@@ -392,5 +388,4 @@ export type {
|
||||
LogsTailResult,
|
||||
PollParams,
|
||||
UpdateRunParams,
|
||||
ChatInjectParams,
|
||||
};
|
||||
@@ -53,15 +53,6 @@ export const ChatAbortParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChatInjectParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: NonEmptyString,
|
||||
message: NonEmptyString,
|
||||
label: Type.Optional(Type.String({ maxLength: 100 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChatEventSchema = Type.Object(
|
||||
{
|
||||
runId: NonEmptyString,
|
||||
|
||||
@@ -62,7 +62,6 @@ import {
|
||||
ChatAbortParamsSchema,
|
||||
ChatEventSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatInjectParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
LogsTailParamsSchema,
|
||||
LogsTailResultSchema,
|
||||
@@ -173,7 +172,6 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
ChatAbortParams: ChatAbortParamsSchema,
|
||||
ChatInjectParams: ChatInjectParamsSchema,
|
||||
ChatEvent: ChatEventSchema,
|
||||
UpdateRunParams: UpdateRunParamsSchema,
|
||||
TickEvent: TickEventSchema,
|
||||
|
||||
@@ -59,7 +59,6 @@ import type {
|
||||
import type {
|
||||
ChatAbortParamsSchema,
|
||||
ChatEventSchema,
|
||||
ChatInjectParamsSchema,
|
||||
LogsTailParamsSchema,
|
||||
LogsTailResultSchema,
|
||||
} from "./logs-chat.js";
|
||||
@@ -164,7 +163,6 @@ export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
|
||||
export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
|
||||
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||
export type UpdateRunParams = Static<typeof UpdateRunParamsSchema>;
|
||||
export type TickEvent = Static<typeof TickEventSchema>;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { updateSessionStore } from "../config/sessions.js";
|
||||
import { saveSessionStore } from "../config/sessions.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BridgeEvent, BridgeHandlersContext } from "./server-bridge-types.js";
|
||||
@@ -32,23 +32,22 @@ export const handleBridgeEvent = async (
|
||||
const cfg = loadConfig();
|
||||
const rawMainKey = normalizeMainKey(cfg.session?.mainKey);
|
||||
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey;
|
||||
const { storePath, entry, canonicalKey } = loadSessionEntry(sessionKey);
|
||||
const { storePath, store, entry, canonicalKey } = loadSessionEntry(sessionKey);
|
||||
const now = Date.now();
|
||||
const sessionId = entry?.sessionId ?? randomUUID();
|
||||
store[canonicalKey] = {
|
||||
sessionId,
|
||||
updatedAt: now,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[canonicalKey] = {
|
||||
sessionId,
|
||||
updatedAt: now,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
});
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
|
||||
// Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send).
|
||||
@@ -103,23 +102,22 @@ export const handleBridgeEvent = async (
|
||||
|
||||
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
|
||||
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
|
||||
const { storePath, entry, canonicalKey } = loadSessionEntry(sessionKey);
|
||||
const { storePath, store, entry, canonicalKey } = loadSessionEntry(sessionKey);
|
||||
const now = Date.now();
|
||||
const sessionId = entry?.sessionId ?? randomUUID();
|
||||
store[canonicalKey] = {
|
||||
sessionId,
|
||||
updatedAt: now,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[canonicalKey] = {
|
||||
sessionId,
|
||||
updatedAt: now,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
});
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
|
||||
void agentCommand(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveThinkingDefault } from "../agents/model-selection.js";
|
||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
|
||||
import { mergeSessionEntry, saveSessionStore } from "../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateChatAbortParams,
|
||||
validateChatInjectParams,
|
||||
validateChatHistoryParams,
|
||||
validateChatSendParams,
|
||||
} from "./protocol/index.js";
|
||||
@@ -34,84 +31,6 @@ import {
|
||||
|
||||
export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, method, params) => {
|
||||
switch (method) {
|
||||
case "chat.inject": {
|
||||
if (!validateChatInjectParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const p = params as {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const { storePath, entry } = loadSessionEntry(p.sessionKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
if (!sessionId || !storePath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: ErrorCodes.INVALID_REQUEST, message: "session not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const transcriptPath = entry?.sessionFile
|
||||
? entry.sessionFile
|
||||
: path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
||||
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: ErrorCodes.INVALID_REQUEST, message: "transcript file not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const messageId = randomUUID().slice(0, 8);
|
||||
const labelPrefix = p.label ? `[${p.label}]\n\n` : "";
|
||||
const messageBody: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `${labelPrefix}${p.message}` }],
|
||||
timestamp: now,
|
||||
stopReason: "injected",
|
||||
usage: { input: 0, output: 0, totalTokens: 0 },
|
||||
};
|
||||
const transcriptEntry = {
|
||||
type: "message",
|
||||
id: messageId,
|
||||
timestamp: new Date(now).toISOString(),
|
||||
message: messageBody,
|
||||
};
|
||||
|
||||
try {
|
||||
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.UNAVAILABLE,
|
||||
message: `failed to write transcript: ${errMessage}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const chatPayload = {
|
||||
runId: `inject-${messageId}`,
|
||||
sessionKey: p.sessionKey,
|
||||
seq: 0,
|
||||
state: "final" as const,
|
||||
message: transcriptEntry.message,
|
||||
};
|
||||
ctx.broadcast("chat", chatPayload);
|
||||
ctx.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
|
||||
|
||||
return { ok: true, payloadJSON: JSON.stringify({ ok: true, messageId }) };
|
||||
}
|
||||
case "chat.history": {
|
||||
if (!validateChatHistoryParams(params)) {
|
||||
return {
|
||||
@@ -298,7 +217,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
|
||||
}
|
||||
}
|
||||
|
||||
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
|
||||
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
|
||||
const timeoutMs = resolveAgentTimeoutMs({
|
||||
cfg,
|
||||
overrideMs: p.timeoutMs,
|
||||
@@ -375,10 +294,11 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
|
||||
clientRunId,
|
||||
});
|
||||
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[canonicalKey] = sessionEntry;
|
||||
});
|
||||
if (store) {
|
||||
store[canonicalKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
}
|
||||
|
||||
const ackPayload = {
|
||||
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
} from "../agents/pi-embedded.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveMainSessionKeyFromConfig,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { clearCommandLane } from "../process/command-queue.js";
|
||||
import {
|
||||
@@ -125,20 +126,19 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
|
||||
const cfg = loadConfig();
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
const applied = await updateSessionStore(storePath, async (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
return await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
storeKey: primaryKey,
|
||||
patch: p,
|
||||
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const applied = await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
storeKey: primaryKey,
|
||||
patch: p,
|
||||
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
|
||||
});
|
||||
if (!applied.ok) {
|
||||
return {
|
||||
@@ -150,6 +150,7 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
|
||||
},
|
||||
};
|
||||
}
|
||||
await saveSessionStore(storePath, store);
|
||||
const payload: SessionsPatchResult = {
|
||||
ok: true,
|
||||
path: storePath,
|
||||
@@ -181,43 +182,32 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
|
||||
};
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
const next = await updateSessionStore(storePath, (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const entry = store[primaryKey];
|
||||
const now = Date.now();
|
||||
const nextEntry: SessionEntry = {
|
||||
sessionId: randomUUID(),
|
||||
updatedAt: now,
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
label: entry?.label,
|
||||
displayName: entry?.displayName,
|
||||
chatType: entry?.chatType,
|
||||
channel: entry?.channel,
|
||||
subject: entry?.subject,
|
||||
room: entry?.room,
|
||||
space: entry?.space,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
};
|
||||
store[primaryKey] = nextEntry;
|
||||
return nextEntry;
|
||||
});
|
||||
const { storePath, store, entry } = loadSessionEntry(key);
|
||||
const now = Date.now();
|
||||
const next: SessionEntry = {
|
||||
sessionId: randomUUID(),
|
||||
updatedAt: now,
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
label: entry?.label,
|
||||
displayName: entry?.displayName,
|
||||
chatType: entry?.chatType,
|
||||
channel: entry?.channel,
|
||||
subject: entry?.subject,
|
||||
room: entry?.room,
|
||||
space: entry?.space,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
};
|
||||
store[key] = next;
|
||||
await saveSessionStore(storePath, store);
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({ ok: true, key, entry: next }),
|
||||
@@ -259,11 +249,9 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
|
||||
|
||||
const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
|
||||
|
||||
const cfg = loadConfig();
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
const { entry } = loadSessionEntry(key);
|
||||
const { storePath, store, entry } = loadSessionEntry(key);
|
||||
const sessionId = entry?.sessionId;
|
||||
const existed = Boolean(store[key]);
|
||||
clearCommandLane(resolveEmbeddedSessionLane(key));
|
||||
if (sessionId && isEmbeddedPiRunActive(sessionId)) {
|
||||
abortEmbeddedPiRun(sessionId);
|
||||
@@ -278,19 +266,8 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
|
||||
};
|
||||
}
|
||||
}
|
||||
const deletion = await updateSessionStore(storePath, (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const entryToDelete = store[primaryKey];
|
||||
const existed = Boolean(entryToDelete);
|
||||
if (existed) delete store[primaryKey];
|
||||
return { existed, entry: entryToDelete };
|
||||
});
|
||||
const existed = deletion.existed;
|
||||
if (existed) delete store[key];
|
||||
await saveSessionStore(storePath, store);
|
||||
|
||||
const archived: string[] = [];
|
||||
if (deleteTranscript && sessionId) {
|
||||
@@ -346,20 +323,7 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
|
||||
? Math.max(1, Math.floor(p.maxLines))
|
||||
: 400;
|
||||
|
||||
const cfg = loadConfig();
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
// Resolve entry inside the lock, but compact outside to avoid holding it.
|
||||
const compactTarget = await updateSessionStore(storePath, (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
return { entry: store[primaryKey], primaryKey };
|
||||
});
|
||||
const entry = compactTarget.entry;
|
||||
const { storePath, store, entry } = loadSessionEntry(key);
|
||||
const sessionId = entry?.sessionId;
|
||||
if (!sessionId) {
|
||||
return {
|
||||
@@ -409,14 +373,13 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
|
||||
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
|
||||
|
||||
// Token counts no longer match; clear so status + UI reflect reality after the next turn.
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const entryToUpdate = store[compactTarget.primaryKey];
|
||||
if (!entryToUpdate) return;
|
||||
delete entryToUpdate.inputTokens;
|
||||
delete entryToUpdate.outputTokens;
|
||||
delete entryToUpdate.totalTokens;
|
||||
entryToUpdate.updatedAt = Date.now();
|
||||
});
|
||||
if (store[key]) {
|
||||
delete store[key].inputTokens;
|
||||
delete store[key].outputTokens;
|
||||
delete store[key].totalTokens;
|
||||
store[key].updatedAt = Date.now();
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
||||
@@ -136,7 +136,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
let cfgForAgent: ReturnType<typeof loadConfig> | undefined;
|
||||
|
||||
if (requestedSessionKey) {
|
||||
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey);
|
||||
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(requestedSessionKey);
|
||||
cfgForAgent = cfg;
|
||||
const now = Date.now();
|
||||
const sessionId = entry?.sessionId ?? randomUUID();
|
||||
@@ -178,10 +178,11 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
const canonicalSessionKey = canonicalKey;
|
||||
const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey);
|
||||
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[canonicalSessionKey] = nextEntry;
|
||||
});
|
||||
if (store) {
|
||||
store[canonicalSessionKey] = nextEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
}
|
||||
if (canonicalSessionKey === mainSessionKey || canonicalSessionKey === "global") {
|
||||
context.addChatRun(idem, {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { agentCommand } from "../../commands/agent.js";
|
||||
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { mergeSessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
@@ -23,7 +21,6 @@ import {
|
||||
formatValidationErrors,
|
||||
validateChatAbortParams,
|
||||
validateChatHistoryParams,
|
||||
validateChatInjectParams,
|
||||
validateChatSendParams,
|
||||
} from "../protocol/index.js";
|
||||
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "../server-constants.js";
|
||||
@@ -208,7 +205,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
|
||||
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
|
||||
const timeoutMs = resolveAgentTimeoutMs({
|
||||
cfg,
|
||||
overrideMs: p.timeoutMs,
|
||||
@@ -287,10 +284,11 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
clientRunId,
|
||||
});
|
||||
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[canonicalKey] = sessionEntry;
|
||||
});
|
||||
if (store) {
|
||||
store[canonicalKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
}
|
||||
|
||||
const ackPayload = {
|
||||
@@ -357,80 +355,4 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
}
|
||||
},
|
||||
"chat.inject": async ({ params, respond, context }) => {
|
||||
if (!validateChatInjectParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
// Load session to find transcript file
|
||||
const { storePath, entry } = loadSessionEntry(p.sessionKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
if (!sessionId || !storePath) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "session not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve transcript path
|
||||
const transcriptPath = entry?.sessionFile
|
||||
? entry.sessionFile
|
||||
: path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
||||
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "transcript file not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build transcript entry
|
||||
const now = Date.now();
|
||||
const messageId = randomUUID().slice(0, 8);
|
||||
const labelPrefix = p.label ? `[${p.label}]\n\n` : "";
|
||||
const messageBody: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `${labelPrefix}${p.message}` }],
|
||||
timestamp: now,
|
||||
stopReason: "injected",
|
||||
usage: { input: 0, output: 0, totalTokens: 0 },
|
||||
};
|
||||
const transcriptEntry = {
|
||||
type: "message",
|
||||
id: messageId,
|
||||
timestamp: new Date(now).toISOString(),
|
||||
message: messageBody,
|
||||
};
|
||||
|
||||
// Append to transcript file
|
||||
try {
|
||||
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `failed to write transcript: ${errMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast to webchat for immediate UI update
|
||||
const chatPayload = {
|
||||
runId: `inject-${messageId}`,
|
||||
sessionKey: p.sessionKey,
|
||||
seq: 0,
|
||||
state: "final" as const,
|
||||
message: transcriptEntry.message,
|
||||
};
|
||||
context.broadcast("chat", chatPayload);
|
||||
context.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
|
||||
|
||||
respond(true, { ok: true, messageId });
|
||||
},
|
||||
};
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveMainSessionKey,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { clearCommandLane } from "../../process/command-queue.js";
|
||||
import {
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
archiveFileOnDisk,
|
||||
listSessionsFromStore,
|
||||
loadCombinedSessionStoreForGateway,
|
||||
loadSessionEntry,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionTranscriptCandidates,
|
||||
type SessionsPatchResult,
|
||||
@@ -106,25 +106,26 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const cfg = loadConfig();
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
const applied = await updateSessionStore(storePath, async (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
return await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
storeKey: primaryKey,
|
||||
patch: p,
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const applied = await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
storeKey: primaryKey,
|
||||
patch: p,
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
});
|
||||
if (!applied.ok) {
|
||||
respond(false, undefined, applied.error);
|
||||
return;
|
||||
}
|
||||
await saveSessionStore(storePath, store);
|
||||
const result: SessionsPatchResult = {
|
||||
ok: true,
|
||||
path: storePath,
|
||||
@@ -155,35 +156,34 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const cfg = loadConfig();
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
const next = await updateSessionStore(storePath, (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const entry = store[primaryKey];
|
||||
const now = Date.now();
|
||||
const nextEntry: SessionEntry = {
|
||||
sessionId: randomUUID(),
|
||||
updatedAt: now,
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
responseUsage: entry?.responseUsage,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
label: entry?.label,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
};
|
||||
store[primaryKey] = nextEntry;
|
||||
return nextEntry;
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const entry = store[primaryKey];
|
||||
const now = Date.now();
|
||||
const next: SessionEntry = {
|
||||
sessionId: randomUUID(),
|
||||
updatedAt: now,
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
responseUsage: entry?.responseUsage,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
label: entry?.label,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
};
|
||||
store[primaryKey] = next;
|
||||
await saveSessionStore(storePath, store);
|
||||
respond(true, { ok: true, key: target.canonicalKey, entry: next }, undefined);
|
||||
},
|
||||
"sessions.delete": async ({ params, respond }) => {
|
||||
@@ -220,7 +220,14 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
|
||||
|
||||
const storePath = target.storePath;
|
||||
const { entry } = loadSessionEntry(key);
|
||||
const store = loadSessionStore(storePath);
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const entry = store[primaryKey];
|
||||
const sessionId = entry?.sessionId;
|
||||
const existed = Boolean(entry);
|
||||
clearCommandLane(resolveEmbeddedSessionLane(target.canonicalKey));
|
||||
@@ -239,15 +246,8 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
if (store[primaryKey]) delete store[primaryKey];
|
||||
});
|
||||
if (existed) delete store[primaryKey];
|
||||
await saveSessionStore(storePath, store);
|
||||
|
||||
const archived: string[] = [];
|
||||
if (deleteTranscript && sessionId) {
|
||||
@@ -295,17 +295,14 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const cfg = loadConfig();
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
// Lock + read in a short critical section; transcript work happens outside.
|
||||
const compactTarget = await updateSessionStore(storePath, (store) => {
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
return { entry: store[primaryKey], primaryKey };
|
||||
});
|
||||
const entry = compactTarget.entry;
|
||||
const store = loadSessionStore(storePath);
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const entry = store[primaryKey];
|
||||
const sessionId = entry?.sessionId;
|
||||
if (!sessionId) {
|
||||
respond(
|
||||
@@ -361,15 +358,13 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const keptLines = lines.slice(-maxLines);
|
||||
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
|
||||
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const entryKey = compactTarget.primaryKey;
|
||||
const entryToUpdate = store[entryKey];
|
||||
if (!entryToUpdate) return;
|
||||
delete entryToUpdate.inputTokens;
|
||||
delete entryToUpdate.outputTokens;
|
||||
delete entryToUpdate.totalTokens;
|
||||
entryToUpdate.updatedAt = Date.now();
|
||||
});
|
||||
if (store[primaryKey]) {
|
||||
delete store[primaryKey].inputTokens;
|
||||
delete store[primaryKey].outputTokens;
|
||||
delete store[primaryKey].totalTokens;
|
||||
store[primaryKey].updatedAt = Date.now();
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
|
||||
respond(
|
||||
true,
|
||||
|
||||
@@ -381,60 +381,6 @@ describe("gateway server chat", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("chat.inject appends to the session transcript", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
const transcriptPath = path.join(dir, "sess-main.jsonl");
|
||||
|
||||
await fs.writeFile(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: "message",
|
||||
id: "m1",
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() },
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq<{ messageId?: string }>(ws, "chat.inject", {
|
||||
sessionKey: "main",
|
||||
message: "injected text",
|
||||
label: "note",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const raw = await fs.readFile(transcriptPath, "utf-8");
|
||||
const lines = raw.split(/\r?\n/).filter(Boolean);
|
||||
expect(lines.length).toBe(2);
|
||||
const last = JSON.parse(lines[1]) as {
|
||||
message?: { role?: string; content?: Array<{ text?: string }> };
|
||||
};
|
||||
expect(last.message?.role).toBe("assistant");
|
||||
expect(last.message?.content?.[0]?.text).toContain("injected text");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("chat.history defaults thinking to low for reasoning-capable models", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [
|
||||
|
||||
@@ -285,12 +285,9 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
|
||||
const store = loadSessionStore(storePath);
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
||||
// Merge with existing entry if present (avoid overwriting with less complete data)
|
||||
const existing = combined[canonicalKey];
|
||||
combined[canonicalKey] = {
|
||||
...existing,
|
||||
...entry,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -17,7 +17,7 @@ import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
@@ -150,13 +150,8 @@ async function restoreHeartbeatUpdatedAt(params: {
|
||||
if (!entry) return;
|
||||
const nextUpdatedAt = Math.max(entry.updatedAt ?? 0, updatedAt);
|
||||
if (entry.updatedAt === nextUpdatedAt) return;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
const nextEntry = nextStore[sessionKey] ?? entry;
|
||||
if (!nextEntry) return;
|
||||
const resolvedUpdatedAt = Math.max(nextEntry.updatedAt ?? 0, updatedAt);
|
||||
if (nextEntry.updatedAt === resolvedUpdatedAt) return;
|
||||
nextStore[sessionKey] = { ...nextEntry, updatedAt: resolvedUpdatedAt };
|
||||
});
|
||||
store[sessionKey] = { ...entry, updatedAt: nextUpdatedAt };
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
|
||||
function normalizeHeartbeatReply(
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SlackAccountConfig } from "../config/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";
|
||||
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||
|
||||
export type SlackTokenSource = "env" | "config" | "none";
|
||||
|
||||
@@ -11,7 +11,6 @@ export type ResolvedSlackAccount = {
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
userToken?: string;
|
||||
botTokenSource: SlackTokenSource;
|
||||
appTokenSource: SlackTokenSource;
|
||||
config: SlackAccountConfig;
|
||||
@@ -76,7 +75,6 @@ export function resolveSlackAccount(params: {
|
||||
const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined;
|
||||
const configBot = resolveSlackBotToken(merged.botToken);
|
||||
const configApp = resolveSlackAppToken(merged.appToken);
|
||||
const configUser = resolveSlackUserToken(merged.userToken);
|
||||
const botToken = configBot ?? envBot;
|
||||
const appToken = configApp ?? envApp;
|
||||
const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none";
|
||||
@@ -88,7 +86,6 @@ export function resolveSlackAccount(params: {
|
||||
name: merged.name?.trim() || undefined,
|
||||
botToken,
|
||||
appToken,
|
||||
userToken: configUser,
|
||||
botTokenSource,
|
||||
appTokenSource,
|
||||
config: merged,
|
||||
|
||||
@@ -10,44 +10,3 @@ export function resolveSlackBotToken(raw?: string): string | undefined {
|
||||
export function resolveSlackAppToken(raw?: string): string | undefined {
|
||||
return normalizeSlackToken(raw);
|
||||
}
|
||||
|
||||
export function resolveSlackUserToken(raw?: string): string | undefined {
|
||||
return normalizeSlackToken(raw);
|
||||
}
|
||||
|
||||
export type SlackTokenOperation = "read" | "write";
|
||||
|
||||
export type SlackTokenSelection = {
|
||||
botToken?: string;
|
||||
userToken?: string;
|
||||
userTokenReadOnly?: boolean;
|
||||
operation: SlackTokenOperation;
|
||||
};
|
||||
|
||||
function selectSlackTokenNormalized(params: SlackTokenSelection): string | undefined {
|
||||
const allowUserWrites = params.userTokenReadOnly === false;
|
||||
if (params.operation === "read") return params.userToken ?? params.botToken;
|
||||
if (!allowUserWrites) return params.botToken;
|
||||
return params.botToken ?? params.userToken;
|
||||
}
|
||||
|
||||
export function selectSlackToken(params: SlackTokenSelection): string | undefined {
|
||||
const botToken = resolveSlackBotToken(params.botToken);
|
||||
const userToken = resolveSlackUserToken(params.userToken);
|
||||
return selectSlackTokenNormalized({
|
||||
...params,
|
||||
botToken,
|
||||
userToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSlackTokenOverride(params: SlackTokenSelection): string | undefined {
|
||||
const botToken = resolveSlackBotToken(params.botToken);
|
||||
const userToken = resolveSlackUserToken(params.userToken);
|
||||
const token = selectSlackTokenNormalized({
|
||||
...params,
|
||||
botToken,
|
||||
userToken,
|
||||
});
|
||||
return token && token !== botToken ? token : undefined;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
loadSessionStore,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
updateSessionStore,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { emitHeartbeatEvent } from "../../infra/heartbeat-events.js";
|
||||
import { getChildLogger } from "../../logging.js";
|
||||
@@ -72,14 +72,7 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
const nextCurrent = nextStore[sessionKey] ?? current;
|
||||
nextStore[sessionKey] = {
|
||||
...nextCurrent,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
const sessionSnapshot = getSessionSnapshot(cfg, to, true);
|
||||
if (verbose) {
|
||||
@@ -170,14 +163,7 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
const store = loadSessionStore(storePath);
|
||||
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
|
||||
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
const nextEntry = nextStore[sessionSnapshot.key];
|
||||
if (!nextEntry) return;
|
||||
nextStore[sessionSnapshot.key] = {
|
||||
...nextEntry,
|
||||
updatedAt: sessionSnapshot.entry.updatedAt,
|
||||
};
|
||||
});
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
|
||||
heartbeatLogger.info(
|
||||
|
||||
Reference in new issue
Block a user