Merge remote-tracking branch 'origin/main' into feature/per-agent-sandbox-tools
This commit is contained in:
commit
abf43f6db1
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -369,6 +369,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
accept-android-sdk-licenses: false
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|||||||
@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
### PR Workflow (Review vs Land)
|
### PR Workflow (Review vs Land)
|
||||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing).
|
- **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!
|
||||||
|
|
||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@ -18,27 +18,47 @@
|
|||||||
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Discord/Telegram: add per-request retry policy with configurable delays and docs.
|
||||||
|
- Telegram: run long polling via grammY runner with per-chat sequentialization and concurrency tied to `agent.maxConcurrent`. Thanks @mukhtharcm for PR #366.
|
||||||
|
- macOS: prevent gateway launchd startup race where the app could kill a just-started gateway; avoid unnecessary `bootout` and ensure the job is enabled at login. Fixes #306. Thanks @gupsammy for PR #387.
|
||||||
|
- macOS: ignore ciao announcement cancellation rejections during Bonjour shutdown to avoid unhandled exits. Thanks @emanuelst for PR #419.
|
||||||
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
|
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
|
||||||
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
|
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
|
||||||
|
- WhatsApp: add self-phone mode (no pairing replies for outbound DMs) and onboarding prompt for personal vs separate numbers (auto allowlist + response prefix for personal).
|
||||||
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
|
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
|
||||||
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
|
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
|
||||||
- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380.
|
- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380.
|
||||||
|
- Tools: make per-agent tool policies override global defaults and run bash synchronously when `process` is disallowed.
|
||||||
|
- Tools: scope `process` sessions per agent to prevent cross-agent visibility.
|
||||||
|
- Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412.
|
||||||
|
- Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414.
|
||||||
|
- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
|
||||||
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
|
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
|
||||||
|
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
|
||||||
|
- Tools: keep tool failure logs concise (no stack traces); full stack only in debug logs.
|
||||||
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
|
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
|
||||||
|
- Android: fix APK output filename renaming after AGP updates. Thanks @Syhids for PR #410.
|
||||||
|
- Android: rotate camera photos by EXIF orientation. Thanks @fcatuhe for PR #403.
|
||||||
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
|
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
|
||||||
- CLI: add `clawdbot docs` live docs search with pretty output.
|
- CLI: add `clawdbot docs` live docs search with pretty output.
|
||||||
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
|
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
|
||||||
|
- Discord/Slack: fork thread sessions (agent-scoped) and inject thread starters for context. Thanks @thewilloftheshadow for PR #400.
|
||||||
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
|
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
|
||||||
|
- Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381.
|
||||||
|
- Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381.
|
||||||
- Agent: deliver final replies for non-streaming models when block chunking is enabled. Thank you @mneves75 for PR #369!
|
- Agent: deliver final replies for non-streaming models when block chunking is enabled. Thank you @mneves75 for PR #369!
|
||||||
- Agent: trim bootstrap context injections and keep group guidance concise (emoji reactions allowed). Thanks @tobiasbischoff for PR #370.
|
- Agent: trim bootstrap context injections and keep group guidance concise (emoji reactions allowed). Thanks @tobiasbischoff for PR #370.
|
||||||
|
- Agent: return a friendly context overflow response (413/request_too_large). Thanks @alejandroOPI for PR #395.
|
||||||
- Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298.
|
- Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298.
|
||||||
- Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent.
|
- Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent.
|
||||||
|
- Sessions: forward explicit sessionKey through gateway/chat/node bridge to avoid sub-agent sessionId mixups.
|
||||||
- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.
|
- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.
|
||||||
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
|
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
|
||||||
- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
|
- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
|
||||||
- Docs: sanitize AGENTS guidance and add Clawdis migration troubleshooting note. Thanks @buddyh for PR #348.
|
- Docs: sanitize AGENTS guidance and add Clawdis migration troubleshooting note. Thanks @buddyh for PR #348.
|
||||||
- Docs: add ClawdHub guide and hubs link for browsing, install, and sync workflows.
|
- Docs: add ClawdHub guide and hubs link for browsing, install, and sync workflows.
|
||||||
- Docs: add FAQ for PNPM/Bun lockfile migration warning; link AgentSkills spec + ClawdHub guide (`/clawdhub`) from skills docs.
|
- Docs: add FAQ for PNPM/Bun lockfile migration warning; link AgentSkills spec + ClawdHub guide (`/clawdhub`) from skills docs.
|
||||||
|
- Docs: add showcase projects (xuezh, gohome, roborock, padel-cli). Thanks @joshp123.
|
||||||
- Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312.
|
- Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312.
|
||||||
- Status: add provider usage snapshots to `/status`, `clawdbot status --usage`, and the macOS menu bar.
|
- Status: add provider usage snapshots to `/status`, `clawdbot status --usage`, and the macOS menu bar.
|
||||||
- Build: fix macOS packaging QR smoke test for the bun-compiled relay. Thanks @dbhurley for PR #358.
|
- Build: fix macOS packaging QR smoke test for the bun-compiled relay. Thanks @dbhurley for PR #358.
|
||||||
@ -49,6 +69,8 @@
|
|||||||
- Telegram: include sender identity in group envelope headers. (#336)
|
- Telegram: include sender identity in group envelope headers. (#336)
|
||||||
- Telegram: support forum topics with topic-isolated sessions and message_thread_id routing. Thanks @HazAT, @nachoiacovino, @RandyVentures for PR #321/#333/#334.
|
- Telegram: support forum topics with topic-isolated sessions and message_thread_id routing. Thanks @HazAT, @nachoiacovino, @RandyVentures for PR #321/#333/#334.
|
||||||
- Telegram: add draft streaming via `sendMessageDraft` with `telegram.streamMode`, plus `/reasoning stream` for draft-only reasoning.
|
- Telegram: add draft streaming via `sendMessageDraft` with `telegram.streamMode`, plus `/reasoning stream` for draft-only reasoning.
|
||||||
|
- Telegram: honor `/activation` session mode for group mention gating and clarify group activation docs. Thanks @julianengel for PR #377.
|
||||||
|
- Telegram: isolate forum topic transcripts per thread and validate Gemini turn ordering in multi-topic sessions. Thanks @hsrvc for PR #407.
|
||||||
- iMessage: ignore disconnect errors during shutdown (avoid unhandled promise rejections). Thanks @antons for PR #359.
|
- iMessage: ignore disconnect errors during shutdown (avoid unhandled promise rejections). Thanks @antons for PR #359.
|
||||||
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
|
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
|
||||||
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
|
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
|
||||||
@ -58,6 +80,7 @@
|
|||||||
- Auto-reply: add per-channel/topic skill filters + system prompts for Discord/Slack/Telegram. Thanks @kitze for PR #286.
|
- Auto-reply: add per-channel/topic skill filters + system prompts for Discord/Slack/Telegram. Thanks @kitze for PR #286.
|
||||||
- Auto-reply: refresh `/status` output with build info, compact context, and queue depth.
|
- Auto-reply: refresh `/status` output with build info, compact context, and queue depth.
|
||||||
- Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295.
|
- Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295.
|
||||||
|
- Commands: allow `/<alias>` shorthand for `/model` using `agent.models.*.alias`, without shadowing built-ins. Thanks @azade-c for PR #393.
|
||||||
- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
|
- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
|
||||||
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
|
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
|
||||||
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
|
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
|
||||||
@ -70,6 +93,7 @@
|
|||||||
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
|
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
|
||||||
- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
|
- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
|
||||||
- Doctor: add `--yes` and `--non-interactive` for headless/automation runs (`--non-interactive` only applies safe migrations).
|
- Doctor: add `--yes` and `--non-interactive` for headless/automation runs (`--non-interactive` only applies safe migrations).
|
||||||
|
- Doctor/CLI: scan for extra gateway-like services (optional `--deep`) and show cleanup hints.
|
||||||
- Gateway/CLI: auto-migrate legacy sessions + agent state layouts on startup (safe; WhatsApp auth still requires `clawdbot doctor`).
|
- Gateway/CLI: auto-migrate legacy sessions + agent state layouts on startup (safe; WhatsApp auth still requires `clawdbot doctor`).
|
||||||
- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion).
|
- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion).
|
||||||
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
|
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
|
||||||
@ -78,6 +102,7 @@
|
|||||||
- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270.
|
- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270.
|
||||||
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
|
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
|
||||||
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
|
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
|
||||||
|
- Status: show configured model in `/status` (override-aware). Thanks @azade-c for PR #396.
|
||||||
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
|
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
|
||||||
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
|
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
|
||||||
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
|
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
|
||||||
@ -117,6 +142,7 @@
|
|||||||
- Control UI: show a reading indicator bubble while the assistant is responding.
|
- Control UI: show a reading indicator bubble while the assistant is responding.
|
||||||
- Control UI: animate reading indicator dots (honors reduced-motion).
|
- Control UI: animate reading indicator dots (honors reduced-motion).
|
||||||
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
|
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
|
||||||
|
- Google: recover from corrupted transcripts that start with an assistant tool call to avoid Cloud Code Assist 400 ordering errors. Thanks @jonasjancarik for PR #421. (#406)
|
||||||
- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
|
- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
|
||||||
- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
|
- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
|
||||||
- Control UI: add Chat focus mode toggle to collapse header + sidebar.
|
- Control UI: add Chat focus mode toggle to collapse header + sidebar.
|
||||||
@ -171,6 +197,11 @@
|
|||||||
- Refactor: centralize group allowlist/mention policy across providers.
|
- Refactor: centralize group allowlist/mention policy across providers.
|
||||||
- Deps: update to latest across the repo.
|
- Deps: update to latest across the repo.
|
||||||
|
|
||||||
|
## 2026.1.7
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Android: bump version to 2026.1.7, add version code, and name APK outputs. Thanks @fcatuhe for PR #402.
|
||||||
|
|
||||||
## 2026.1.5-3
|
## 2026.1.5-3
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@ -454,5 +454,5 @@ Thanks to all clawtributors:
|
|||||||
<a href="https://github.com/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></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/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/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/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></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/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/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/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></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/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/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/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/alejandroOPI"><img src="https://avatars.githubusercontent.com/u/5042906?v=4&s=48" width="48" height="48" alt="alejandroOPI" title="alejandroOPI"/></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>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
@ -19,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 20260107
|
||||||
versionName = "2.0.0-beta3"
|
versionName = "2026.1.7"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@ -54,6 +56,19 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants { variant ->
|
||||||
|
variant.outputs
|
||||||
|
.filterIsInstance<VariantOutputImpl>()
|
||||||
|
.forEach { output ->
|
||||||
|
val versionName = output.versionName.orNull ?: "0"
|
||||||
|
val buildType = variant.buildType
|
||||||
|
|
||||||
|
val outputFileName = "clawdbot-${versionName}-${buildType}.apk"
|
||||||
|
output.outputFileName = outputFileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
|||||||
@ -5,8 +5,10 @@ import android.content.Context
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.ExifInterface
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.camera.core.ImageCapture
|
import androidx.camera.core.ImageCapture
|
||||||
@ -86,18 +88,19 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(owner, selector, capture)
|
provider.bindToLifecycle(owner, selector, capture)
|
||||||
|
|
||||||
val bytes = capture.takeJpegBytes(context.mainExecutor())
|
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor())
|
||||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||||
|
val rotated = rotateBitmapByExif(decoded, orientation)
|
||||||
val scaled =
|
val scaled =
|
||||||
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
|
if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) {
|
||||||
val h =
|
val h =
|
||||||
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
|
||||||
.toInt()
|
.toInt()
|
||||||
.coerceAtLeast(1)
|
.coerceAtLeast(1)
|
||||||
decoded.scale(maxWidth, h)
|
rotated.scale(maxWidth, h)
|
||||||
} else {
|
} else {
|
||||||
decoded
|
rotated
|
||||||
}
|
}
|
||||||
|
|
||||||
val maxPayloadBytes = 5 * 1024 * 1024
|
val maxPayloadBytes = 5 * 1024 * 1024
|
||||||
@ -194,6 +197,31 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
|
||||||
|
val matrix = Matrix()
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
|
matrix.postRotate(90f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
|
matrix.postRotate(-90f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
else -> return bitmap
|
||||||
|
}
|
||||||
|
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
if (rotated !== bitmap) {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
return rotated
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFacing(paramsJson: String?): String? =
|
private fun parseFacing(paramsJson: String?): String? =
|
||||||
when {
|
when {
|
||||||
paramsJson?.contains("\"front\"") == true -> "front"
|
paramsJson?.contains("\"front\"") == true -> "front"
|
||||||
@ -254,7 +282,8 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
|
||||||
|
private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<ByteArray, Int> =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
val file = File.createTempFile("clawdbot-snap-", ".jpg")
|
val file = File.createTempFile("clawdbot-snap-", ".jpg")
|
||||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||||
@ -263,13 +292,19 @@ private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
|||||||
executor,
|
executor,
|
||||||
object : ImageCapture.OnImageSavedCallback {
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
override fun onError(exception: ImageCaptureException) {
|
override fun onError(exception: ImageCaptureException) {
|
||||||
|
file.delete()
|
||||||
cont.resumeWithException(exception)
|
cont.resumeWithException(exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||||
try {
|
try {
|
||||||
|
val exif = ExifInterface(file.absolutePath)
|
||||||
|
val orientation = exif.getAttributeInt(
|
||||||
|
ExifInterface.TAG_ORIENTATION,
|
||||||
|
ExifInterface.ORIENTATION_NORMAL,
|
||||||
|
)
|
||||||
val bytes = file.readBytes()
|
val bytes = file.readBytes()
|
||||||
cont.resume(bytes)
|
cont.resume(Pair(bytes, orientation))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
cont.resumeWithException(e)
|
cont.resumeWithException(e)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotProtocol
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
|
|
||||||
/// Marked `@unchecked Sendable` because it can hold reference types.
|
|
||||||
struct AnyCodable: Codable, @unchecked Sendable {
|
|
||||||
let value: Any
|
|
||||||
|
|
||||||
init(_ value: Any) { self.value = value }
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
|
||||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
|
||||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
|
||||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
|
||||||
if container.decodeNil() { self.value = NSNull(); return }
|
|
||||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
|
||||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
|
||||||
throw DecodingError.dataCorruptedError(
|
|
||||||
in: container,
|
|
||||||
debugDescription: "Unsupported type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
switch self.value {
|
|
||||||
case let intVal as Int: try container.encode(intVal)
|
|
||||||
case let doubleVal as Double: try container.encode(doubleVal)
|
|
||||||
case let boolVal as Bool: try container.encode(boolVal)
|
|
||||||
case let stringVal as String: try container.encode(stringVal)
|
|
||||||
case is NSNull: try container.encodeNil()
|
|
||||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
|
||||||
case let array as [AnyCodable]: try container.encode(array)
|
|
||||||
case let dict as [String: Any]:
|
|
||||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
|
||||||
case let array as [Any]:
|
|
||||||
try container.encode(array.map { AnyCodable($0) })
|
|
||||||
case let dict as NSDictionary:
|
|
||||||
var converted: [String: AnyCodable] = [:]
|
|
||||||
for (k, v) in dict {
|
|
||||||
guard let key = k as? String else { continue }
|
|
||||||
converted[key] = AnyCodable(v)
|
|
||||||
}
|
|
||||||
try container.encode(converted)
|
|
||||||
case let array as NSArray:
|
|
||||||
try container.encode(array.map { AnyCodable($0) })
|
|
||||||
default:
|
|
||||||
let context = EncodingError.Context(
|
|
||||||
codingPath: encoder.codingPath,
|
|
||||||
debugDescription: "Unsupported type")
|
|
||||||
throw EncodingError.invalidValue(self.value, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -229,7 +229,7 @@ actor BridgeServer {
|
|||||||
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
|
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
let params: [String: AnyCodable]?
|
let params: [String: ClawdbotProtocol.AnyCodable]?
|
||||||
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
|
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
|
||||||
guard let data = json.data(using: .utf8) else {
|
guard let data = json.data(using: .utf8) else {
|
||||||
return BridgeRPCResponse(
|
return BridgeRPCResponse(
|
||||||
@ -238,7 +238,7 @@ actor BridgeServer {
|
|||||||
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
|
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
|
params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
return BridgeRPCResponse(
|
return BridgeRPCResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
@ -360,16 +360,16 @@ actor BridgeServer {
|
|||||||
"reason \(reason)",
|
"reason \(reason)",
|
||||||
].compactMap(\.self).joined(separator: " · ")
|
].compactMap(\.self).joined(separator: " · ")
|
||||||
|
|
||||||
var params: [String: AnyCodable] = [
|
var params: [String: ClawdbotProtocol.AnyCodable] = [
|
||||||
"text": AnyCodable(summary),
|
"text": ClawdbotProtocol.AnyCodable(summary),
|
||||||
"instanceId": AnyCodable(nodeId),
|
"instanceId": ClawdbotProtocol.AnyCodable(nodeId),
|
||||||
"host": AnyCodable(host),
|
"host": ClawdbotProtocol.AnyCodable(host),
|
||||||
"mode": AnyCodable("node"),
|
"mode": ClawdbotProtocol.AnyCodable("node"),
|
||||||
"reason": AnyCodable(reason),
|
"reason": ClawdbotProtocol.AnyCodable(reason),
|
||||||
"tags": AnyCodable(tags),
|
"tags": ClawdbotProtocol.AnyCodable(tags),
|
||||||
]
|
]
|
||||||
if let ip { params["ip"] = AnyCodable(ip) }
|
if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
|
||||||
if let version { params["version"] = AnyCodable(version) }
|
if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
|
||||||
await GatewayConnection.shared.sendSystemEvent(params)
|
await GatewayConnection.shared.sendSystemEvent(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum ClawdbotConfigFile {
|
enum ClawdbotConfigFile {
|
||||||
@ -32,7 +33,8 @@ enum ClawdbotConfigFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func saveDict(_ dict: [String: Any]) {
|
static func saveDict(_ dict: [String: Any]) {
|
||||||
if ProcessInfo.processInfo.isNixMode { return }
|
// Nix mode disables config writes in production, but tests rely on saving temp configs.
|
||||||
|
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
|
||||||
do {
|
do {
|
||||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||||
let url = self.url()
|
let url = self.url()
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import Foundation
|
|||||||
enum ClawdbotEnv {
|
enum ClawdbotEnv {
|
||||||
static func path(_ key: String) -> String? {
|
static func path(_ key: String) -> String? {
|
||||||
// Normalize env overrides once so UI + file IO stay consistent.
|
// Normalize env overrides once so UI + file IO stay consistent.
|
||||||
guard let value = ProcessInfo.processInfo.environment[key]?
|
guard let raw = getenv(key) else { return nil }
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
!value.isEmpty
|
guard !value.isEmpty
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum ConfigStore {
|
enum ConfigStore {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotProtocol
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CronJobEditor: View {
|
struct CronJobEditor: View {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension CronSettings {
|
extension CronSettings {
|
||||||
|
|||||||
@ -16,12 +16,11 @@ enum GatewayAgentChannel: String, CaseIterable, Sendable {
|
|||||||
func shouldDeliver(_ isLast: Bool) -> Bool {
|
func shouldDeliver(_ isLast: Bool) -> Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .webchat:
|
case .webchat:
|
||||||
return false
|
false
|
||||||
case .last:
|
case .last:
|
||||||
return isLast
|
isLast
|
||||||
case .whatsapp, .telegram:
|
case .whatsapp, .telegram:
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -208,9 +208,15 @@ final class GatewayDiscoveryModel {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parseGatewayTXT(_ txt: [String: String])
|
struct GatewayTXT: Equatable {
|
||||||
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, gatewayPort: Int?, cliPath: String?)
|
var lanHost: String?
|
||||||
{
|
var tailnetDns: String?
|
||||||
|
var sshPort: Int
|
||||||
|
var gatewayPort: Int?
|
||||||
|
var cliPath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
||||||
var lanHost: String?
|
var lanHost: String?
|
||||||
var tailnetDns: String?
|
var tailnetDns: String?
|
||||||
var sshPort = 22
|
var sshPort = 22
|
||||||
@ -242,7 +248,12 @@ final class GatewayDiscoveryModel {
|
|||||||
cliPath = trimmed.isEmpty ? nil : trimmed
|
cliPath = trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
return (lanHost, tailnetDns, sshPort, gatewayPort, cliPath)
|
return GatewayTXT(
|
||||||
|
lanHost: lanHost,
|
||||||
|
tailnetDns: tailnetDns,
|
||||||
|
sshPort: sshPort,
|
||||||
|
gatewayPort: gatewayPort,
|
||||||
|
cliPath: cliPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||||
|
|||||||
@ -43,25 +43,52 @@ enum GatewayLaunchAgentManager {
|
|||||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||||
}
|
}
|
||||||
|
|
||||||
static func status() async -> Bool {
|
static func isLoaded() async -> Bool {
|
||||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
return result.status == 0
|
return result.status == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
if enabled {
|
if enabled {
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||||
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
|
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
|
||||||
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
|
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
|
||||||
}
|
}
|
||||||
self.logger.info("launchd enable requested port=\(port)")
|
|
||||||
|
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||||
|
let desiredToken = self.preferredGatewayToken()
|
||||||
|
let desiredPassword = self.preferredGatewayPassword()
|
||||||
|
let desiredConfig = DesiredConfig(
|
||||||
|
port: port,
|
||||||
|
bind: desiredBind,
|
||||||
|
token: desiredToken,
|
||||||
|
password: desiredPassword)
|
||||||
|
|
||||||
|
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||||
|
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||||
|
let loaded = await self.isLoaded()
|
||||||
|
if loaded,
|
||||||
|
let existing = self.readPlistConfig(),
|
||||||
|
existing.matches(desiredConfig)
|
||||||
|
{
|
||||||
|
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||||
|
await self.ensureEnabled()
|
||||||
|
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||||
self.writePlist(bundlePath: bundlePath, port: port)
|
self.writePlist(bundlePath: bundlePath, port: port)
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
|
||||||
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
await self.ensureEnabled()
|
||||||
|
if loaded {
|
||||||
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
}
|
||||||
|
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||||
if bootstrap.status != 0 {
|
if bootstrap.status != 0 {
|
||||||
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
self.logger.error("launchd bootstrap failed: \(msg)")
|
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||||
@ -69,20 +96,19 @@ enum GatewayLaunchAgentManager {
|
|||||||
? "Failed to bootstrap gateway launchd job"
|
? "Failed to bootstrap gateway launchd job"
|
||||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
// Note: removed redundant `kickstart -k` that caused race condition.
|
await self.ensureEnabled()
|
||||||
// bootstrap already starts the job; kickstart -k would kill it immediately
|
|
||||||
// and with KeepAlive=true, cause a restart loop with port conflicts.
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
self.logger.info("launchd disable requested")
|
self.logger.info("launchd disable requested")
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
await self.ensureDisabled()
|
||||||
try? FileManager.default.removeItem(at: self.plistURL)
|
try? FileManager.default.removeItem(at: self.plistURL)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func kickstart() async {
|
static func kickstart() async {
|
||||||
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func writePlist(bundlePath: String, port: Int) {
|
private static func writePlist(bundlePath: String, port: Int) {
|
||||||
@ -208,30 +234,57 @@ enum GatewayLaunchAgentManager {
|
|||||||
.replacingOccurrences(of: "'", with: "'")
|
.replacingOccurrences(of: "'", with: "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LaunchctlResult {
|
private struct DesiredConfig: Equatable {
|
||||||
let status: Int32
|
let port: Int
|
||||||
let output: String
|
let bind: String
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
private struct InstalledConfig: Equatable {
|
||||||
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
|
let port: Int?
|
||||||
await Task.detached(priority: .utility) { () -> LaunchctlResult in
|
let bind: String?
|
||||||
let process = Process()
|
let token: String?
|
||||||
process.launchPath = "/bin/launchctl"
|
let password: String?
|
||||||
process.arguments = args
|
|
||||||
let pipe = Pipe()
|
func matches(_ desired: DesiredConfig) -> Bool {
|
||||||
process.standardOutput = pipe
|
guard self.port == desired.port else { return false }
|
||||||
process.standardError = pipe
|
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
||||||
do {
|
guard self.token == desired.token else { return false }
|
||||||
try process.run()
|
guard self.password == desired.password else { return false }
|
||||||
process.waitUntilExit()
|
return true
|
||||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
}
|
||||||
let output = String(data: data, encoding: .utf8) ?? ""
|
}
|
||||||
return LaunchctlResult(status: process.terminationStatus, output: output)
|
|
||||||
} catch {
|
private static func readPlistConfig() -> InstalledConfig? {
|
||||||
return LaunchctlResult(status: -1, output: error.localizedDescription)
|
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
||||||
}
|
return InstalledConfig(
|
||||||
}.value
|
port: snapshot.port,
|
||||||
|
bind: snapshot.bind,
|
||||||
|
token: snapshot.token,
|
||||||
|
password: snapshot.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureEnabled() async {
|
||||||
|
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
guard result.status != 0 else { return }
|
||||||
|
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if msg.isEmpty {
|
||||||
|
self.logger.warning("launchd enable failed")
|
||||||
|
} else {
|
||||||
|
self.logger.warning("launchd enable failed: \(msg)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureDisabled() async {
|
||||||
|
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
guard result.status != 0 else { return }
|
||||||
|
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if msg.isEmpty {
|
||||||
|
self.logger.warning("launchd disable failed")
|
||||||
|
} else {
|
||||||
|
self.logger.warning("launchd disable failed: \(msg)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -70,7 +70,7 @@ final class GatewayProcessManager {
|
|||||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||||
guard !AppStateStore.attachExistingGatewayOnly else { return }
|
guard !AppStateStore.attachExistingGatewayOnly else { return }
|
||||||
let enabled = await GatewayLaunchAgentManager.status()
|
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||||
guard !enabled else { return }
|
guard !enabled else { return }
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
|
|||||||
81
apps/macos/Sources/Clawdbot/Launchctl.swift
Normal file
81
apps/macos/Sources/Clawdbot/Launchctl.swift
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Launchctl {
|
||||||
|
struct Result: Sendable {
|
||||||
|
let status: Int32
|
||||||
|
let output: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func run(_ args: [String]) async -> Result {
|
||||||
|
await Task.detached(priority: .utility) { () -> Result in
|
||||||
|
let process = Process()
|
||||||
|
process.launchPath = "/bin/launchctl"
|
||||||
|
process.arguments = args
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||||
|
let output = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
return Result(status: process.terminationStatus, output: output)
|
||||||
|
} catch {
|
||||||
|
return Result(status: -1, output: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
||||||
|
let programArguments: [String]
|
||||||
|
let environment: [String: String]
|
||||||
|
|
||||||
|
let port: Int?
|
||||||
|
let bind: String?
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LaunchAgentPlist {
|
||||||
|
static func snapshot(url: URL) -> LaunchAgentPlistSnapshot? {
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
let rootAny: Any
|
||||||
|
do {
|
||||||
|
rootAny = try PropertyListSerialization.propertyList(
|
||||||
|
from: data,
|
||||||
|
options: [],
|
||||||
|
format: nil)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let root = rootAny as? [String: Any] else { return nil }
|
||||||
|
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
||||||
|
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
||||||
|
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
||||||
|
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
||||||
|
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
let password = env["CLAWDBOT_GATEWAY_PASSWORD"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
return LaunchAgentPlistSnapshot(
|
||||||
|
programArguments: programArguments,
|
||||||
|
environment: env,
|
||||||
|
port: port,
|
||||||
|
bind: bind,
|
||||||
|
token: token,
|
||||||
|
password: password)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractFlagInt(_ args: [String], flag: String) -> Int? {
|
||||||
|
guard let raw = self.extractFlagString(args, flag: flag) else { return nil }
|
||||||
|
return Int(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractFlagString(_ args: [String], flag: String) -> String? {
|
||||||
|
guard let idx = args.firstIndex(of: flag) else { return nil }
|
||||||
|
let valueIdx = args.index(after: idx)
|
||||||
|
guard valueIdx < args.endIndex else { return nil }
|
||||||
|
let token = args[valueIdx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return token.isEmpty ? nil : token
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -110,8 +110,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
|
|
||||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||||
let width = self.initialWidth(for: menu)
|
let width = self.initialWidth(for: menu)
|
||||||
|
let isConnected = self.isControlChannelConnected
|
||||||
guard self.isControlChannelConnected else { return }
|
|
||||||
|
|
||||||
var cursor = insertIndex
|
var cursor = insertIndex
|
||||||
var headerView: NSView?
|
var headerView: NSView?
|
||||||
@ -132,7 +131,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
|
count: rows.count,
|
||||||
|
statusText: isConnected ? nil : "Gateway disconnected")),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
@ -163,16 +164,29 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
let headerItem = NSMenuItem()
|
let headerItem = NSMenuItem()
|
||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
|
let statusText = isConnected
|
||||||
|
? (self.cachedErrorText ?? "Loading sessions…")
|
||||||
|
: "Gateway disconnected"
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
count: 0,
|
count: 0,
|
||||||
statusText: self.cachedErrorText ?? "Loading sessions…")),
|
statusText: statusText)),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
headerView = hosted
|
headerView = hosted
|
||||||
menu.insertItem(headerItem, at: cursor)
|
menu.insertItem(headerItem, at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
|
|
||||||
|
if !isConnected {
|
||||||
|
menu.insertItem(
|
||||||
|
self.makeMessageItem(
|
||||||
|
text: "Connect the gateway to see sessions",
|
||||||
|
symbolName: "bolt.slash",
|
||||||
|
width: width),
|
||||||
|
at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||||
@ -253,7 +267,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
let rows = self.usageRows
|
let rows = self.usageRows
|
||||||
let errorText = self.cachedUsageErrorText
|
let errorText = self.cachedUsageErrorText
|
||||||
|
|
||||||
if rows.isEmpty && errorText == nil {
|
if rows.isEmpty, errorText == nil {
|
||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,4 +42,3 @@ struct MenuUsageHeaderView: View {
|
|||||||
return "\(self.count) providers"
|
return "\(self.count) providers"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,16 @@ import Foundation
|
|||||||
|
|
||||||
actor MacNodeRuntime {
|
actor MacNodeRuntime {
|
||||||
private let cameraCapture = CameraCaptureService()
|
private let cameraCapture = CameraCaptureService()
|
||||||
@MainActor private let screenRecorder = ScreenRecordService()
|
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||||
@MainActor private let locationService = MacNodeLocationService()
|
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||||
|
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
|
||||||
|
})
|
||||||
|
{
|
||||||
|
self.makeMainActorServices = makeMainActorServices
|
||||||
|
}
|
||||||
|
|
||||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
let command = req.command
|
let command = req.command
|
||||||
@ -212,7 +220,8 @@ actor MacNodeRuntime {
|
|||||||
ClawdbotLocationGetParams()
|
ClawdbotLocationGetParams()
|
||||||
let desired = params.desiredAccuracy ??
|
let desired = params.desiredAccuracy ??
|
||||||
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
||||||
let status = await self.locationService.authorizationStatus()
|
let services = await self.mainActorServices()
|
||||||
|
let status = await services.locationAuthorizationStatus()
|
||||||
if status != .authorizedAlways {
|
if status != .authorizedAlways {
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
@ -222,11 +231,11 @@ actor MacNodeRuntime {
|
|||||||
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
|
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let location = try await self.locationService.currentLocation(
|
let location = try await services.currentLocation(
|
||||||
desiredAccuracy: desired,
|
desiredAccuracy: desired,
|
||||||
maxAgeMs: params.maxAgeMs,
|
maxAgeMs: params.maxAgeMs,
|
||||||
timeoutMs: params.timeoutMs)
|
timeoutMs: params.timeoutMs)
|
||||||
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
|
let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy
|
||||||
let payload = ClawdbotLocationPayload(
|
let payload = ClawdbotLocationPayload(
|
||||||
lat: location.coordinate.latitude,
|
lat: location.coordinate.latitude,
|
||||||
lon: location.coordinate.longitude,
|
lon: location.coordinate.longitude,
|
||||||
@ -265,7 +274,8 @@ actor MacNodeRuntime {
|
|||||||
code: .invalidRequest,
|
code: .invalidRequest,
|
||||||
message: "INVALID_REQUEST: screen format must be mp4")
|
message: "INVALID_REQUEST: screen format must be mp4")
|
||||||
}
|
}
|
||||||
let res = try await self.screenRecorder.record(
|
let services = await self.mainActorServices()
|
||||||
|
let res = try await services.recordScreen(
|
||||||
screenIndex: params.screenIndex,
|
screenIndex: params.screenIndex,
|
||||||
durationMs: params.durationMs,
|
durationMs: params.durationMs,
|
||||||
fps: params.fps,
|
fps: params.fps,
|
||||||
@ -291,6 +301,13 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func mainActorServices() async -> any MacNodeRuntimeMainActorServices {
|
||||||
|
if let cachedMainActorServices { return cachedMainActorServices }
|
||||||
|
let services = await self.makeMainActorServices()
|
||||||
|
self.cachedMainActorServices = services
|
||||||
|
return services
|
||||||
|
}
|
||||||
|
|
||||||
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
try await self.ensureA2UIHost()
|
try await self.ensureA2UIHost()
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
import ClawdbotKit
|
||||||
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||||
|
func recordScreen(
|
||||||
|
screenIndex: Int?,
|
||||||
|
durationMs: Int?,
|
||||||
|
fps: Double?,
|
||||||
|
includeAudio: Bool?,
|
||||||
|
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||||
|
|
||||||
|
func locationAuthorizationStatus() -> CLAuthorizationStatus
|
||||||
|
func locationAccuracyAuthorization() -> CLAccuracyAuthorization
|
||||||
|
func currentLocation(
|
||||||
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
|
maxAgeMs: Int?,
|
||||||
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
|
||||||
|
private let screenRecorder = ScreenRecordService()
|
||||||
|
private let locationService = MacNodeLocationService()
|
||||||
|
|
||||||
|
func recordScreen(
|
||||||
|
screenIndex: Int?,
|
||||||
|
durationMs: Int?,
|
||||||
|
fps: Double?,
|
||||||
|
includeAudio: Bool?,
|
||||||
|
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||||
|
{
|
||||||
|
try await self.screenRecorder.record(
|
||||||
|
screenIndex: screenIndex,
|
||||||
|
durationMs: durationMs,
|
||||||
|
fps: fps,
|
||||||
|
includeAudio: includeAudio,
|
||||||
|
outPath: outPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationAuthorizationStatus() -> CLAuthorizationStatus {
|
||||||
|
self.locationService.authorizationStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
|
||||||
|
self.locationService.accuracyAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentLocation(
|
||||||
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
|
maxAgeMs: Int?,
|
||||||
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
{
|
||||||
|
try await self.locationService.currentLocation(
|
||||||
|
desiredAccuracy: desiredAccuracy,
|
||||||
|
maxAgeMs: maxAgeMs,
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,11 +2,12 @@ import Foundation
|
|||||||
|
|
||||||
extension ProcessInfo {
|
extension ProcessInfo {
|
||||||
var isPreview: Bool {
|
var isPreview: Bool {
|
||||||
self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false }
|
||||||
|
return String(cString: raw) == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
var isNixMode: Bool {
|
var isNixMode: Bool {
|
||||||
if self.environment["CLAWDBOT_NIX_MODE"] == "1" { return true }
|
if let raw = getenv("CLAWDBOT_NIX_MODE"), String(cString: raw) == "1" { return true }
|
||||||
return UserDefaults.standard.bool(forKey: "clawdbot.nixMode")
|
return UserDefaults.standard.bool(forKey: "clawdbot.nixMode")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,8 +41,8 @@ final class RemotePortTunnel {
|
|||||||
static func create(
|
static func create(
|
||||||
remotePort: Int,
|
remotePort: Int,
|
||||||
preferredLocalPort: UInt16? = nil,
|
preferredLocalPort: UInt16? = nil,
|
||||||
allowRemoteUrlOverride: Bool = true
|
allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
|
||||||
) async throws -> RemotePortTunnel {
|
{
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
|
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
|
|||||||
@ -29,8 +29,8 @@ struct UsageRow: Identifiable {
|
|||||||
let error: String?
|
let error: String?
|
||||||
|
|
||||||
var titleText: String {
|
var titleText: String {
|
||||||
if let plan, !plan.isEmpty { return "\(displayName) (\(plan))" }
|
if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" }
|
||||||
return displayName
|
return self.displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
var remainingPercent: Int? {
|
var remainingPercent: Int? {
|
||||||
@ -107,4 +107,3 @@ enum UsageLoader {
|
|||||||
return try JSONDecoder().decode(GatewayUsageSummary.self, from: data)
|
return try JSONDecoder().decode(GatewayUsageSummary.self, from: data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ struct UsageMenuLabelView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
Text(row.titleText)
|
Text(self.row.titleText)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(self.primaryTextColor)
|
.foregroundStyle(self.primaryTextColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -30,7 +30,7 @@ struct UsageMenuLabelView: View {
|
|||||||
|
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
|
|
||||||
Text(row.detailText())
|
Text(self.row.detailText())
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundStyle(self.secondaryTextColor)
|
.foregroundStyle(self.secondaryTextColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -43,4 +43,3 @@ struct UsageMenuLabelView: View {
|
|||||||
.padding(.trailing, self.paddingTrailing)
|
.padding(.trailing, self.paddingTrailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@ -53,7 +54,7 @@ final class WorkActivityStore {
|
|||||||
phase: String,
|
phase: String,
|
||||||
name: String?,
|
name: String?,
|
||||||
meta: String?,
|
meta: String?,
|
||||||
args: [String: AnyCodable]?)
|
args: [String: ClawdbotProtocol.AnyCodable]?)
|
||||||
{
|
{
|
||||||
let toolKind = Self.mapToolKind(name)
|
let toolKind = Self.mapToolKind(name)
|
||||||
let label = Self.buildLabel(name: name, meta: meta, args: args)
|
let label = Self.buildLabel(name: name, meta: meta, args: args)
|
||||||
@ -211,7 +212,7 @@ final class WorkActivityStore {
|
|||||||
private static func buildLabel(
|
private static func buildLabel(
|
||||||
name: String?,
|
name: String?,
|
||||||
meta: String?,
|
meta: String?,
|
||||||
args: [String: AnyCodable]?) -> String
|
args: [String: ClawdbotProtocol.AnyCodable]?) -> String
|
||||||
{
|
{
|
||||||
let wrappedArgs = self.wrapToolArgs(args)
|
let wrappedArgs = self.wrapToolArgs(args)
|
||||||
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
|
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
|
||||||
@ -221,17 +222,17 @@ final class WorkActivityStore {
|
|||||||
return display.label
|
return display.label
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdbotKit.AnyCodable? {
|
private static func wrapToolArgs(_ args: [String: ClawdbotProtocol.AnyCodable]?) -> ClawdbotKit.AnyCodable? {
|
||||||
guard let args else { return nil }
|
guard let args else { return nil }
|
||||||
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
|
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
|
||||||
return ClawdbotKit.AnyCodable(converted)
|
return ClawdbotKit.AnyCodable(converted)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapJSONValue(_ value: Any) -> Any {
|
private static func unwrapJSONValue(_ value: Any) -> Any {
|
||||||
if let dict = value as? [String: AnyCodable] {
|
if let dict = value as? [String: ClawdbotProtocol.AnyCodable] {
|
||||||
return dict.mapValues { self.unwrapJSONValue($0.value) }
|
return dict.mapValues { self.unwrapJSONValue($0.value) }
|
||||||
}
|
}
|
||||||
if let array = value as? [AnyCodable] {
|
if let array = value as? [ClawdbotProtocol.AnyCodable] {
|
||||||
return array.map { self.unwrapJSONValue($0.value) }
|
return array.map { self.unwrapJSONValue($0.value) }
|
||||||
}
|
}
|
||||||
if let dict = value as? [String: Any] {
|
if let dict = value as? [String: Any] {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
import ClawdbotProtocol
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite
|
@Suite
|
||||||
@ -15,7 +16,7 @@ struct AgentEventStoreTests {
|
|||||||
seq: 1,
|
seq: 1,
|
||||||
stream: "test",
|
stream: "test",
|
||||||
ts: 0,
|
ts: 0,
|
||||||
data: [:] as [String: AnyCodable],
|
data: [:] as [String: ClawdbotProtocol.AnyCodable],
|
||||||
summary: nil))
|
summary: nil))
|
||||||
#expect(store.events.count == 1)
|
#expect(store.events.count == 1)
|
||||||
|
|
||||||
@ -32,7 +33,7 @@ struct AgentEventStoreTests {
|
|||||||
seq: i,
|
seq: i,
|
||||||
stream: "test",
|
stream: "test",
|
||||||
ts: Double(i),
|
ts: Double(i),
|
||||||
data: [:] as [String: AnyCodable],
|
data: [:] as [String: ClawdbotProtocol.AnyCodable],
|
||||||
summary: nil))
|
summary: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import Testing
|
|||||||
"null": NSNull(),
|
"null": NSNull(),
|
||||||
]
|
]
|
||||||
|
|
||||||
let data = try JSONEncoder().encode(Clawdbot.AnyCodable(payload))
|
let data = try JSONEncoder().encode(ClawdbotProtocol.AnyCodable(payload))
|
||||||
let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
|
||||||
#expect(obj["tags"] as? [String] == ["node", "ios"])
|
#expect(obj["tags"] as? [String] == ["node", "ios"])
|
||||||
|
|||||||
@ -2,29 +2,29 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite
|
@Suite(.serialized)
|
||||||
struct ClawdbotConfigFileTests {
|
struct ClawdbotConfigFileTests {
|
||||||
@Test
|
@Test
|
||||||
func configPathRespectsEnvOverride() {
|
func configPathRespectsEnvOverride() async {
|
||||||
let override = FileManager.default.temporaryDirectory
|
let override = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
.path
|
.path
|
||||||
|
|
||||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
|
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
|
||||||
#expect(ClawdbotConfigFile.url().path == override)
|
#expect(ClawdbotConfigFile.url().path == override)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test
|
@Test
|
||||||
func remoteGatewayPortParsesAndMatchesHost() {
|
func remoteGatewayPortParsesAndMatchesHost() async {
|
||||||
let override = FileManager.default.temporaryDirectory
|
let override = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
.path
|
.path
|
||||||
|
|
||||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
|
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
|
||||||
ClawdbotConfigFile.saveDict([
|
ClawdbotConfigFile.saveDict([
|
||||||
"gateway": [
|
"gateway": [
|
||||||
"remote": [
|
"remote": [
|
||||||
@ -41,13 +41,13 @@ struct ClawdbotConfigFileTests {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test
|
@Test
|
||||||
func setRemoteGatewayUrlPreservesScheme() {
|
func setRemoteGatewayUrlPreservesScheme() async {
|
||||||
let override = FileManager.default.temporaryDirectory
|
let override = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
.path
|
.path
|
||||||
|
|
||||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
|
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
|
||||||
ClawdbotConfigFile.saveDict([
|
ClawdbotConfigFile.saveDict([
|
||||||
"gateway": [
|
"gateway": [
|
||||||
"remote": [
|
"remote": [
|
||||||
@ -63,33 +63,17 @@ struct ClawdbotConfigFileTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func stateDirOverrideSetsConfigPath() {
|
func stateDirOverrideSetsConfigPath() async {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
|
||||||
.path
|
.path
|
||||||
|
|
||||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: nil) {
|
await TestIsolation.withEnvValues([
|
||||||
self.withEnv("CLAWDBOT_STATE_DIR", value: dir) {
|
"CLAWDBOT_CONFIG_PATH": nil,
|
||||||
#expect(ClawdbotConfigFile.stateDirURL().path == dir)
|
"CLAWDBOT_STATE_DIR": dir,
|
||||||
#expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json")
|
]) {
|
||||||
}
|
#expect(ClawdbotConfigFile.stateDirURL().path == dir)
|
||||||
|
#expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func withEnv(_ key: String, value: String?, _ body: () -> Void) {
|
|
||||||
let previous = ProcessInfo.processInfo.environment[key]
|
|
||||||
if let value {
|
|
||||||
setenv(key, value, 1)
|
|
||||||
} else {
|
|
||||||
unsetenv(key)
|
|
||||||
}
|
|
||||||
defer {
|
|
||||||
if let previous {
|
|
||||||
setenv(key, previous, 1)
|
|
||||||
} else {
|
|
||||||
unsetenv(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,7 +35,7 @@ struct CronJobEditorSmokeTests {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 120,
|
timeoutSeconds: 120,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "whatsapp",
|
provider: "whatsapp",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||||
|
|||||||
@ -31,7 +31,7 @@ struct CronModelsTests {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 15,
|
timeoutSeconds: 15,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "whatsapp",
|
provider: "whatsapp",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: false)
|
bestEffortDeliver: false)
|
||||||
let data = try JSONEncoder().encode(payload)
|
let data = try JSONEncoder().encode(payload)
|
||||||
|
|||||||
@ -170,7 +170,7 @@ import Testing
|
|||||||
let url = URL(string: "ws://example.invalid")!
|
let url = URL(string: "ws://example.invalid")!
|
||||||
let cfg = ConfigSource(token: nil)
|
let cfg = ConfigSource(token: nil)
|
||||||
let conn = GatewayConnection(
|
let conn = GatewayConnection(
|
||||||
configProvider: { (url, cfg.snapshotToken()) },
|
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||||
sessionBox: WebSocketSessionBox(session: session))
|
sessionBox: WebSocketSessionBox(session: session))
|
||||||
|
|
||||||
_ = try await conn.request(method: "status", params: nil)
|
_ = try await conn.request(method: "status", params: nil)
|
||||||
@ -186,7 +186,7 @@ import Testing
|
|||||||
let url = URL(string: "ws://example.invalid")!
|
let url = URL(string: "ws://example.invalid")!
|
||||||
let cfg = ConfigSource(token: "a")
|
let cfg = ConfigSource(token: "a")
|
||||||
let conn = GatewayConnection(
|
let conn = GatewayConnection(
|
||||||
configProvider: { (url, cfg.snapshotToken()) },
|
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||||
sessionBox: WebSocketSessionBox(session: session))
|
sessionBox: WebSocketSessionBox(session: session))
|
||||||
|
|
||||||
_ = try await conn.request(method: "status", params: nil)
|
_ = try await conn.request(method: "status", params: nil)
|
||||||
@ -203,7 +203,7 @@ import Testing
|
|||||||
let url = URL(string: "ws://example.invalid")!
|
let url = URL(string: "ws://example.invalid")!
|
||||||
let cfg = ConfigSource(token: nil)
|
let cfg = ConfigSource(token: nil)
|
||||||
let conn = GatewayConnection(
|
let conn = GatewayConnection(
|
||||||
configProvider: { (url, cfg.snapshotToken()) },
|
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||||
sessionBox: WebSocketSessionBox(session: session))
|
sessionBox: WebSocketSessionBox(session: session))
|
||||||
|
|
||||||
async let r1: Data = conn.request(method: "status", params: nil)
|
async let r1: Data = conn.request(method: "status", params: nil)
|
||||||
@ -218,7 +218,7 @@ import Testing
|
|||||||
let url = URL(string: "ws://example.invalid")!
|
let url = URL(string: "ws://example.invalid")!
|
||||||
let cfg = ConfigSource(token: nil)
|
let cfg = ConfigSource(token: nil)
|
||||||
let conn = GatewayConnection(
|
let conn = GatewayConnection(
|
||||||
configProvider: { (url, cfg.snapshotToken()) },
|
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||||
sessionBox: WebSocketSessionBox(session: session))
|
sessionBox: WebSocketSessionBox(session: session))
|
||||||
|
|
||||||
_ = try await conn.request(method: "status", params: nil)
|
_ = try await conn.request(method: "status", params: nil)
|
||||||
@ -239,7 +239,7 @@ import Testing
|
|||||||
let url = URL(string: "ws://example.invalid")!
|
let url = URL(string: "ws://example.invalid")!
|
||||||
let cfg = ConfigSource(token: nil)
|
let cfg = ConfigSource(token: nil)
|
||||||
let conn = GatewayConnection(
|
let conn = GatewayConnection(
|
||||||
configProvider: { (url, cfg.snapshotToken()) },
|
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||||
sessionBox: WebSocketSessionBox(session: session))
|
sessionBox: WebSocketSessionBox(session: session))
|
||||||
|
|
||||||
let stream = await conn.subscribe(bufferingNewest: 10)
|
let stream = await conn.subscribe(bufferingNewest: 10)
|
||||||
|
|||||||
@ -19,13 +19,19 @@ import Testing
|
|||||||
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func gatewayPortDefaultsAndRespectsOverride() {
|
@Test func gatewayPortDefaultsAndRespectsOverride() async {
|
||||||
let defaultPort = GatewayEnvironment.gatewayPort()
|
let configPath = TestIsolation.tempConfigPath()
|
||||||
#expect(defaultPort == 18789)
|
await TestIsolation.withIsolatedState(
|
||||||
|
env: ["CLAWDBOT_CONFIG_PATH": configPath],
|
||||||
|
defaults: ["gatewayPort": nil])
|
||||||
|
{
|
||||||
|
let defaultPort = GatewayEnvironment.gatewayPort()
|
||||||
|
#expect(defaultPort == 18789)
|
||||||
|
|
||||||
UserDefaults.standard.set(19999, forKey: "gatewayPort")
|
UserDefaults.standard.set(19999, forKey: "gatewayPort")
|
||||||
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
|
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
|
||||||
#expect(GatewayEnvironment.gatewayPort() == 19999)
|
#expect(GatewayEnvironment.gatewayPort() == 19999)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
@Suite struct GatewayLaunchAgentManagerTests {
|
||||||
|
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
|
let plist: [String: Any] = [
|
||||||
|
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
||||||
|
"EnvironmentVariables": [
|
||||||
|
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||||
|
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||||
|
#expect(snapshot.port == 18789)
|
||||||
|
#expect(snapshot.bind == "loopback")
|
||||||
|
#expect(snapshot.token == "secret")
|
||||||
|
#expect(snapshot.password == "pw")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
|
let plist: [String: Any] = [
|
||||||
|
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
||||||
|
]
|
||||||
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||||
|
#expect(snapshot.port == 18789)
|
||||||
|
#expect(snapshot.bind == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
import ClawdbotProtocol
|
||||||
|
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@ -23,7 +24,7 @@ struct LowCoverageHelperTests {
|
|||||||
#expect(dict["list"]?.arrayValue?.count == 2)
|
#expect(dict["list"]?.arrayValue?.count == 2)
|
||||||
|
|
||||||
let foundation = any.foundationValue as? [String: Any]
|
let foundation = any.foundationValue as? [String: Any]
|
||||||
#expect(foundation?["title"] as? String == "Hello")
|
#expect((foundation?["title"] as? String) == "Hello")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func attributedStringStripsForegroundColor() {
|
@Test func attributedStringStripsForegroundColor() {
|
||||||
@ -92,34 +93,22 @@ struct LowCoverageHelperTests {
|
|||||||
_ = PresenceReporter._testPrimaryIPv4Address()
|
_ = PresenceReporter._testPrimaryIPv4Address()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func gatewayLaunchAgentHelpers() {
|
@Test func gatewayLaunchAgentHelpers() async throws {
|
||||||
let keyBind = "CLAWDBOT_GATEWAY_BIND"
|
await TestIsolation.withEnvValues(
|
||||||
let keyToken = "CLAWDBOT_GATEWAY_TOKEN"
|
[
|
||||||
let previousBind = ProcessInfo.processInfo.environment[keyBind]
|
"CLAWDBOT_GATEWAY_BIND": "Lan",
|
||||||
let previousToken = ProcessInfo.processInfo.environment[keyToken]
|
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||||
defer {
|
])
|
||||||
if let previousBind {
|
{
|
||||||
setenv(keyBind, previousBind, 1)
|
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
||||||
} else {
|
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
||||||
unsetenv(keyBind)
|
#expect(
|
||||||
}
|
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
||||||
if let previousToken {
|
"a&b<c>"'")
|
||||||
setenv(keyToken, previousToken, 1)
|
|
||||||
} else {
|
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
|
||||||
unsetenv(keyToken)
|
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setenv(keyBind, "Lan", 1)
|
|
||||||
setenv(keyToken, " secret ", 1)
|
|
||||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
|
||||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
|
||||||
#expect(
|
|
||||||
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
|
||||||
"a&b<c>"'")
|
|
||||||
|
|
||||||
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
|
|
||||||
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func portGuardianParsesListenersAndBuildsReports() {
|
@Test func portGuardianParsesListenersAndBuildsReports() {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Testing
|
import Testing
|
||||||
|
import ClawdbotProtocol
|
||||||
|
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct MacNodeRuntimeTests {
|
struct MacNodeRuntimeTests {
|
||||||
@Test func handleInvokeRejectsUnknownCommand() async {
|
@Test func handleInvokeRejectsUnknownCommand() async {
|
||||||
let runtime = MacNodeRuntime()
|
let runtime = MacNodeRuntime()
|
||||||
@ -31,21 +31,58 @@ struct MacNodeRuntimeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func handleInvokeCameraListRequiresEnabledCamera() async {
|
@Test func handleInvokeCameraListRequiresEnabledCamera() async {
|
||||||
let defaults = UserDefaults.standard
|
await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) {
|
||||||
let previous = defaults.object(forKey: cameraEnabledKey)
|
let runtime = MacNodeRuntime()
|
||||||
defaults.set(false, forKey: cameraEnabledKey)
|
let response = await runtime.handleInvoke(
|
||||||
defer {
|
BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
|
||||||
if let previous {
|
#expect(response.ok == false)
|
||||||
defaults.set(previous, forKey: cameraEnabledKey)
|
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
|
||||||
} else {
|
}
|
||||||
defaults.removeObject(forKey: cameraEnabledKey)
|
}
|
||||||
|
|
||||||
|
@Test func handleInvokeScreenRecordUsesInjectedServices() async throws {
|
||||||
|
@MainActor
|
||||||
|
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
|
||||||
|
func recordScreen(
|
||||||
|
screenIndex: Int?,
|
||||||
|
durationMs: Int?,
|
||||||
|
fps: Double?,
|
||||||
|
includeAudio: Bool?,
|
||||||
|
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||||
|
{
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4")
|
||||||
|
try Data("ok".utf8).write(to: url)
|
||||||
|
return (path: url.path, hasAudio: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationAuthorizationStatus() -> CLAuthorizationStatus { .authorizedAlways }
|
||||||
|
func locationAccuracyAuthorization() -> CLAccuracyAuthorization { .fullAccuracy }
|
||||||
|
func currentLocation(
|
||||||
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
|
maxAgeMs: Int?,
|
||||||
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
{
|
||||||
|
CLLocation(latitude: 0, longitude: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let runtime = MacNodeRuntime()
|
let services = await MainActor.run { FakeMainActorServices() }
|
||||||
|
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||||
|
|
||||||
|
let params = MacNodeScreenRecordParams(durationMs: 250)
|
||||||
|
let json = String(data: try JSONEncoder().encode(params), encoding: .utf8)
|
||||||
let response = await runtime.handleInvoke(
|
let response = await runtime.handleInvoke(
|
||||||
BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
|
BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json))
|
||||||
#expect(response.ok == false)
|
#expect(response.ok == true)
|
||||||
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
|
let payloadJSON = try #require(response.payloadJSON)
|
||||||
|
|
||||||
|
struct Payload: Decodable {
|
||||||
|
var format: String
|
||||||
|
var base64: String
|
||||||
|
}
|
||||||
|
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
|
||||||
|
#expect(payload.format == "mp4")
|
||||||
|
#expect(!payload.base64.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ struct MenuSessionsInjectorTests {
|
|||||||
key: "main",
|
key: "main",
|
||||||
kind: .direct,
|
kind: .direct,
|
||||||
displayName: nil,
|
displayName: nil,
|
||||||
surface: nil,
|
provider: nil,
|
||||||
subject: nil,
|
subject: nil,
|
||||||
room: nil,
|
room: nil,
|
||||||
space: nil,
|
space: nil,
|
||||||
@ -47,7 +47,7 @@ struct MenuSessionsInjectorTests {
|
|||||||
key: "discord:group:alpha",
|
key: "discord:group:alpha",
|
||||||
kind: .group,
|
kind: .group,
|
||||||
displayName: nil,
|
displayName: nil,
|
||||||
surface: nil,
|
provider: nil,
|
||||||
subject: nil,
|
subject: nil,
|
||||||
room: nil,
|
room: nil,
|
||||||
space: nil,
|
space: nil,
|
||||||
|
|||||||
@ -28,7 +28,7 @@ struct SessionDataTests {
|
|||||||
key: "user@example.com",
|
key: "user@example.com",
|
||||||
kind: .direct,
|
kind: .direct,
|
||||||
displayName: nil,
|
displayName: nil,
|
||||||
surface: nil,
|
provider: nil,
|
||||||
subject: nil,
|
subject: nil,
|
||||||
room: nil,
|
room: nil,
|
||||||
space: nil,
|
space: nil,
|
||||||
|
|||||||
@ -45,7 +45,7 @@ struct SettingsViewSmokeTests {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 30,
|
timeoutSeconds: 30,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "sms",
|
provider: "sms",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Testing
|
import Testing
|
||||||
|
import ClawdbotProtocol
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
|
|||||||
116
apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
Normal file
116
apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor TestIsolationLock {
|
||||||
|
static let shared = TestIsolationLock()
|
||||||
|
|
||||||
|
private var locked = false
|
||||||
|
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
func acquire() async {
|
||||||
|
if !self.locked {
|
||||||
|
self.locked = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await withCheckedContinuation { cont in
|
||||||
|
self.waiters.append(cont)
|
||||||
|
}
|
||||||
|
// `unlock()` resumed us; lock is now held for this caller.
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
if self.waiters.isEmpty {
|
||||||
|
self.locked = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let next = self.waiters.removeFirst()
|
||||||
|
next.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum TestIsolation {
|
||||||
|
static func withIsolatedState<T>(
|
||||||
|
env: [String: String?] = [:],
|
||||||
|
defaults: [String: Any?] = [:],
|
||||||
|
_ body: () async throws -> T) async rethrows -> T
|
||||||
|
{
|
||||||
|
await TestIsolationLock.shared.acquire()
|
||||||
|
var previousEnv: [String: String?] = [:]
|
||||||
|
for (key, value) in env {
|
||||||
|
previousEnv[key] = getenv(key).map { String(cString: $0) }
|
||||||
|
if let value {
|
||||||
|
setenv(key, value, 1)
|
||||||
|
} else {
|
||||||
|
unsetenv(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let userDefaults = UserDefaults.standard
|
||||||
|
var previousDefaults: [String: Any?] = [:]
|
||||||
|
for (key, value) in defaults {
|
||||||
|
previousDefaults[key] = userDefaults.object(forKey: key)
|
||||||
|
if let value {
|
||||||
|
userDefaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
userDefaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await body()
|
||||||
|
for (key, value) in previousDefaults {
|
||||||
|
if let value {
|
||||||
|
userDefaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
userDefaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, value) in previousEnv {
|
||||||
|
if let value {
|
||||||
|
setenv(key, value, 1)
|
||||||
|
} else {
|
||||||
|
unsetenv(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await TestIsolationLock.shared.release()
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
for (key, value) in previousDefaults {
|
||||||
|
if let value {
|
||||||
|
userDefaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
userDefaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, value) in previousEnv {
|
||||||
|
if let value {
|
||||||
|
setenv(key, value, 1)
|
||||||
|
} else {
|
||||||
|
unsetenv(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await TestIsolationLock.shared.release()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func withEnvValues<T>(
|
||||||
|
_ values: [String: String?],
|
||||||
|
_ body: () async throws -> T) async rethrows -> T
|
||||||
|
{
|
||||||
|
try await Self.withIsolatedState(env: values, defaults: [:], body)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func withUserDefaultsValues<T>(
|
||||||
|
_ values: [String: Any?],
|
||||||
|
_ body: () async throws -> T) async rethrows -> T
|
||||||
|
{
|
||||||
|
try await Self.withIsolatedState(env: [:], defaults: values, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func tempConfigPath() -> String {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
|
||||||
|
.path
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,6 @@ import Testing
|
|||||||
#expect(opts.thinking == "low")
|
#expect(opts.thinking == "low")
|
||||||
#expect(opts.deliver == true)
|
#expect(opts.deliver == true)
|
||||||
#expect(opts.to == nil)
|
#expect(opts.to == nil)
|
||||||
#expect(opts.channel == .last)
|
#expect(opts.provider == .last)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
import ClawdbotProtocol
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite
|
@Suite
|
||||||
|
|||||||
@ -84,6 +84,52 @@
|
|||||||
box-shadow: 0 12px 0 -8px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 12px 0 -8px rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.showcase-link {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-preview {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 100%;
|
||||||
|
width: min(420px, 80vw);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in oklab, var(--panel) 92%, transparent);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--frame-border) 30%, transparent);
|
||||||
|
box-shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.55);
|
||||||
|
transform: translate(-50%, 10px) scale(0.98);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 20;
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--frame-border) 25%, transparent);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-link:hover .showcase-preview,
|
||||||
|
.showcase-link:focus-within .showcase-preview {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translate(-50%, 6px) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.showcase-preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.markdown code {
|
.markdown code {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
|
|||||||
BIN
docs/assets/showcase/gohome-grafana.png
Normal file
BIN
docs/assets/showcase/gohome-grafana.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 379 KiB |
11
docs/assets/showcase/padel-cli.svg
Normal file
11
docs/assets/showcase/padel-cli.svg
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="420" viewBox="0 0 800 420" role="img" aria-label="padel-cli availability output">
|
||||||
|
<rect width="800" height="420" rx="24" fill="#0b0f14" />
|
||||||
|
<rect x="24" y="24" width="752" height="372" rx="18" fill="#0f172a" stroke="#263246" stroke-width="2" />
|
||||||
|
<text x="48" y="72" fill="#9ca3af" font-size="18" font-family="Fragment Mono, ui-monospace, SFMono-Regular, Menlo, monospace">
|
||||||
|
<tspan x="48" dy="0">$ padel search --location "Barcelona" --date 2026-01-08 --time 18:00-22:00</tspan>
|
||||||
|
<tspan x="48" dy="30" fill="#e5e7eb">Available courts (3):</tspan>
|
||||||
|
<tspan x="48" dy="28" fill="#e5e7eb">- Vall d'Hebron 19:00 Court 2 (90m) EUR 34</tspan>
|
||||||
|
<tspan x="48" dy="28" fill="#e5e7eb">- Badalona 20:30 Court 1 (60m) EUR 28</tspan>
|
||||||
|
<tspan x="48" dy="28" fill="#e5e7eb">- Gracia 21:00 Court 4 (90m) EUR 36</tspan>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 897 B |
BIN
docs/assets/showcase/padel-screenshot.jpg
Normal file
BIN
docs/assets/showcase/padel-screenshot.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/assets/showcase/roborock-screenshot.jpg
Normal file
BIN
docs/assets/showcase/roborock-screenshot.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
13
docs/assets/showcase/roborock-status.svg
Normal file
13
docs/assets/showcase/roborock-status.svg
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="420" viewBox="0 0 800 420" role="img" aria-label="GoHome Roborock status output">
|
||||||
|
<rect width="800" height="420" rx="24" fill="#0b0f14" />
|
||||||
|
<rect x="24" y="24" width="752" height="372" rx="18" fill="#111827" stroke="#263246" stroke-width="2" />
|
||||||
|
<text x="48" y="72" fill="#9ca3af" font-size="18" font-family="Fragment Mono, ui-monospace, SFMono-Regular, Menlo, monospace">
|
||||||
|
<tspan x="48" dy="0">$ gohome roborock status --device "Living Room"</tspan>
|
||||||
|
<tspan x="48" dy="30" fill="#e5e7eb">Device: Roborock Q Revo</tspan>
|
||||||
|
<tspan x="48" dy="28" fill="#e5e7eb">State: cleaning (zone)</tspan>
|
||||||
|
<tspan x="48" dy="28" fill="#e5e7eb">Battery: 78%</tspan>
|
||||||
|
<tspan x="48" dy="28" fill="#e5e7eb">Dustbin: 42%</tspan>
|
||||||
|
<tspan x="48" dy="28" fill="#e5e7eb">Water tank: 61%</tspan>
|
||||||
|
<tspan x="48" dy="28" fill="#e5e7eb">Last clean: 2026-01-06 19:42</tspan>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 947 B |
BIN
docs/assets/showcase/xuezh-pronunciation.jpeg
Normal file
BIN
docs/assets/showcase/xuezh-pronunciation.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@ -176,10 +176,13 @@ Interactive configuration wizard (models, providers, skills, gateway).
|
|||||||
Audit and modernize the local configuration.
|
Audit and modernize the local configuration.
|
||||||
|
|
||||||
### `doctor`
|
### `doctor`
|
||||||
Health checks + quick fixes.
|
Health checks + quick fixes (config + gateway + legacy services).
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- `--no-workspace-suggestions`: disable workspace memory hints.
|
- `--no-workspace-suggestions`: disable workspace memory hints.
|
||||||
|
- `--yes`: accept defaults without prompting (headless).
|
||||||
|
- `--non-interactive`: skip prompts; apply safe migrations only.
|
||||||
|
- `--deep`: scan system services for extra gateway installs.
|
||||||
|
|
||||||
## Auth + provider helpers
|
## Auth + provider helpers
|
||||||
|
|
||||||
@ -362,6 +365,25 @@ Options:
|
|||||||
### `gateway-daemon`
|
### `gateway-daemon`
|
||||||
Run the Gateway as a long-lived daemon (same options as `gateway`, minus `--allow-unconfigured` and `--force`).
|
Run the Gateway as a long-lived daemon (same options as `gateway`, minus `--allow-unconfigured` and `--force`).
|
||||||
|
|
||||||
|
### `daemon`
|
||||||
|
Manage the Gateway service (launchd/systemd/schtasks).
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
- `daemon status` (probes the Gateway RPC by default)
|
||||||
|
- `daemon install` (service install)
|
||||||
|
- `daemon uninstall`
|
||||||
|
- `daemon start`
|
||||||
|
- `daemon stop`
|
||||||
|
- `daemon restart`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `daemon status` uses the same URL/token defaults as `gateway status` unless you pass `--url/--token/--password`.
|
||||||
|
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
||||||
|
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans).
|
||||||
|
- `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
|
||||||
|
- `daemon install` options: `--port`, `--runtime`, `--token`.
|
||||||
|
- `gateway install|uninstall|start|stop|restart` remain as service aliases; `daemon` is the dedicated manager.
|
||||||
|
|
||||||
### `gateway <subcommand>`
|
### `gateway <subcommand>`
|
||||||
Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
|
Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
|
||||||
|
|
||||||
@ -372,8 +394,12 @@ Subcommands:
|
|||||||
- `gateway wake --text <text> [--mode now|next-heartbeat]`
|
- `gateway wake --text <text> [--mode now|next-heartbeat]`
|
||||||
- `gateway send --to <jidOrPhone> --message <text> [--media-url <url>] [--gif-playback] [--idempotency-key <key>]`
|
- `gateway send --to <jidOrPhone> --message <text> [--media-url <url>] [--gif-playback] [--idempotency-key <key>]`
|
||||||
- `gateway agent --message <text> [--to <jidOrPhone>] [--session-id <id>] [--thinking <level>] [--deliver] [--timeout-seconds <n>] [--idempotency-key <key>]`
|
- `gateway agent --message <text> [--to <jidOrPhone>] [--session-id <id>] [--thinking <level>] [--deliver] [--timeout-seconds <n>] [--idempotency-key <key>]`
|
||||||
|
- `gateway install`
|
||||||
|
- `gateway uninstall`
|
||||||
|
- `gateway start`
|
||||||
- `gateway stop`
|
- `gateway stop`
|
||||||
- `gateway restart`
|
- `gateway restart`
|
||||||
|
- `gateway daemon status` (alias for `clawdbot daemon status`)
|
||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
|
|||||||
43
docs/concepts/compaction.md
Normal file
43
docs/concepts/compaction.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
summary: "Context window + compaction: how Clawdbot keeps sessions under model limits"
|
||||||
|
read_when:
|
||||||
|
- You want to understand auto-compaction and /compact
|
||||||
|
- You are debugging long sessions hitting context limits
|
||||||
|
---
|
||||||
|
# Context Window & Compaction
|
||||||
|
|
||||||
|
Every model has a **context window** (max tokens it can see). Long-running chats accumulate messages and tool results; once the window is tight, Clawdbot **compacts** older history to stay within limits.
|
||||||
|
|
||||||
|
## What compaction is
|
||||||
|
Compaction **summarizes older conversation** into a compact summary entry and keeps recent messages intact. The summary is stored in the session history, so future requests use:
|
||||||
|
- The compaction summary
|
||||||
|
- Recent messages after the compaction point
|
||||||
|
|
||||||
|
Compaction **persists** in the session’s JSONL history.
|
||||||
|
|
||||||
|
## Auto-compaction (default on)
|
||||||
|
When a session nears or exceeds the model’s context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context.
|
||||||
|
|
||||||
|
You’ll see:
|
||||||
|
- `🧹 Auto-compaction complete` in verbose mode
|
||||||
|
- `/status` showing `🧹 Compactions: <count>`
|
||||||
|
|
||||||
|
## Manual compaction
|
||||||
|
Use `/compact` (optionally with instructions) to force a compaction pass:
|
||||||
|
```
|
||||||
|
/compact Focus on decisions and open questions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context window source
|
||||||
|
Context window is model-specific. Clawdbot uses the model definition from the configured provider catalog to determine limits.
|
||||||
|
|
||||||
|
## Compaction vs pruning
|
||||||
|
- **Compaction**: summarises and **persists** in JSONL.
|
||||||
|
- **Session pruning**: trims old **tool results** only, **in-memory**, per request.
|
||||||
|
|
||||||
|
See [/concepts/session-pruning](/concepts/session-pruning) for pruning details.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
- Use `/compact` when sessions feel stale or context is bloated.
|
||||||
|
- Large tool outputs are already truncated; pruning can further reduce tool-result buildup.
|
||||||
|
- If you need a fresh slate, `/new` or `/reset` starts a new session id.
|
||||||
@ -71,3 +71,4 @@ Only the owner number (from `whatsapp.allowFrom`, or the bot’s own E.164 when
|
|||||||
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
|
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
|
||||||
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
|
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
|
||||||
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.clawdbot/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
|
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.clawdbot/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
|
||||||
|
- Typing indicators in groups follow `agent.typingMode` (default: `message` when unmentioned).
|
||||||
|
|||||||
@ -12,6 +12,23 @@ See [`docs/model-failover.md`](/concepts/model-failover) for how auth profiles r
|
|||||||
Goal: give clear model visibility + control (configured vs available), plus scan tooling
|
Goal: give clear model visibility + control (configured vs available), plus scan tooling
|
||||||
that prefers tool-call + image-capable models and maintains ordered fallbacks.
|
that prefers tool-call + image-capable models and maintains ordered fallbacks.
|
||||||
|
|
||||||
|
## How Clawdbot models work (quick explainer)
|
||||||
|
|
||||||
|
Clawdbot selects models in this order:
|
||||||
|
1) The configured **primary** model (`agent.model.primary`).
|
||||||
|
2) If it fails, fallbacks in `agent.model.fallbacks` (in order).
|
||||||
|
3) Auth failover happens **inside** the provider first (see [/concepts/model-failover](/concepts/model-failover)).
|
||||||
|
|
||||||
|
Key pieces:
|
||||||
|
- `provider/model` is the canonical model id (e.g. `anthropic/claude-opus-4-5`).
|
||||||
|
- `agent.models` is the **allowlist/catalog** of models Clawdbot can use, with optional aliases.
|
||||||
|
- `agent.imageModel` is only used when the primary model **can’t** accept images.
|
||||||
|
- `models.providers` lets you add custom providers + models (written to `models.json`).
|
||||||
|
- `/model <id>` switches the active model for the current session; `/model list` shows what’s allowed.
|
||||||
|
|
||||||
|
Related:
|
||||||
|
- Context limits are model-specific; long sessions may trigger compaction. See [/concepts/compaction](/concepts/compaction).
|
||||||
|
|
||||||
## Model recommendations
|
## Model recommendations
|
||||||
|
|
||||||
Through testing, we’ve found [Claude Opus 4.5](https://www.anthropic.com/claude/opus) is the most useful general-purpose model for anything coding-related. We suggest [GPT-5.2-Codex](https://developers.openai.com/codex/models) for coding and sub-agents. For personal assistant work, nothing comes close to Opus. If you’re going all-in on Claude, we recommend the [Claude Max $200 subscription](https://www.anthropic.com/pricing/).
|
Through testing, we’ve found [Claude Opus 4.5](https://www.anthropic.com/claude/opus) is the most useful general-purpose model for anything coding-related. We suggest [GPT-5.2-Codex](https://developers.openai.com/codex/models) for coding and sub-agents. For personal assistant work, nothing comes close to Opus. If you’re going all-in on Claude, we recommend the [Claude Max $200 subscription](https://www.anthropic.com/pricing/).
|
||||||
@ -45,6 +62,33 @@ Anecdotal notes from the Discord thread on January 4–5, 2026. Treat as “what
|
|||||||
|
|
||||||
See [/cli](/cli) for the full command tree and CLI flags.
|
See [/cli](/cli) for the full command tree and CLI flags.
|
||||||
|
|
||||||
|
### CLI output (list + status)
|
||||||
|
|
||||||
|
`clawdbot models list` (default) prints a table with these columns:
|
||||||
|
- `Model`: `provider/model` key (truncated in TTY).
|
||||||
|
- `Input`: `text` or `text+image`.
|
||||||
|
- `Ctx`: context window in K tokens (from the model registry).
|
||||||
|
- `Local`: `yes/no` when the provider base URL is local.
|
||||||
|
- `Auth`: `yes/no` when the provider has usable auth.
|
||||||
|
- `Tags`: origin + role hints.
|
||||||
|
|
||||||
|
Common tags:
|
||||||
|
- `default` — resolved default model.
|
||||||
|
- `fallback#N` — `agent.model.fallbacks` order.
|
||||||
|
- `image` — `agent.imageModel.primary`.
|
||||||
|
- `img-fallback#N` — `agent.imageModel.fallbacks` order.
|
||||||
|
- `configured` — present in `agent.models`.
|
||||||
|
- `alias:<name>` — alias from `agent.models.*.alias`.
|
||||||
|
- `missing` — referenced in config but not found in the registry.
|
||||||
|
|
||||||
|
Output formats:
|
||||||
|
- `--plain`: prints only `provider/model` keys (one per line).
|
||||||
|
- `--json`: `{ count, models: [{ key, name, input, contextWindow, local, available, tags, missing }] }`.
|
||||||
|
|
||||||
|
`clawdbot models status` prints the resolved defaults, fallbacks, image model, aliases,
|
||||||
|
and an **Auth overview** section showing which providers have profiles/env/models.json keys.
|
||||||
|
`--plain` prints the resolved default model only; `--json` returns a structured object for tooling.
|
||||||
|
|
||||||
## Config changes
|
## Config changes
|
||||||
|
|
||||||
- `agent.models` (configured model catalog + aliases).
|
- `agent.models` (configured model catalog + aliases).
|
||||||
|
|||||||
58
docs/concepts/retry.md
Normal file
58
docs/concepts/retry.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
summary: "Retry policy for outbound provider calls"
|
||||||
|
read_when:
|
||||||
|
- Updating provider retry behavior or defaults
|
||||||
|
- Debugging provider send errors or rate limits
|
||||||
|
---
|
||||||
|
# Retry policy
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Retry per HTTP request, not per multi-step flow.
|
||||||
|
- Preserve ordering by retrying only the current step.
|
||||||
|
- Avoid duplicating non-idempotent operations.
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
- Attempts: 3
|
||||||
|
- Max delay cap: 30000 ms
|
||||||
|
- Jitter: 0.1 (10 percent)
|
||||||
|
- Provider defaults:
|
||||||
|
- Telegram min delay: 400 ms
|
||||||
|
- Discord min delay: 500 ms
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
### Discord
|
||||||
|
- Retries only on rate-limit errors (HTTP 429).
|
||||||
|
- Uses Discord `retry_after` when available, otherwise exponential backoff.
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
- Retries on transient errors (429, timeout, connect/reset/closed, temporarily unavailable).
|
||||||
|
- Uses `retry_after` when available, otherwise exponential backoff.
|
||||||
|
- Markdown parse errors are not retried; they fall back to plain text.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
Set retry policy per provider in `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
telegram: {
|
||||||
|
retry: {
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 400,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
discord: {
|
||||||
|
retry: {
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 500,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Retries apply per request (message send, media upload, reaction, poll, sticker).
|
||||||
|
- Composite flows do not retry completed steps.
|
||||||
92
docs/concepts/session-pruning.md
Normal file
92
docs/concepts/session-pruning.md
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
summary: "Session pruning: opt-in tool-result trimming to reduce context bloat"
|
||||||
|
read_when:
|
||||||
|
- You want to reduce LLM context growth from tool outputs
|
||||||
|
- You are tuning agent.contextPruning
|
||||||
|
---
|
||||||
|
# Session Pruning
|
||||||
|
|
||||||
|
Session pruning trims **old tool results** from the in-memory context right before each LLM call. It is **opt-in** and does **not** rewrite the on-disk session history (`*.jsonl`).
|
||||||
|
|
||||||
|
## When it runs
|
||||||
|
- Before each LLM request (context hook).
|
||||||
|
- Only affects the messages sent to the model for that request.
|
||||||
|
|
||||||
|
## What can be pruned
|
||||||
|
- Only `toolResult` messages.
|
||||||
|
- User + assistant messages are **never** modified.
|
||||||
|
- The last `keepLastAssistants` assistant messages are protected; tool results after that cutoff are not pruned.
|
||||||
|
- If there aren’t enough assistant messages to establish the cutoff, pruning is skipped.
|
||||||
|
- Tool results containing **image blocks** are skipped (never trimmed/cleared).
|
||||||
|
|
||||||
|
## Context window estimation
|
||||||
|
Pruning uses an estimated context window (chars ≈ tokens × 4). The window size is resolved in this order:
|
||||||
|
1) Model definition `contextWindow` (from the model registry).
|
||||||
|
2) `models.providers.*.models[].contextWindow` override.
|
||||||
|
3) `agent.contextTokens`.
|
||||||
|
4) Default `200000` tokens.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
### adaptive
|
||||||
|
- If estimated context ratio ≥ `softTrimRatio`: soft-trim oversized tool results.
|
||||||
|
- If still ≥ `hardClearRatio` **and** prunable tool text ≥ `minPrunableToolChars`: hard-clear oldest eligible tool results.
|
||||||
|
|
||||||
|
### aggressive
|
||||||
|
- Always hard-clears eligible tool results before the cutoff.
|
||||||
|
- Ignores `hardClear.enabled` (always clears when eligible).
|
||||||
|
|
||||||
|
## Soft vs hard pruning
|
||||||
|
- **Soft-trim**: only for oversized tool results.
|
||||||
|
- Keeps head + tail, inserts `...`, and appends a note with the original size.
|
||||||
|
- Skips results with image blocks.
|
||||||
|
- **Hard-clear**: replaces the entire tool result with `hardClear.placeholder`.
|
||||||
|
|
||||||
|
## Tool selection
|
||||||
|
- `tools.allow` / `tools.deny` support `*` wildcards.
|
||||||
|
- Deny wins.
|
||||||
|
- Empty allow list => all tools allowed.
|
||||||
|
|
||||||
|
## Interaction with other limits
|
||||||
|
- Built-in tools already truncate their own output; session pruning is an extra layer that prevents long-running chats from accumulating too much tool output in the model context.
|
||||||
|
- Compaction is separate: compaction summarizes and persists, pruning is transient per request. See [/concepts/compaction](/concepts/compaction).
|
||||||
|
|
||||||
|
## Defaults (when enabled)
|
||||||
|
- `keepLastAssistants`: `3`
|
||||||
|
- `softTrimRatio`: `0.3`
|
||||||
|
- `hardClearRatio`: `0.5`
|
||||||
|
- `minPrunableToolChars`: `50000`
|
||||||
|
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }`
|
||||||
|
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
Minimal (adaptive):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
contextPruning: { mode: "adaptive" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggressive:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
contextPruning: { mode: "aggressive" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restrict pruning to specific tools:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
contextPruning: {
|
||||||
|
mode: "adaptive",
|
||||||
|
tools: { allow: ["bash", "read"], deny: ["*image*"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See config reference: [Gateway Configuration](/gateway/configuration)
|
||||||
@ -127,14 +127,15 @@ Parameters:
|
|||||||
- `task` (required)
|
- `task` (required)
|
||||||
- `label?` (optional; used for logs/UI)
|
- `label?` (optional; used for logs/UI)
|
||||||
- `model?` (optional; overrides the sub-agent model; invalid values error)
|
- `model?` (optional; overrides the sub-agent model; invalid values error)
|
||||||
- `timeoutSeconds?` (optional; omit for long-running jobs; if set, Clawdbot aborts the sub-agent when the timeout elapses)
|
- `runTimeoutSeconds?` (default 0; when set, aborts the sub-agent run after N seconds)
|
||||||
- `cleanup?` (`delete|keep`, default `keep`)
|
- `cleanup?` (`delete|keep`, default `keep`)
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
- Starts a new `agent:<id>:subagent:<uuid>` session with `deliver: false`.
|
- Starts a new `agent:<agentId>:subagent:<uuid>` session with `deliver: false`.
|
||||||
- Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`).
|
- Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`).
|
||||||
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
|
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
|
||||||
- After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat provider.
|
- Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately.
|
||||||
|
- After completion, Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat provider.
|
||||||
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
||||||
- Sub-agent sessions are auto-archived after `agent.subagents.archiveAfterMinutes` (default: 60).
|
- Sub-agent sessions are auto-archived after `agent.subagents.archiveAfterMinutes` (default: 60).
|
||||||
- Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost).
|
- Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost).
|
||||||
|
|||||||
@ -16,11 +16,15 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl
|
|||||||
## Where state lives
|
## Where state lives
|
||||||
- On the **gateway host**:
|
- On the **gateway host**:
|
||||||
- Store file: `~/.clawdbot/agents/<agentId>/sessions/sessions.json` (per agent).
|
- Store file: `~/.clawdbot/agents/<agentId>/sessions/sessions.json` (per agent).
|
||||||
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (one file per session id).
|
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
|
||||||
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
||||||
- Group entries may include `displayName`, `provider`, `subject`, `room`, and `space` to label sessions in UIs.
|
- Group entries may include `displayName`, `provider`, `subject`, `room`, and `space` to label sessions in UIs.
|
||||||
- Clawdbot does **not** read legacy Pi/Tau session folders.
|
- Clawdbot does **not** read legacy Pi/Tau session folders.
|
||||||
|
|
||||||
|
## Session pruning (optional)
|
||||||
|
Clawdbot can trim **old tool results** from the in-memory context right before LLM calls (opt-in).
|
||||||
|
This does **not** rewrite JSONL history. See [/concepts/session-pruning](/concepts/session-pruning).
|
||||||
|
|
||||||
## Mapping transports → session keys
|
## Mapping transports → session keys
|
||||||
- Direct chats collapse to the per-agent primary key: `agent:<agentId>:<mainKey>`.
|
- Direct chats collapse to the per-agent primary key: `agent:<agentId>:<mainKey>`.
|
||||||
- Multiple phone numbers and providers can map to the same agent main key; they act as transports into one conversation.
|
- Multiple phone numbers and providers can map to the same agent main key; they act as transports into one conversation.
|
||||||
@ -81,7 +85,7 @@ Send these as standalone messages so they register.
|
|||||||
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
|
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
|
||||||
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
|
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
|
||||||
- Send `/stop` as a standalone message to abort the current run.
|
- Send `/stop` as a standalone message to abort the current run.
|
||||||
- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space.
|
- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction).
|
||||||
- JSONL transcripts can be opened directly to review full turns.
|
- JSONL transcripts can be opened directly to review full turns.
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|||||||
59
docs/concepts/typing-indicators.md
Normal file
59
docs/concepts/typing-indicators.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
summary: "When Clawdbot shows typing indicators and how to tune them"
|
||||||
|
read_when:
|
||||||
|
- Changing typing indicator behavior or defaults
|
||||||
|
---
|
||||||
|
# Typing indicators
|
||||||
|
|
||||||
|
Typing indicators are sent to the chat provider while a run is active. Use
|
||||||
|
`agent.typingMode` to control **when** typing starts and `typingIntervalSeconds`
|
||||||
|
to control **how often** it refreshes.
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
When `agent.typingMode` is **unset**, Clawdbot keeps the legacy behavior:
|
||||||
|
- **Direct chats**: typing starts immediately once the model loop begins.
|
||||||
|
- **Group chats with a mention**: typing starts immediately.
|
||||||
|
- **Group chats without a mention**: typing starts only when message text begins streaming.
|
||||||
|
- **Heartbeat runs**: typing is disabled.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
Set `agent.typingMode` to one of:
|
||||||
|
- `never` — no typing indicator, ever.
|
||||||
|
- `instant` — start typing **as soon as the model loop begins**, even if the run
|
||||||
|
later returns only the silent reply token.
|
||||||
|
- `thinking` — start typing on the **first reasoning delta** (requires
|
||||||
|
`reasoningLevel: "stream"` for the run).
|
||||||
|
- `message` — start typing on the **first non-silent text delta** (ignores
|
||||||
|
the `NO_REPLY` silent token).
|
||||||
|
|
||||||
|
Order of “how early it fires”:
|
||||||
|
`never` → `message` → `thinking` → `instant`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
typingMode: "thinking",
|
||||||
|
typingIntervalSeconds: 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can override mode or cadence per session:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
session: {
|
||||||
|
typingMode: "message",
|
||||||
|
typingIntervalSeconds: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `message` mode won’t show typing for silent-only replies (e.g. the `NO_REPLY`
|
||||||
|
token used to suppress output).
|
||||||
|
- `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`).
|
||||||
|
If the model doesn’t emit reasoning deltas, typing won’t start.
|
||||||
|
- Heartbeats never show typing, regardless of mode.
|
||||||
|
- `typingIntervalSeconds` controls the **refresh cadence**, not the start time.
|
||||||
|
The default is 6 seconds.
|
||||||
@ -545,13 +545,16 @@
|
|||||||
"concepts/agent-loop",
|
"concepts/agent-loop",
|
||||||
"concepts/agent-workspace",
|
"concepts/agent-workspace",
|
||||||
"concepts/multi-agent",
|
"concepts/multi-agent",
|
||||||
|
"concepts/compaction",
|
||||||
"concepts/session",
|
"concepts/session",
|
||||||
|
"concepts/session-pruning",
|
||||||
"concepts/sessions",
|
"concepts/sessions",
|
||||||
"concepts/session-tool",
|
"concepts/session-tool",
|
||||||
"concepts/presence",
|
"concepts/presence",
|
||||||
"concepts/provider-routing",
|
"concepts/provider-routing",
|
||||||
"concepts/groups",
|
"concepts/groups",
|
||||||
"concepts/group-messages",
|
"concepts/group-messages",
|
||||||
|
"concepts/typing-indicators",
|
||||||
"concepts/queue",
|
"concepts/queue",
|
||||||
"concepts/models",
|
"concepts/models",
|
||||||
"concepts/model-failover",
|
"concepts/model-failover",
|
||||||
@ -644,7 +647,17 @@
|
|||||||
{
|
{
|
||||||
"group": "Platforms",
|
"group": "Platforms",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"platforms",
|
||||||
"platforms/macos",
|
"platforms/macos",
|
||||||
|
"platforms/ios",
|
||||||
|
"platforms/android",
|
||||||
|
"platforms/windows",
|
||||||
|
"platforms/linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "macOS Companion App",
|
||||||
|
"pages": [
|
||||||
"platforms/mac/dev-setup",
|
"platforms/mac/dev-setup",
|
||||||
"platforms/mac/menu-bar",
|
"platforms/mac/menu-bar",
|
||||||
"platforms/mac/voicewake",
|
"platforms/mac/voicewake",
|
||||||
@ -662,11 +675,7 @@
|
|||||||
"platforms/mac/bun",
|
"platforms/mac/bun",
|
||||||
"platforms/mac/xpc",
|
"platforms/mac/xpc",
|
||||||
"platforms/mac/skills",
|
"platforms/mac/skills",
|
||||||
"platforms/mac/peekaboo",
|
"platforms/mac/peekaboo"
|
||||||
"platforms/ios",
|
|
||||||
"platforms/android",
|
|
||||||
"platforms/windows",
|
|
||||||
"platforms/linux"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -24,6 +24,7 @@ Behavior:
|
|||||||
- Foreground runs return output directly.
|
- Foreground runs return output directly.
|
||||||
- When backgrounded (explicit or timeout), the tool returns `status: "running"` + `sessionId` and a short tail.
|
- When backgrounded (explicit or timeout), the tool returns `status: "running"` + `sessionId` and a short tail.
|
||||||
- Output is kept in memory until the session is polled or cleared.
|
- Output is kept in memory until the session is polled or cleared.
|
||||||
|
- If the `process` tool is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
|
||||||
|
|
||||||
Environment overrides:
|
Environment overrides:
|
||||||
- `PI_BASH_YIELD_MS`: default yield (ms)
|
- `PI_BASH_YIELD_MS`: default yield (ms)
|
||||||
@ -50,6 +51,7 @@ Notes:
|
|||||||
- Only backgrounded sessions are listed/persisted in memory.
|
- Only backgrounded sessions are listed/persisted in memory.
|
||||||
- Sessions are lost on process restart (no disk persistence).
|
- Sessions are lost on process restart (no disk persistence).
|
||||||
- Session logs are only saved to chat history if you run `process poll/log` and the tool result is recorded.
|
- Session logs are only saved to chat history if you run `process poll/log` and the tool result is recorded.
|
||||||
|
- `process` is scoped per agent; it only sees sessions started by that agent.
|
||||||
- `process list` includes a derived `name` (command verb + target) for quick scans.
|
- `process list` includes a derived `name` (command verb + target) for quick scans.
|
||||||
- `process log` uses line-based `offset`/`limit` (omit `offset` to grab the last N lines).
|
- `process log` uses line-based `offset`/`limit` (omit `offset` to grab the last N lines).
|
||||||
|
|
||||||
|
|||||||
@ -340,7 +340,7 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o
|
|||||||
- `scope`: `"session"` | `"agent"` | `"shared"`
|
- `scope`: `"session"` | `"agent"` | `"shared"`
|
||||||
- `workspaceRoot`: custom sandbox workspace root
|
- `workspaceRoot`: custom sandbox workspace root
|
||||||
- `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`)
|
- `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`)
|
||||||
- `tools`: per-agent tool restrictions (applied before sandbox tool policy).
|
- `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy).
|
||||||
- `allow`: array of allowed tool names
|
- `allow`: array of allowed tool names
|
||||||
- `deny`: array of denied tool names (deny wins)
|
- `deny`: array of denied tool names (deny wins)
|
||||||
- `routing.bindings[]`: routes inbound messages to an `agentId`.
|
- `routing.bindings[]`: routes inbound messages to an `agentId`.
|
||||||
@ -359,6 +359,75 @@ Deterministic match order:
|
|||||||
|
|
||||||
Within each match tier, the first matching entry in `routing.bindings` wins.
|
Within each match tier, the first matching entry in `routing.bindings` wins.
|
||||||
|
|
||||||
|
#### Per-agent access profiles (multi-agent)
|
||||||
|
|
||||||
|
Each agent can carry its own sandbox + tool policy. Use this to mix access
|
||||||
|
levels in one gateway:
|
||||||
|
- **Full access** (personal agent)
|
||||||
|
- **Read-only** tools + workspace
|
||||||
|
- **No filesystem access** (messaging/session tools only)
|
||||||
|
|
||||||
|
See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for precedence and
|
||||||
|
additional examples.
|
||||||
|
|
||||||
|
Full access (no sandbox):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
routing: {
|
||||||
|
agents: {
|
||||||
|
personal: {
|
||||||
|
workspace: "~/clawd-personal",
|
||||||
|
sandbox: { mode: "off" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Read-only tools + read-only workspace:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
routing: {
|
||||||
|
agents: {
|
||||||
|
family: {
|
||||||
|
workspace: "~/clawd-family",
|
||||||
|
sandbox: {
|
||||||
|
mode: "all",
|
||||||
|
scope: "agent",
|
||||||
|
workspaceAccess: "ro"
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
allow: ["read", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
|
||||||
|
deny: ["write", "edit", "bash", "process", "browser"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No filesystem access (messaging/session tools enabled):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
routing: {
|
||||||
|
agents: {
|
||||||
|
public: {
|
||||||
|
workspace: "~/clawd-public",
|
||||||
|
sandbox: {
|
||||||
|
mode: "all",
|
||||||
|
scope: "agent",
|
||||||
|
workspaceAccess: "none"
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "whatsapp", "telegram", "slack", "discord", "gateway"],
|
||||||
|
deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Example: two WhatsApp accounts → two agents:
|
Example: two WhatsApp accounts → two agents:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@ -493,6 +562,12 @@ Set `telegram.enabled: false` to disable automatic startup.
|
|||||||
streamMode: "partial", // off | partial | block (draft streaming)
|
streamMode: "partial", // off | partial | block (draft streaming)
|
||||||
actions: { reactions: true }, // tool action gates (false disables)
|
actions: { reactions: true }, // tool action gates (false disables)
|
||||||
mediaMaxMb: 5,
|
mediaMaxMb: 5,
|
||||||
|
retry: { // outbound retry policy
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 400,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
|
},
|
||||||
proxy: "socks5://localhost:9050",
|
proxy: "socks5://localhost:9050",
|
||||||
webhookUrl: "https://example.com/telegram-webhook",
|
webhookUrl: "https://example.com/telegram-webhook",
|
||||||
webhookSecret: "secret",
|
webhookSecret: "secret",
|
||||||
@ -505,6 +580,7 @@ Draft streaming notes:
|
|||||||
- Uses Telegram `sendMessageDraft` (draft bubble, not a real message).
|
- Uses Telegram `sendMessageDraft` (draft bubble, not a real message).
|
||||||
- Requires **private chat topics** (message_thread_id in DMs; bot has topics enabled).
|
- Requires **private chat topics** (message_thread_id in DMs; bot has topics enabled).
|
||||||
- `/reasoning stream` streams reasoning into the draft, then sends the final answer.
|
- `/reasoning stream` streams reasoning into the draft, then sends the final answer.
|
||||||
|
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
### `discord` (bot transport)
|
### `discord` (bot transport)
|
||||||
|
|
||||||
@ -559,7 +635,13 @@ Configure the Discord bot by setting the bot token and optional gating:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
historyLimit: 20 // include last N guild messages as context
|
historyLimit: 20, // include last N guild messages as context
|
||||||
|
retry: { // outbound retry policy
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 500,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -571,6 +653,7 @@ Reaction notification modes:
|
|||||||
- `own`: reactions on the bot's own messages (default).
|
- `own`: reactions on the bot's own messages (default).
|
||||||
- `all`: all reactions on all messages.
|
- `all`: all reactions on all messages.
|
||||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||||
|
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
### `slack` (socket mode)
|
### `slack` (socket mode)
|
||||||
|
|
||||||
@ -813,6 +896,88 @@ If you configure the same alias name (case-insensitive) yourself, your value win
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `agent.contextPruning` (opt-in tool-result pruning)
|
||||||
|
|
||||||
|
`agent.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM.
|
||||||
|
It does **not** modify the session history on disk (`*.jsonl` remains complete).
|
||||||
|
|
||||||
|
This is intended to reduce token usage for chatty agents that accumulate large tool outputs over time.
|
||||||
|
|
||||||
|
High level:
|
||||||
|
- Never touches user/assistant messages.
|
||||||
|
- Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned).
|
||||||
|
- Protects the bootstrap prefix (nothing before the first user message is pruned).
|
||||||
|
- Modes:
|
||||||
|
- `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`.
|
||||||
|
Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and**
|
||||||
|
there’s enough prunable tool-result bulk (`minPrunableToolChars`).
|
||||||
|
- `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks).
|
||||||
|
|
||||||
|
Soft vs hard pruning (what changes in the context sent to the LLM):
|
||||||
|
- **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle.
|
||||||
|
- Before: `toolResult("…very long output…")`
|
||||||
|
- After: `toolResult("HEAD…\n...\n…TAIL\n\n[Tool result trimmed: …]")`
|
||||||
|
- **Hard-clear**: replaces the entire tool result with the placeholder.
|
||||||
|
- Before: `toolResult("…very long output…")`
|
||||||
|
- After: `toolResult("[Old tool result content cleared]")`
|
||||||
|
|
||||||
|
Notes / current limitations:
|
||||||
|
- Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
|
||||||
|
- The estimated “context ratio” is based on **characters** (approximate), not exact tokens.
|
||||||
|
- If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped.
|
||||||
|
- In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`).
|
||||||
|
|
||||||
|
Example (minimal):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
contextPruning: {
|
||||||
|
mode: "adaptive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults (when `mode` is `"adaptive"` or `"aggressive"`):
|
||||||
|
- `keepLastAssistants`: `3`
|
||||||
|
- `softTrimRatio`: `0.3` (adaptive only)
|
||||||
|
- `hardClearRatio`: `0.5` (adaptive only)
|
||||||
|
- `minPrunableToolChars`: `50000` (adaptive only)
|
||||||
|
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` (adaptive only)
|
||||||
|
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
||||||
|
|
||||||
|
Example (aggressive, minimal):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
contextPruning: {
|
||||||
|
mode: "aggressive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (adaptive tuned):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
contextPruning: {
|
||||||
|
mode: "adaptive",
|
||||||
|
keepLastAssistants: 3,
|
||||||
|
softTrimRatio: 0.3,
|
||||||
|
hardClearRatio: 0.5,
|
||||||
|
minPrunableToolChars: 50000,
|
||||||
|
softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 },
|
||||||
|
hardClear: { enabled: true, placeholder: "[Old tool result content cleared]" },
|
||||||
|
// Optional: restrict pruning to specific tools (deny wins; supports "*" wildcards)
|
||||||
|
tools: { deny: ["browser", "canvas"] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
|
||||||
|
|
||||||
Block streaming:
|
Block streaming:
|
||||||
- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on).
|
- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on).
|
||||||
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end).
|
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end).
|
||||||
@ -828,6 +993,14 @@ Block streaming:
|
|||||||
```
|
```
|
||||||
See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
|
See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
|
||||||
|
|
||||||
|
Typing indicators:
|
||||||
|
- `agent.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to
|
||||||
|
`instant` for direct chats / mentions and `message` for unmentioned group chats.
|
||||||
|
- `session.typingMode`: per-session override for the mode.
|
||||||
|
- `agent.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s).
|
||||||
|
- `session.typingIntervalSeconds`: per-session override for the refresh interval.
|
||||||
|
See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details.
|
||||||
|
|
||||||
`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
|
`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
|
||||||
Aliases come from `agent.models.*.alias` (e.g. `Opus`).
|
Aliases come from `agent.models.*.alias` (e.g. `Opus`).
|
||||||
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
|
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
|
||||||
|
|||||||
@ -15,6 +15,7 @@ read_when:
|
|||||||
- Migrates legacy `~/.clawdis/clawdis.json` when no Clawdbot config exists.
|
- Migrates legacy `~/.clawdis/clawdis.json` when no Clawdbot config exists.
|
||||||
- Checks sandbox Docker images when sandboxing is enabled (offers to build or switch to legacy names).
|
- Checks sandbox Docker images when sandboxing is enabled (offers to build or switch to legacy names).
|
||||||
- Detects legacy Clawdis services (launchd/systemd; legacy schtasks for native Windows) and offers to migrate them.
|
- Detects legacy Clawdis services (launchd/systemd; legacy schtasks for native Windows) and offers to migrate them.
|
||||||
|
- Detects other gateway-like services and prints cleanup hints (optional deep scan for system services).
|
||||||
- On Linux, checks if systemd user lingering is enabled and can enable it (required to keep the Gateway alive after logout).
|
- On Linux, checks if systemd user lingering is enabled and can enable it (required to keep the Gateway alive after logout).
|
||||||
- Migrates legacy on-disk state layouts (sessions, agentDir, provider auth dirs) into the current per-agent/per-account structure.
|
- Migrates legacy on-disk state layouts (sessions, agentDir, provider auth dirs) into the current per-agent/per-account structure.
|
||||||
|
|
||||||
@ -70,6 +71,12 @@ clawdbot doctor --non-interactive
|
|||||||
|
|
||||||
Run without prompts and only apply safe migrations (config normalization + on-disk state moves). Skips restart/service/sandbox actions that require human confirmation.
|
Run without prompts and only apply safe migrations (config normalization + on-disk state moves). Skips restart/service/sandbox actions that require human confirmation.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot doctor --deep
|
||||||
|
```
|
||||||
|
|
||||||
|
Scan system services for extra gateway installs (launchd/systemd/schtasks).
|
||||||
|
|
||||||
If you want to review changes before writing, open the config file first:
|
If you want to review changes before writing, open the config file first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -157,6 +157,25 @@ See also: [`docs/presence.md`](/concepts/presence) for how presence is produced/
|
|||||||
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
|
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
|
||||||
- LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
|
- LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
|
||||||
|
|
||||||
|
## Daemon management (CLI)
|
||||||
|
|
||||||
|
Use the CLI daemon manager for install/start/stop/restart/status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot daemon status
|
||||||
|
clawdbot daemon install
|
||||||
|
clawdbot daemon stop
|
||||||
|
clawdbot daemon restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `daemon status` probes the Gateway RPC by default (same URL/token defaults as `gateway status`).
|
||||||
|
- `daemon status --deep` adds system-level scans (LaunchDaemons/system units).
|
||||||
|
- `gateway install|uninstall|start|stop|restart` remain supported as aliases; `daemon` is the dedicated manager.
|
||||||
|
- `gateway daemon status` is an alias for `clawdbot daemon status`.
|
||||||
|
- If other gateway-like services are detected, the CLI warns. We recommend **one gateway per machine**; one gateway can host multiple agents.
|
||||||
|
- Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
|
||||||
|
|
||||||
Bundled mac app:
|
Bundled mac app:
|
||||||
- Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`.
|
- Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`.
|
||||||
- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
|
- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
|
||||||
|
|||||||
@ -128,12 +128,13 @@ Consider running your AI on a separate phone number from your personal one:
|
|||||||
- Personal number: Your conversations stay private
|
- Personal number: Your conversations stay private
|
||||||
- Bot number: AI handles these, with appropriate boundaries
|
- Bot number: AI handles these, with appropriate boundaries
|
||||||
|
|
||||||
### 4. Read-Only Mode (Future)
|
### 4. Read-Only Mode (Today, via sandbox + tools)
|
||||||
|
|
||||||
We're considering a `readOnlyMode` flag that prevents the AI from:
|
You can already build a read-only profile by combining:
|
||||||
- Writing files outside a sandbox
|
- `sandbox.workspaceAccess: "ro"` (or `"none"` for no workspace access)
|
||||||
- Executing shell commands
|
- tool allow/deny lists that block `write`, `edit`, `bash`, `process`, etc.
|
||||||
- Sending messages
|
|
||||||
|
We may add a single `readOnlyMode` flag later to simplify this configuration.
|
||||||
|
|
||||||
## Sandboxing (recommended)
|
## Sandboxing (recommended)
|
||||||
|
|
||||||
@ -153,6 +154,79 @@ Also consider agent workspace access inside the sandbox:
|
|||||||
|
|
||||||
Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers.
|
Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers.
|
||||||
|
|
||||||
|
## Per-agent access profiles (multi-agent)
|
||||||
|
|
||||||
|
With multi-agent routing, each agent can have its own sandbox + tool policy:
|
||||||
|
use this to give **full access**, **read-only**, or **no access** per agent.
|
||||||
|
See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for full details
|
||||||
|
and precedence rules.
|
||||||
|
|
||||||
|
Common use cases:
|
||||||
|
- Personal agent: full access, no sandbox
|
||||||
|
- Family/work agent: sandboxed + read-only tools
|
||||||
|
- Public agent: sandboxed + no filesystem/shell tools
|
||||||
|
|
||||||
|
### Example: full access (no sandbox)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
routing: {
|
||||||
|
agents: {
|
||||||
|
personal: {
|
||||||
|
workspace: "~/clawd-personal",
|
||||||
|
sandbox: { mode: "off" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: read-only tools + read-only workspace
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
routing: {
|
||||||
|
agents: {
|
||||||
|
family: {
|
||||||
|
workspace: "~/clawd-family",
|
||||||
|
sandbox: {
|
||||||
|
mode: "all",
|
||||||
|
scope: "agent",
|
||||||
|
workspaceAccess: "ro"
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
allow: ["read"],
|
||||||
|
deny: ["write", "edit", "bash", "process", "browser"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: no filesystem/shell access (provider messaging allowed)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
routing: {
|
||||||
|
agents: {
|
||||||
|
public: {
|
||||||
|
workspace: "~/clawd-public",
|
||||||
|
sandbox: {
|
||||||
|
mode: "all",
|
||||||
|
scope: "agent",
|
||||||
|
workspaceAccess: "none"
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "whatsapp", "telegram", "slack", "discord", "gateway"],
|
||||||
|
deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## What to Tell Your AI
|
## What to Tell Your AI
|
||||||
|
|
||||||
Include security guidelines in your agent's system prompt:
|
Include security guidelines in your agent's system prompt:
|
||||||
|
|||||||
@ -86,6 +86,18 @@ container. The gateway stays on your host, but the tool execution is isolated:
|
|||||||
Warning: `scope: "shared"` disables cross-session isolation. All sessions share
|
Warning: `scope: "shared"` disables cross-session isolation. All sessions share
|
||||||
one container and one workspace.
|
one container and one workspace.
|
||||||
|
|
||||||
|
### Per-agent sandbox profiles (multi-agent)
|
||||||
|
|
||||||
|
If you use multi-agent routing, each agent can override sandbox + tool settings:
|
||||||
|
`routing.agents[id].sandbox` and `routing.agents[id].tools`. This lets you run
|
||||||
|
mixed access levels in one gateway:
|
||||||
|
- Full access (personal agent)
|
||||||
|
- Read-only tools + read-only workspace (family/work agent)
|
||||||
|
- No filesystem/shell tools (public agent)
|
||||||
|
|
||||||
|
See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for examples,
|
||||||
|
precedence, and troubleshooting.
|
||||||
|
|
||||||
### Default behavior
|
### Default behavior
|
||||||
|
|
||||||
- Image: `clawdbot-sandbox:bookworm-slim`
|
- Image: `clawdbot-sandbox:bookworm-slim`
|
||||||
|
|||||||
@ -97,7 +97,7 @@ Runbook + exact service labels: [Gateway runbook](/gateway)
|
|||||||
Install a known-good version:
|
Install a known-good version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i -g clawdbot@2026.1.5-3
|
npm i -g clawdbot@2026.1.7
|
||||||
```
|
```
|
||||||
|
|
||||||
Then restart + re-run doctor:
|
Then restart + re-run doctor:
|
||||||
|
|||||||
@ -8,6 +8,15 @@ read_when:
|
|||||||
|
|
||||||
# Android App (Node)
|
# Android App (Node)
|
||||||
|
|
||||||
|
## Support snapshot
|
||||||
|
- Role: companion node app (Android does not host the Gateway).
|
||||||
|
- Gateway required: yes (run it on macOS, Linux, or Windows via WSL2).
|
||||||
|
- Install: [Getting Started](/start/getting-started) + [Pairing](/gateway/pairing).
|
||||||
|
- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration).
|
||||||
|
|
||||||
|
## System control
|
||||||
|
System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gateway).
|
||||||
|
|
||||||
## Connection Runbook
|
## Connection Runbook
|
||||||
|
|
||||||
Android node app ⇄ (mDNS/NSD + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
|
Android node app ⇄ (mDNS/NSD + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
|
||||||
|
|||||||
40
docs/platforms/index.md
Normal file
40
docs/platforms/index.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
summary: "Platform support overview (Gateway + companion apps)"
|
||||||
|
read_when:
|
||||||
|
- Looking for OS support or install paths
|
||||||
|
- Deciding where to run the Gateway
|
||||||
|
---
|
||||||
|
# Platforms
|
||||||
|
|
||||||
|
Clawdbot core is written in TypeScript, so the CLI + Gateway run anywhere Node or Bun runs.
|
||||||
|
|
||||||
|
Companion apps exist for macOS (menu bar app) and mobile nodes (iOS/Android). Windows and
|
||||||
|
Linux companion apps are planned, but the core Gateway is fully supported today.
|
||||||
|
|
||||||
|
## Choose your OS
|
||||||
|
|
||||||
|
- macOS: [macOS](/platforms/macos)
|
||||||
|
- iOS: [iOS](/platforms/ios)
|
||||||
|
- Android: [Android](/platforms/android)
|
||||||
|
- Windows: [Windows](/platforms/windows)
|
||||||
|
- Linux: [Linux](/platforms/linux)
|
||||||
|
|
||||||
|
## Common links
|
||||||
|
|
||||||
|
- Install guide: [Getting Started](/start/getting-started)
|
||||||
|
- Gateway runbook: [Gateway](/gateway)
|
||||||
|
- Gateway configuration: [Configuration](/gateway/configuration)
|
||||||
|
- Service status: `clawdbot daemon status`
|
||||||
|
|
||||||
|
## Gateway service install (CLI)
|
||||||
|
|
||||||
|
Use one of these (all supported):
|
||||||
|
|
||||||
|
- Wizard (recommended): `clawdbot onboard --install-daemon`
|
||||||
|
- Direct: `clawdbot daemon install` (alias: `clawdbot gateway install`)
|
||||||
|
- Configure flow: `clawdbot configure` → select **Gateway daemon**
|
||||||
|
- Repair/migrate: `clawdbot doctor` (offers to install or fix the service)
|
||||||
|
|
||||||
|
The service target depends on OS:
|
||||||
|
- macOS: LaunchAgent (`com.clawdbot.gateway`)
|
||||||
|
- Linux/WSL2: systemd user service
|
||||||
@ -12,6 +12,15 @@ read_when:
|
|||||||
|
|
||||||
Status: prototype implemented (internal) · Date: 2025-12-13
|
Status: prototype implemented (internal) · Date: 2025-12-13
|
||||||
|
|
||||||
|
## Support snapshot
|
||||||
|
- Role: companion node app (iOS does not host the Gateway).
|
||||||
|
- Gateway required: yes (run it on macOS, Linux, or Windows via WSL2).
|
||||||
|
- Install: [Getting Started](/start/getting-started) + [Pairing](/gateway/pairing).
|
||||||
|
- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration).
|
||||||
|
|
||||||
|
## System control
|
||||||
|
System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gateway).
|
||||||
|
|
||||||
## Connection Runbook
|
## Connection Runbook
|
||||||
|
|
||||||
This is the practical “how do I connect the iOS node” guide:
|
This is the practical “how do I connect the iOS node” guide:
|
||||||
|
|||||||
@ -1,11 +1,80 @@
|
|||||||
---
|
---
|
||||||
summary: "Linux app status + contribution call"
|
summary: "Linux support + companion app status"
|
||||||
read_when:
|
read_when:
|
||||||
- Looking for Linux companion app status
|
- Looking for Linux companion app status
|
||||||
- Planning platform coverage or contributions
|
- Planning platform coverage or contributions
|
||||||
---
|
---
|
||||||
# Linux App
|
# Linux App
|
||||||
|
|
||||||
Clawdbot core is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node runs.
|
Clawdbot core is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node or Bun runs.
|
||||||
|
|
||||||
We do not have a Linux companion app yet. It is planned, and we would love contributions to make it happen.
|
We do not have a Linux companion app yet. It is planned, and we would love contributions to make it happen.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
- [Getting Started](/start/getting-started)
|
||||||
|
- [Install & updates](/install/updating)
|
||||||
|
- Optional flows: [Bun](/install/bun), [Nix](/install/nix), [Docker](/install/docker)
|
||||||
|
|
||||||
|
## Gateway
|
||||||
|
- [Gateway runbook](/gateway)
|
||||||
|
- [Configuration](/gateway/configuration)
|
||||||
|
|
||||||
|
## Gateway service install (CLI)
|
||||||
|
|
||||||
|
Use one of these:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot onboard --install-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot daemon install
|
||||||
|
```
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot gateway install
|
||||||
|
```
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot configure
|
||||||
|
```
|
||||||
|
|
||||||
|
Select **Gateway daemon** when prompted.
|
||||||
|
|
||||||
|
Repair/migrate:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
## System control (systemd user unit)
|
||||||
|
Full unit example lives in the [Gateway runbook](/gateway). Minimal setup:
|
||||||
|
|
||||||
|
Create `~/.config/systemd/user/clawdbot-gateway.service`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=Clawdbot Gateway
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/bin/clawdbot gateway --port 18789
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable it:
|
||||||
|
|
||||||
|
```
|
||||||
|
systemctl --user enable --now clawdbot-gateway.service
|
||||||
|
```
|
||||||
|
|||||||
@ -8,6 +8,23 @@ read_when:
|
|||||||
|
|
||||||
Author: steipete · Status: draft spec · Date: 2025-12-20
|
Author: steipete · Status: draft spec · Date: 2025-12-20
|
||||||
|
|
||||||
|
## Support snapshot
|
||||||
|
- Core Gateway: supported (TypeScript on Node/Bun).
|
||||||
|
- Companion app: macOS menu bar app with permissions + node bridge.
|
||||||
|
- Install: [Getting Started](/start/getting-started) or [Install & updates](/install/updating).
|
||||||
|
- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration).
|
||||||
|
|
||||||
|
## System control (launchd)
|
||||||
|
If you run the bundled macOS app, it installs a per-user LaunchAgent labeled `com.clawdbot.gateway`.
|
||||||
|
CLI-only installs can use `clawdbot onboard --install-daemon`, `clawdbot daemon install`, or `clawdbot configure` → **Gateway daemon**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl kickstart -k gui/$UID/com.clawdbot.gateway
|
||||||
|
launchctl bootout gui/$UID/com.clawdbot.gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: [Gateway runbook](/gateway) and [Bundled bun Gateway](/platforms/mac/bun).
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
- Single macOS menu-bar app named **Clawdbot** that:
|
- Single macOS menu-bar app named **Clawdbot** that:
|
||||||
- Shows native notifications for Clawdbot/clawdbot events.
|
- Shows native notifications for Clawdbot/clawdbot events.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "Windows (WSL2) setup + companion app status"
|
summary: "Windows (WSL2) support + companion app status"
|
||||||
read_when:
|
read_when:
|
||||||
- Installing Clawdbot on Windows
|
- Installing Clawdbot on Windows
|
||||||
- Looking for Windows companion app status
|
- Looking for Windows companion app status
|
||||||
@ -7,14 +7,55 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Windows (WSL2)
|
# Windows (WSL2)
|
||||||
|
|
||||||
Clawdbot runs on Windows **via WSL2** (Ubuntu recommended). WSL2 is **strongly
|
Clawdbot core is supported on Windows **via WSL2** (Ubuntu recommended). The
|
||||||
recommended**; native Windows installs are untested and more problematic. Use
|
CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
|
||||||
WSL2 and follow the Linux flow inside it.
|
Windows installs are untested and more problematic.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
- [Getting Started](/start/getting-started) (use inside WSL)
|
||||||
|
- [Install & updates](/install/updating)
|
||||||
|
- Official WSL2 guide (Microsoft): https://learn.microsoft.com/windows/wsl/install
|
||||||
|
|
||||||
|
## Gateway
|
||||||
|
- [Gateway runbook](/gateway)
|
||||||
|
- [Configuration](/gateway/configuration)
|
||||||
|
|
||||||
|
## Gateway service install (CLI)
|
||||||
|
|
||||||
|
Inside WSL2:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot onboard --install-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot daemon install
|
||||||
|
```
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot gateway install
|
||||||
|
```
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot configure
|
||||||
|
```
|
||||||
|
|
||||||
|
Select **Gateway daemon** when prompted.
|
||||||
|
|
||||||
|
Repair/migrate:
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot doctor
|
||||||
|
```
|
||||||
|
|
||||||
## How to install this correctly
|
## How to install this correctly
|
||||||
|
|
||||||
Start here (official WSL2 guide): https://learn.microsoft.com/windows/wsl/install
|
|
||||||
|
|
||||||
### 1) Install WSL2 + Ubuntu
|
### 1) Install WSL2 + Ubuntu
|
||||||
|
|
||||||
Open PowerShell (Admin):
|
Open PowerShell (Admin):
|
||||||
|
|||||||
@ -5,7 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Discord (Bot API)
|
# Discord (Bot API)
|
||||||
|
|
||||||
Updated: 2025-12-07
|
Updated: 2026-01-07
|
||||||
|
|
||||||
Status: ready for DM and guild text channels via the official Discord bot gateway.
|
Status: ready for DM and guild text channels via the official Discord bot gateway.
|
||||||
|
|
||||||
@ -122,6 +122,12 @@ Example “single server, only allow me, only allow #help”:
|
|||||||
help: { allow: true, requireMention: true }
|
help: { allow: true, requireMention: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 500,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -154,6 +160,9 @@ Notes:
|
|||||||
- Reply context is injected when a message references another message (quoted content + ids).
|
- Reply context is injected when a message references another message (quoted content + ids).
|
||||||
- Native reply threading is **off by default**; enable with `discord.replyToMode` and reply tags.
|
- Native reply threading is **off by default**; enable with `discord.replyToMode` and reply tags.
|
||||||
|
|
||||||
|
## Retry policy
|
||||||
|
Outbound Discord API calls retry on rate limits (429) using Discord `retry_after` when available, with exponential backoff and jitter. Configure via `discord.retry`. See [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@ -235,6 +244,7 @@ Ack reactions are controlled globally via `messages.ackReaction` +
|
|||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||||
|
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||||
- `reactions` (covers react + read reactions)
|
- `reactions` (covers react + read reactions)
|
||||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||||
|
|||||||
@ -37,6 +37,59 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
|
|||||||
- Inbound messages are normalized into the shared provider envelope with reply context and media placeholders.
|
- Inbound messages are normalized into the shared provider envelope with reply context and media placeholders.
|
||||||
- Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`).
|
- Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`).
|
||||||
- Replies always route back to the same Telegram chat.
|
- Replies always route back to the same Telegram chat.
|
||||||
|
- Long-polling uses grammY runner with per-chat sequencing; overall concurrency is capped by `agent.maxConcurrent`.
|
||||||
|
|
||||||
|
## Group activation modes
|
||||||
|
|
||||||
|
By default, the bot only responds to mentions in groups (`@botname` or patterns in `routing.groupChat.mentionPatterns`). To change this behavior:
|
||||||
|
|
||||||
|
### Via config (recommended)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
telegram: {
|
||||||
|
groups: {
|
||||||
|
"-1001234567890": { requireMention: false } // always respond in this group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Setting `telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted.
|
||||||
|
|
||||||
|
To allow all groups with always-respond:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
telegram: {
|
||||||
|
groups: {
|
||||||
|
"*": { requireMention: false } // all groups, always respond
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To keep mention-only for all groups (default behavior):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
telegram: {
|
||||||
|
groups: {
|
||||||
|
"*": { requireMention: true } // or omit groups entirely
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Via command (session-level)
|
||||||
|
|
||||||
|
Send in the group:
|
||||||
|
- `/activation always` - respond to all messages
|
||||||
|
- `/activation mention` - require mentions (default)
|
||||||
|
|
||||||
|
**Note:** Commands update session state only. For persistent behavior across restarts, use config.
|
||||||
|
|
||||||
|
### Getting the group chat ID
|
||||||
|
|
||||||
|
Forward any message from the group to `@userinfobot` or `@getidsbot` on Telegram to see the chat ID (negative number like `-1001234567890`).
|
||||||
|
|
||||||
## Topics (forum supergroups)
|
## Topics (forum supergroups)
|
||||||
Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
||||||
@ -50,15 +103,29 @@ Private topics (DM forum mode) also include `message_thread_id`. Clawdbot:
|
|||||||
- Uses the thread id for draft streaming + replies.
|
- Uses the thread id for draft streaming + replies.
|
||||||
|
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
|
|
||||||
|
### DM access
|
||||||
- Default: `telegram.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
|
- Default: `telegram.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
|
||||||
- Approve via:
|
- Approve via:
|
||||||
- `clawdbot pairing list --provider telegram`
|
- `clawdbot pairing list --provider telegram`
|
||||||
- `clawdbot pairing approve --provider telegram <CODE>`
|
- `clawdbot pairing approve --provider telegram <CODE>`
|
||||||
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
||||||
|
|
||||||
Group gating:
|
### Group access
|
||||||
- `telegram.groupPolicy = open | allowlist | disabled`.
|
|
||||||
- `telegram.groups` doubles as a group allowlist when set (include `"*"` to allow all).
|
Two independent controls:
|
||||||
|
|
||||||
|
**1. Which groups are allowed** (group allowlist via `telegram.groups`):
|
||||||
|
- No `groups` config = all groups allowed
|
||||||
|
- With `groups` config = only listed groups or `"*"` are allowed
|
||||||
|
- Example: `"groups": { "-1001234567890": {}, "*": {} }` allows all groups
|
||||||
|
|
||||||
|
**2. Which senders are allowed** (sender filtering via `telegram.groupPolicy`):
|
||||||
|
- `"open"` (default) = all senders in allowed groups can message
|
||||||
|
- `"allowlist"` = only senders in `telegram.groupAllowFrom` can message
|
||||||
|
- `"disabled"` = no group messages accepted at all
|
||||||
|
|
||||||
|
Most users want: `groupPolicy: "open"` + specific groups listed in `telegram.groups`
|
||||||
|
|
||||||
## Long-polling vs webhook
|
## Long-polling vs webhook
|
||||||
- Default: long-polling (no public URL required).
|
- Default: long-polling (no public URL required).
|
||||||
@ -96,6 +163,9 @@ Reasoning stream (Telegram only):
|
|||||||
- If `telegram.streamMode` is `off`, reasoning stream is disabled.
|
- If `telegram.streamMode` is `off`, reasoning stream is disabled.
|
||||||
More context: [Streaming + chunking](/concepts/streaming).
|
More context: [Streaming + chunking](/concepts/streaming).
|
||||||
|
|
||||||
|
## Retry policy
|
||||||
|
Outbound Telegram API calls retry on transient network/429 errors with exponential backoff and jitter. Configure via `telegram.retry`. See [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
## Agent tool (reactions)
|
## Agent tool (reactions)
|
||||||
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
||||||
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
||||||
@ -105,6 +175,27 @@ More context: [Streaming + chunking](/concepts/streaming).
|
|||||||
- Use a chat id (`123456789`) or a username (`@name`) as the target.
|
- Use a chat id (`123456789`) or a username (`@name`) as the target.
|
||||||
- Example: `clawdbot send --provider telegram --to 123456789 "hi"`.
|
- Example: `clawdbot send --provider telegram --to 123456789 "hi"`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Bot doesn't respond to non-mention messages in group:**
|
||||||
|
- Check if group is in `telegram.groups` with `requireMention: false`
|
||||||
|
- Or use `"*": { "requireMention": false }` to enable for all groups
|
||||||
|
- Test with `/activation always` command (requires config change to persist)
|
||||||
|
|
||||||
|
**Bot not seeing group messages at all:**
|
||||||
|
- If `telegram.groups` is set, the group must be listed or use `"*"`
|
||||||
|
- Check Privacy Settings in @BotFather → "Group Privacy" should be **OFF**
|
||||||
|
- Verify bot is actually a member (not just an admin with no read access)
|
||||||
|
- Check gateway logs: `journalctl --user -u clawdbot -f` (look for "skipping group message")
|
||||||
|
|
||||||
|
**Bot responds to mentions but not `/activation always`:**
|
||||||
|
- The `/activation` command updates session state but doesn't persist to config
|
||||||
|
- For persistent behavior, add group to `telegram.groups` with `requireMention: false`
|
||||||
|
|
||||||
|
**Commands like `/status` don't work:**
|
||||||
|
- Make sure your Telegram user ID is authorized (via pairing or `telegram.allowFrom`)
|
||||||
|
- Commands require authorization even in groups with `groupPolicy: "open"`
|
||||||
|
|
||||||
## Configuration reference (Telegram)
|
## Configuration reference (Telegram)
|
||||||
Full configuration: [Configuration](/gateway/configuration)
|
Full configuration: [Configuration](/gateway/configuration)
|
||||||
|
|
||||||
@ -128,6 +219,7 @@ Provider options:
|
|||||||
- `telegram.textChunkLimit`: outbound chunk size (chars).
|
- `telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
- `telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
- `telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
|
- `telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
- `telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
- `telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
||||||
- `telegram.webhookUrl`: enable webhook mode.
|
- `telegram.webhookUrl`: enable webhook mode.
|
||||||
- `telegram.webhookSecret`: webhook secret (optional).
|
- `telegram.webhookSecret`: webhook secret (optional).
|
||||||
|
|||||||
@ -61,6 +61,25 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
|
|||||||
- Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp <code>`; codes expire after 1 hour).
|
- Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp <code>`; codes expire after 1 hour).
|
||||||
- Open: requires `whatsapp.allowFrom` to include `"*"`.
|
- Open: requires `whatsapp.allowFrom` to include `"*"`.
|
||||||
- Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number.
|
- Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number.
|
||||||
|
|
||||||
|
### Same-phone mode (personal number)
|
||||||
|
If you run Clawdbot on your **personal WhatsApp number**, set:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"whatsapp": {
|
||||||
|
"selfChatMode": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Suppresses pairing replies for **outbound DMs** (prevents spamming contacts).
|
||||||
|
- Inbound unknown senders still follow `whatsapp.dmPolicy`.
|
||||||
|
|
||||||
|
Recommended for personal numbers:
|
||||||
|
- Set `whatsapp.dmPolicy="allowlist"` and add your number to `whatsapp.allowFrom`.
|
||||||
|
- Set `messages.responsePrefix` (for example, `[clawdbot]`) so replies are clearly labeled.
|
||||||
- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).
|
- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).
|
||||||
- `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
|
- `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
|
||||||
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
|
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
|
||||||
@ -139,6 +158,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
|
|||||||
|
|
||||||
## Config quick map
|
## Config quick map
|
||||||
- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
||||||
|
- `whatsapp.selfChatMode` (same-phone setup; suppress pairing replies for outbound DMs).
|
||||||
- `whatsapp.allowFrom` (DM allowlist).
|
- `whatsapp.allowFrom` (DM allowlist).
|
||||||
- `whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
- `whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
||||||
- `whatsapp.groupAllowFrom` (group sender allowlist).
|
- `whatsapp.groupAllowFrom` (group sender allowlist).
|
||||||
|
|||||||
@ -337,7 +337,7 @@ See [Groups](/concepts/groups) for details.
|
|||||||
|
|
||||||
### How much context can Clawdbot handle?
|
### How much context can Clawdbot handle?
|
||||||
|
|
||||||
Context window depends on the model. Clawdbot uses **autocompaction** — older conversation gets summarized to stay under the limit.
|
Context window depends on the model. Clawdbot uses **autocompaction** — older conversation gets summarized to stay under the limit. See [/concepts/compaction](/concepts/compaction).
|
||||||
|
|
||||||
Practical tips:
|
Practical tips:
|
||||||
- Keep `AGENTS.md` focused, not bloated.
|
- Keep `AGENTS.md` focused, not bloated.
|
||||||
|
|||||||
@ -36,8 +36,10 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
- [Agent loop](https://docs.clawd.bot/concepts/agent-loop)
|
- [Agent loop](https://docs.clawd.bot/concepts/agent-loop)
|
||||||
- [Streaming + chunking](/concepts/streaming)
|
- [Streaming + chunking](/concepts/streaming)
|
||||||
- [Multi-agent routing](https://docs.clawd.bot/concepts/multi-agent)
|
- [Multi-agent routing](https://docs.clawd.bot/concepts/multi-agent)
|
||||||
|
- [Compaction](https://docs.clawd.bot/concepts/compaction)
|
||||||
- [Sessions](https://docs.clawd.bot/concepts/session)
|
- [Sessions](https://docs.clawd.bot/concepts/session)
|
||||||
- [Sessions (alias)](https://docs.clawd.bot/concepts/sessions)
|
- [Sessions (alias)](https://docs.clawd.bot/concepts/sessions)
|
||||||
|
- [Session pruning](https://docs.clawd.bot/concepts/session-pruning)
|
||||||
- [Session tools](https://docs.clawd.bot/concepts/session-tool)
|
- [Session tools](https://docs.clawd.bot/concepts/session-tool)
|
||||||
- [Queue](https://docs.clawd.bot/concepts/queue)
|
- [Queue](https://docs.clawd.bot/concepts/queue)
|
||||||
- [Slash commands](https://docs.clawd.bot/tools/slash-commands)
|
- [Slash commands](https://docs.clawd.bot/tools/slash-commands)
|
||||||
@ -112,7 +114,16 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
|
|
||||||
## Platforms
|
## Platforms
|
||||||
|
|
||||||
- [macOS app overview](https://docs.clawd.bot/platforms/macos)
|
- [Platforms overview](https://docs.clawd.bot/platforms)
|
||||||
|
- [macOS](https://docs.clawd.bot/platforms/macos)
|
||||||
|
- [iOS](https://docs.clawd.bot/platforms/ios)
|
||||||
|
- [Android](https://docs.clawd.bot/platforms/android)
|
||||||
|
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
|
||||||
|
- [Linux](https://docs.clawd.bot/platforms/linux)
|
||||||
|
- [Web surfaces](https://docs.clawd.bot/web)
|
||||||
|
|
||||||
|
## macOS companion app (internals)
|
||||||
|
|
||||||
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
|
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
|
||||||
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||||
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
|
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
|
||||||
@ -131,11 +142,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
- [macOS XPC](https://docs.clawd.bot/platforms/mac/xpc)
|
- [macOS XPC](https://docs.clawd.bot/platforms/mac/xpc)
|
||||||
- [macOS skills](https://docs.clawd.bot/platforms/mac/skills)
|
- [macOS skills](https://docs.clawd.bot/platforms/mac/skills)
|
||||||
- [macOS Peekaboo plan](https://docs.clawd.bot/platforms/mac/peekaboo)
|
- [macOS Peekaboo plan](https://docs.clawd.bot/platforms/mac/peekaboo)
|
||||||
- [iOS node](https://docs.clawd.bot/platforms/ios)
|
|
||||||
- [Android node](https://docs.clawd.bot/platforms/android)
|
|
||||||
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
|
|
||||||
- [Linux app](https://docs.clawd.bot/platforms/linux)
|
|
||||||
- [Web surfaces](https://docs.clawd.bot/web)
|
|
||||||
|
|
||||||
## Workspace + templates
|
## Workspace + templates
|
||||||
|
|
||||||
|
|||||||
@ -11,9 +11,11 @@ Real projects from the community. Highlights from #showcase (Jan 2–5, 2026).
|
|||||||
- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill
|
- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill
|
||||||
- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api
|
- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api
|
||||||
- **German rail planning** — Go CLI for Deutsche Bahn; skill picks best connections given time windows and preferences. https://github.com/timkrase/dbrest-cli + https://github.com/timkrase/clawdis-skills/tree/main/db-bahn
|
- **German rail planning** — Go CLI for Deutsche Bahn; skill picks best connections given time windows and preferences. https://github.com/timkrase/dbrest-cli + https://github.com/timkrase/clawdis-skills/tree/main/db-bahn
|
||||||
|
- **padel-cli** — Playtomic availability + booking CLI with a Clawdbot plugin output. <span class="showcase-link"><a href="https://github.com/joshp123/padel-cli">github.com/joshp123/padel-cli</a><span class="showcase-preview"><img src="/assets/showcase/padel-screenshot.jpg" alt="padel-cli availability screenshot" loading="lazy" decoding="async" /></span></span>
|
||||||
- **Accounting intake** — Collect PDFs from email, prep for tax consultant (monthly accounting batch). (No link shared.)
|
- **Accounting intake** — Collect PDFs from email, prep for tax consultant (monthly accounting batch). (No link shared.)
|
||||||
|
|
||||||
## Knowledge & memory systems
|
## Knowledge & memory systems
|
||||||
|
- **xuezh** — Chinese learning engine + Clawdbot skill for pronunciation feedback and study flows. <span class="showcase-link"><a href="https://github.com/joshp123/xuezh">github.com/joshp123/xuezh</a><span class="showcase-preview"><img src="/assets/showcase/xuezh-pronunciation.jpeg" alt="xuezh pronunciation feedback in Clawdbot" loading="lazy" decoding="async" /></span></span>
|
||||||
- **WhatsApp memory vault** — Ingests full exports, transcribes 1k+ voice notes, cross‑checks with git logs, outputs linked MD reports + ongoing indexing. (No link shared.)
|
- **WhatsApp memory vault** — Ingests full exports, transcribes 1k+ voice notes, cross‑checks with git logs, outputs linked MD reports + ongoing indexing. (No link shared.)
|
||||||
- **Karakeep semantic search** — Sidecar adds vector search to Karakeep bookmarks (Qdrant + OpenAI/Ollama), includes Clawdis skill. https://github.com/jamesbrooksco/karakeep-semantic-search
|
- **Karakeep semantic search** — Sidecar adds vector search to Karakeep bookmarks (Qdrant + OpenAI/Ollama), includes Clawdis skill. https://github.com/jamesbrooksco/karakeep-semantic-search
|
||||||
- **Inside‑Out‑2 style memory** — Separate memory manager app turns session files into memories → beliefs → self model. (No link shared.)
|
- **Inside‑Out‑2 style memory** — Separate memory manager app turns session files into memories → beliefs → self model. (No link shared.)
|
||||||
@ -26,11 +28,12 @@ Real projects from the community. Highlights from #showcase (Jan 2–5, 2026).
|
|||||||
## Infrastructure & deployment
|
## Infrastructure & deployment
|
||||||
- **Home Assistant OS gateway add‑on** — Clawdbot gateway running on HA OS (Raspberry Pi), with SSH tunnel support + persistent state in /config. https://github.com/ngutman/clawdbot-ha-addon
|
- **Home Assistant OS gateway add‑on** — Clawdbot gateway running on HA OS (Raspberry Pi), with SSH tunnel support + persistent state in /config. https://github.com/ngutman/clawdbot-ha-addon
|
||||||
- **Home Assistant skill** — Control/automate HA via ClawdHub. https://clawdhub.com/skills/homeassistant
|
- **Home Assistant skill** — Control/automate HA via ClawdHub. https://clawdhub.com/skills/homeassistant
|
||||||
- **Nix packaging** — Batteries‑included nixified clawdis config. https://github.com/joshp123/nix-clawdis
|
- **Nix packaging** — Batteries‑included nixified clawdbot config. https://github.com/clawdbot/nix-clawdbot
|
||||||
- **CalDAV skill** — khal/vdirsyncer based calendar skill. ClawdHub: caldav-calendar → https://clawdhub.com/skills/caldav-calendar
|
- **CalDAV skill** — khal/vdirsyncer based calendar skill. ClawdHub: caldav-calendar → https://clawdhub.com/skills/caldav-calendar
|
||||||
|
|
||||||
## Home + hardware
|
## Home + hardware
|
||||||
- **Roborock integration** — Plugin for robot vacuum control. https://github.com/joshp123/gohome/tree/main/plugins/roborock
|
- **gohome** — Nix-native home automation with Clawdbot as the interface, plus Grafana dashboards. <span class="showcase-link"><a href="https://github.com/joshp123/gohome">github.com/joshp123/gohome</a><span class="showcase-preview"><img src="/assets/showcase/gohome-grafana.png" alt="GoHome Grafana dashboard" loading="lazy" decoding="async" /></span></span>
|
||||||
|
- **Roborock integration** — Plugin for robot vacuum control. <span class="showcase-link"><a href="https://github.com/joshp123/gohome/tree/main/plugins/roborock">github.com/joshp123/gohome/tree/main/plugins/roborock</a><span class="showcase-preview"><img src="/assets/showcase/roborock-screenshot.jpg" alt="GoHome Roborock status screenshot" loading="lazy" decoding="async" /></span></span>
|
||||||
|
|
||||||
## Community builds (non‑Clawdis but made with/around it)
|
## Community builds (non‑Clawdis but made with/around it)
|
||||||
- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/
|
- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/
|
||||||
|
|||||||
@ -8,6 +8,8 @@ read_when:
|
|||||||
# Bash tool
|
# Bash tool
|
||||||
|
|
||||||
Run shell commands in the workspace. Supports foreground + background execution via `process`.
|
Run shell commands in the workspace. Supports foreground + background execution via `process`.
|
||||||
|
If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
|
||||||
|
Background sessions are scoped per agent; `process` only sees sessions from the same agent.
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,7 @@ Core parameters:
|
|||||||
Notes:
|
Notes:
|
||||||
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
||||||
- Use `process` to poll/log/write/kill/clear background sessions.
|
- Use `process` to poll/log/write/kill/clear background sessions.
|
||||||
|
- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
|
||||||
|
|
||||||
### `process`
|
### `process`
|
||||||
Manage background bash sessions.
|
Manage background bash sessions.
|
||||||
@ -52,6 +53,7 @@ Core actions:
|
|||||||
Notes:
|
Notes:
|
||||||
- `poll` returns new output and exit status when complete.
|
- `poll` returns new output and exit status when complete.
|
||||||
- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
|
- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
|
||||||
|
- `process` is scoped per agent; sessions from other agents are not visible.
|
||||||
|
|
||||||
### `browser`
|
### `browser`
|
||||||
Control the dedicated clawd browser.
|
Control the dedicated clawd browser.
|
||||||
@ -157,13 +159,14 @@ Core parameters:
|
|||||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||||
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
||||||
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||||
- `sessions_spawn`: `task`, `label?`, `model?`, `timeoutSeconds?`, `cleanup?`
|
- `sessions_spawn`: `task`, `label?`, `model?`, `runTimeoutSeconds?`, `cleanup?`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||||
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
||||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
||||||
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
|
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
|
||||||
|
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
|
||||||
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
|
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
|
||||||
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
||||||
|
|
||||||
|
|||||||
@ -42,11 +42,11 @@ Text + native (when enabled):
|
|||||||
- `/verbose on|off` (alias: `/v`)
|
- `/verbose on|off` (alias: `/v`)
|
||||||
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
|
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
|
||||||
- `/elevated on|off` (alias: `/elev`)
|
- `/elevated on|off` (alias: `/elev`)
|
||||||
- `/model <name>`
|
- `/model <name>` (or `/<alias>` from `agent.models.*.alias`)
|
||||||
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`)
|
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`)
|
||||||
|
|
||||||
Text-only:
|
Text-only:
|
||||||
- `/compact [instructions]`
|
- `/compact [instructions]` (see [/concepts/compaction](/concepts/compaction))
|
||||||
|
|
||||||
## Surface notes
|
## Surface notes
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# Sub-agents
|
# Sub-agents
|
||||||
|
|
||||||
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent:<id>:subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat provider.
|
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent:<agentId>:subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat provider.
|
||||||
|
|
||||||
Primary goals:
|
Primary goals:
|
||||||
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
||||||
@ -25,7 +25,7 @@ Tool params:
|
|||||||
- `task` (required)
|
- `task` (required)
|
||||||
- `label?` (optional)
|
- `label?` (optional)
|
||||||
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
|
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
|
||||||
- `timeoutSeconds?` (optional; omit for long-running jobs; when set, Clawdbot waits up to N seconds and aborts the sub-agent if it is still running)
|
- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds)
|
||||||
- `cleanup?` (`delete|keep`, default `keep`)
|
- `cleanup?` (`delete|keep`, default `keep`)
|
||||||
|
|
||||||
Auto-archive:
|
Auto-archive:
|
||||||
@ -33,7 +33,7 @@ Auto-archive:
|
|||||||
- Archive uses `sessions.delete` and renames the transcript to `*.deleted.<timestamp>` (same folder).
|
- Archive uses `sessions.delete` and renames the transcript to `*.deleted.<timestamp>` (same folder).
|
||||||
- `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename).
|
- `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename).
|
||||||
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
|
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
|
||||||
- Timeouts do **not** auto-archive; they only stop the run. The session remains until auto-archive.
|
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
|
||||||
|
|
||||||
## Announce
|
## Announce
|
||||||
|
|
||||||
@ -84,3 +84,4 @@ Sub-agents use a dedicated in-process queue lane:
|
|||||||
|
|
||||||
- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost.
|
- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost.
|
||||||
- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.
|
- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.
|
||||||
|
- `sessions_spawn` is always non-blocking: it returns `{ status: "accepted", runId, childSessionKey }` immediately.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "clawdbot",
|
||||||
"version": "2026.1.5-3",
|
"version": "2026.1.7",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@ -85,6 +85,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@buape/carbon": "0.0.0-beta-20260107085330",
|
"@buape/carbon": "0.0.0-beta-20260107085330",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
|
"@grammyjs/runner": "^2.0.3",
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"@homebridge/ciao": "^1.3.4",
|
"@homebridge/ciao": "^1.3.4",
|
||||||
"@mariozechner/pi-agent-core": "^0.37.2",
|
"@mariozechner/pi-agent-core": "^0.37.2",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@ -28,6 +28,9 @@ importers:
|
|||||||
'@clack/prompts':
|
'@clack/prompts':
|
||||||
specifier: ^0.11.0
|
specifier: ^0.11.0
|
||||||
version: 0.11.0
|
version: 0.11.0
|
||||||
|
'@grammyjs/runner':
|
||||||
|
specifier: ^2.0.3
|
||||||
|
version: 2.0.3(grammy@1.39.2)
|
||||||
'@grammyjs/transformer-throttler':
|
'@grammyjs/transformer-throttler':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.1
|
||||||
version: 1.2.1(grammy@1.39.2)
|
version: 1.2.1(grammy@1.39.2)
|
||||||
@ -591,6 +594,12 @@ packages:
|
|||||||
'@modelcontextprotocol/sdk':
|
'@modelcontextprotocol/sdk':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@grammyjs/runner@2.0.3':
|
||||||
|
resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==}
|
||||||
|
engines: {node: '>=12.20.0 || >=14.13.1'}
|
||||||
|
peerDependencies:
|
||||||
|
grammy: ^1.13.1
|
||||||
|
|
||||||
'@grammyjs/transformer-throttler@1.2.1':
|
'@grammyjs/transformer-throttler@1.2.1':
|
||||||
resolution: {integrity: sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==}
|
resolution: {integrity: sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==}
|
||||||
engines: {node: ^12.20.0 || >=14.13.1}
|
engines: {node: ^12.20.0 || >=14.13.1}
|
||||||
@ -3411,6 +3420,11 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
|
'@grammyjs/runner@2.0.3(grammy@1.39.2)':
|
||||||
|
dependencies:
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
grammy: 1.39.2
|
||||||
|
|
||||||
'@grammyjs/transformer-throttler@1.2.1(grammy@1.39.2)':
|
'@grammyjs/transformer-throttler@1.2.1(grammy@1.39.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
bottleneck: 2.19.5
|
bottleneck: 2.19.5
|
||||||
|
|||||||
@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Highlights from #showcase (Jan 2–5, 2026). Curated for “wow” factor + concrete links.
|
Highlights from #showcase (Jan 2–5, 2026). Curated for “wow” factor + concrete links.
|
||||||
|
|
||||||
|
## Clawdhub projects (formerly Clawdis)
|
||||||
|
- **xuezh** — Chinese learning engine + Clawdbot skill for pronunciation feedback and study flows. <span class="showcase-link"><a href="https://github.com/joshp123/xuezh">github.com/joshp123/xuezh</a><span class="showcase-preview"><img src="/assets/showcase/xuezh-pronunciation.jpeg" alt="xuezh pronunciation feedback in Clawdbot" loading="lazy" decoding="async" /></span></span>
|
||||||
|
- **gohome** — Nix-native home automation with Clawdbot as the interface, plus Grafana dashboards. <span class="showcase-link"><a href="https://github.com/joshp123/gohome">github.com/joshp123/gohome</a><span class="showcase-preview"><img src="/assets/showcase/gohome-grafana.png" alt="GoHome Grafana dashboard" loading="lazy" decoding="async" /></span></span>
|
||||||
|
- **Roborock skill for GoHome** — Vacuum control plugin with gRPC actions + metrics. <span class="showcase-link"><a href="https://github.com/joshp123/gohome/tree/main/plugins/roborock">github.com/joshp123/gohome/tree/main/plugins/roborock</a><span class="showcase-preview"><img src="/assets/showcase/roborock-screenshot.jpg" alt="GoHome Roborock status screenshot" loading="lazy" decoding="async" /></span></span>
|
||||||
|
- **padel-cli** — Playtomic availability + booking CLI with a Clawdbot plugin output. <span class="showcase-link"><a href="https://github.com/joshp123/padel-cli">github.com/joshp123/padel-cli</a><span class="showcase-preview"><img src="/assets/showcase/padel-screenshot.jpg" alt="padel-cli availability screenshot" loading="lazy" decoding="async" /></span></span>
|
||||||
|
|
||||||
## Automation & real-world outcomes
|
## Automation & real-world outcomes
|
||||||
- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill
|
- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill
|
||||||
- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api
|
- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api
|
||||||
|
|||||||
@ -19,26 +19,29 @@ Follow the official CLI get-started steps. Don't guess install commands.
|
|||||||
1. Check OS + shell.
|
1. Check OS + shell.
|
||||||
2. Verify CLI present: `op --version`.
|
2. Verify CLI present: `op --version`.
|
||||||
3. Confirm desktop app integration is enabled (per get-started) and the app is unlocked.
|
3. Confirm desktop app integration is enabled (per get-started) and the app is unlocked.
|
||||||
4. Sign in / authorize this terminal: `op signin` (expect an app prompt).
|
4. REQUIRED: create a fresh tmux session for all `op` commands (no direct `op` calls outside tmux).
|
||||||
5. If multiple accounts: use `--account` or `OP_ACCOUNT`.
|
5. Sign in / authorize inside tmux: `op signin` (expect app prompt).
|
||||||
6. Verify access: `op whoami` or `op account list`.
|
6. Verify access inside tmux: `op whoami` (must succeed before any secret read).
|
||||||
|
7. If multiple accounts: use `--account` or `OP_ACCOUNT`.
|
||||||
|
|
||||||
## Avoid repeated auth prompts (tmux)
|
## REQUIRED tmux session (T-Max)
|
||||||
|
|
||||||
The bash tool uses a fresh TTY per command, so app integration may prompt every time. To reuse authorization, run multiple `op` commands inside a single tmux session.
|
The shell tool uses a fresh TTY per command. To avoid re-prompts and failures, always run `op` inside a dedicated tmux session with a fresh socket/session name.
|
||||||
|
|
||||||
Example (see `tmux` skill for socket conventions):
|
Example (see `tmux` skill for socket conventions, do not reuse old session names):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
|
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
|
||||||
mkdir -p "$SOCKET_DIR"
|
mkdir -p "$SOCKET_DIR"
|
||||||
SOCKET="$SOCKET_DIR/clawdbot.sock"
|
SOCKET="$SOCKET_DIR/clawdbot-op.sock"
|
||||||
SESSION=op-auth
|
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
|
||||||
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
|
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
|
||||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op signin --account my.1password.com" Enter
|
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op signin --account my.1password.com" Enter
|
||||||
|
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter
|
||||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op vault list" Enter
|
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op vault list" Enter
|
||||||
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
||||||
|
tmux -S "$SOCKET" kill-session -t "$SESSION"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
@ -46,4 +49,5 @@ tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
|||||||
- Never paste secrets into logs, chat, or code.
|
- Never paste secrets into logs, chat, or code.
|
||||||
- Prefer `op run` / `op inject` over writing secrets to disk.
|
- Prefer `op run` / `op inject` over writing secrets to disk.
|
||||||
- If sign-in without app integration is needed, use `op account add`.
|
- If sign-in without app integration is needed, use `op account add`.
|
||||||
- If a command returns "account is not signed in", re-run `op signin` and authorize in the app.
|
- If a command returns "account is not signed in", re-run `op signin` inside tmux and authorize in the app.
|
||||||
|
- Do not run `op` outside tmux; stop and ask if tmux is unavailable.
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export type ProcessStatus = "running" | "completed" | "failed" | "killed";
|
|||||||
export interface ProcessSession {
|
export interface ProcessSession {
|
||||||
id: string;
|
id: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
scopeKey?: string;
|
||||||
child?: ChildProcessWithoutNullStreams;
|
child?: ChildProcessWithoutNullStreams;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
@ -38,6 +39,7 @@ export interface ProcessSession {
|
|||||||
export interface FinishedSession {
|
export interface FinishedSession {
|
||||||
id: string;
|
id: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
scopeKey?: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
endedAt: number;
|
endedAt: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@ -126,6 +128,7 @@ function moveToFinished(session: ProcessSession, status: ProcessStatus) {
|
|||||||
finishedSessions.set(session.id, {
|
finishedSessions.set(session.id, {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
command: session.command,
|
command: session.command,
|
||||||
|
scopeKey: session.scopeKey,
|
||||||
startedAt: session.startedAt,
|
startedAt: session.startedAt,
|
||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
cwd: session.cwd,
|
cwd: session.cwd,
|
||||||
|
|||||||
@ -185,4 +185,36 @@ describe("bash tool backgrounding", () => {
|
|||||||
const textBlock = log.content.find((c) => c.type === "text");
|
const textBlock = log.content.find((c) => c.type === "text");
|
||||||
expect(textBlock?.text).toBe("beta");
|
expect(textBlock?.text).toBe("beta");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scopes process sessions by scopeKey", async () => {
|
||||||
|
const bashA = createBashTool({ backgroundMs: 10, scopeKey: "agent:alpha" });
|
||||||
|
const processA = createProcessTool({ scopeKey: "agent:alpha" });
|
||||||
|
const bashB = createBashTool({ backgroundMs: 10, scopeKey: "agent:beta" });
|
||||||
|
const processB = createProcessTool({ scopeKey: "agent:beta" });
|
||||||
|
|
||||||
|
const resultA = await bashA.execute("call1", {
|
||||||
|
command: 'node -e "setTimeout(() => {}, 50)"',
|
||||||
|
background: true,
|
||||||
|
});
|
||||||
|
const resultB = await bashB.execute("call2", {
|
||||||
|
command: 'node -e "setTimeout(() => {}, 50)"',
|
||||||
|
background: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionA = (resultA.details as { sessionId: string }).sessionId;
|
||||||
|
const sessionB = (resultB.details as { sessionId: string }).sessionId;
|
||||||
|
|
||||||
|
const listA = await processA.execute("call3", { action: "list" });
|
||||||
|
const sessionsA = (
|
||||||
|
listA.details as { sessions: Array<{ sessionId: string }> }
|
||||||
|
).sessions;
|
||||||
|
expect(sessionsA.some((s) => s.sessionId === sessionA)).toBe(true);
|
||||||
|
expect(sessionsA.some((s) => s.sessionId === sessionB)).toBe(false);
|
||||||
|
|
||||||
|
const pollB = await processB.execute("call4", {
|
||||||
|
action: "poll",
|
||||||
|
sessionId: sessionA,
|
||||||
|
});
|
||||||
|
expect(pollB.details.status).toBe("failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -39,27 +39,32 @@ const DEFAULT_PATH =
|
|||||||
process.env.PATH ??
|
process.env.PATH ??
|
||||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||||
|
|
||||||
const stringEnum = (
|
// NOTE: Using Type.Unsafe with enum instead of Type.Union([Type.Literal(...)])
|
||||||
values: readonly string[],
|
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
|
||||||
options?: Parameters<typeof Type.Union>[1],
|
// Type.Union of literals compiles to { anyOf: [{enum:["a"]}, {enum:["b"]}, ...] }
|
||||||
|
// which is valid but not accepted. A flat enum { type: "string", enum: [...] } works.
|
||||||
|
const stringEnum = <T extends readonly string[]>(
|
||||||
|
values: T,
|
||||||
|
options?: { description?: string },
|
||||||
) =>
|
) =>
|
||||||
Type.Union(
|
Type.Unsafe<T[number]>({
|
||||||
values.map((value) => Type.Literal(value)) as [
|
type: "string",
|
||||||
ReturnType<typeof Type.Literal>,
|
enum: values as unknown as string[],
|
||||||
...ReturnType<typeof Type.Literal>[],
|
...options,
|
||||||
],
|
});
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
export type BashToolDefaults = {
|
export type BashToolDefaults = {
|
||||||
backgroundMs?: number;
|
backgroundMs?: number;
|
||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
sandbox?: BashSandboxConfig;
|
sandbox?: BashSandboxConfig;
|
||||||
elevated?: BashElevatedDefaults;
|
elevated?: BashElevatedDefaults;
|
||||||
|
allowBackground?: boolean;
|
||||||
|
scopeKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProcessToolDefaults = {
|
export type ProcessToolDefaults = {
|
||||||
cleanupMs?: number;
|
cleanupMs?: number;
|
||||||
|
scopeKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BashSandboxConfig = {
|
export type BashSandboxConfig = {
|
||||||
@ -126,6 +131,7 @@ export function createBashTool(
|
|||||||
10,
|
10,
|
||||||
120_000,
|
120_000,
|
||||||
);
|
);
|
||||||
|
const allowBackground = defaults?.allowBackground ?? true;
|
||||||
const defaultTimeoutSec =
|
const defaultTimeoutSec =
|
||||||
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
|
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
|
||||||
? defaults.timeoutSec
|
? defaults.timeoutSec
|
||||||
@ -152,18 +158,27 @@ export function createBashTool(
|
|||||||
throw new Error("Provide a command to start.");
|
throw new Error("Provide a command to start.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const yieldWindow = params.background
|
|
||||||
? 0
|
|
||||||
: clampNumber(
|
|
||||||
params.yieldMs ?? defaultBackgroundMs,
|
|
||||||
defaultBackgroundMs,
|
|
||||||
10,
|
|
||||||
120_000,
|
|
||||||
);
|
|
||||||
const maxOutput = DEFAULT_MAX_OUTPUT;
|
const maxOutput = DEFAULT_MAX_OUTPUT;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const backgroundRequested = params.background === true;
|
||||||
|
const yieldRequested = typeof params.yieldMs === "number";
|
||||||
|
if (!allowBackground && (backgroundRequested || yieldRequested)) {
|
||||||
|
warnings.push(
|
||||||
|
"Warning: background execution is disabled; running synchronously.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const yieldWindow = allowBackground
|
||||||
|
? backgroundRequested
|
||||||
|
? 0
|
||||||
|
: clampNumber(
|
||||||
|
params.yieldMs ?? defaultBackgroundMs,
|
||||||
|
defaultBackgroundMs,
|
||||||
|
10,
|
||||||
|
120_000,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const elevatedDefaults = defaults?.elevated;
|
const elevatedDefaults = defaults?.elevated;
|
||||||
const elevatedDefaultOn =
|
const elevatedDefaultOn =
|
||||||
elevatedDefaults?.defaultLevel === "on" &&
|
elevatedDefaults?.defaultLevel === "on" &&
|
||||||
@ -238,6 +253,7 @@ export function createBashTool(
|
|||||||
const session = {
|
const session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
command: params.command,
|
command: params.command,
|
||||||
|
scopeKey: defaults?.scopeKey,
|
||||||
child,
|
child,
|
||||||
pid: child?.pid,
|
pid: child?.pid,
|
||||||
startedAt,
|
startedAt,
|
||||||
@ -351,15 +367,17 @@ export function createBashTool(
|
|||||||
resolveRunning();
|
resolveRunning();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (yieldWindow === 0) {
|
if (allowBackground && yieldWindow !== null) {
|
||||||
onYieldNow();
|
if (yieldWindow === 0) {
|
||||||
} else {
|
onYieldNow();
|
||||||
yieldTimer = setTimeout(() => {
|
} else {
|
||||||
if (settled) return;
|
yieldTimer = setTimeout(() => {
|
||||||
yielded = true;
|
if (settled) return;
|
||||||
markBackgrounded(session);
|
yielded = true;
|
||||||
resolveRunning();
|
markBackgrounded(session);
|
||||||
}, yieldWindow);
|
resolveRunning();
|
||||||
|
}, yieldWindow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExit = (
|
const handleExit = (
|
||||||
@ -456,6 +474,9 @@ export function createProcessTool(
|
|||||||
if (defaults?.cleanupMs !== undefined) {
|
if (defaults?.cleanupMs !== undefined) {
|
||||||
setJobTtlMs(defaults.cleanupMs);
|
setJobTtlMs(defaults.cleanupMs);
|
||||||
}
|
}
|
||||||
|
const scopeKey = defaults?.scopeKey;
|
||||||
|
const isInScope = (session?: { scopeKey?: string } | null) =>
|
||||||
|
!scopeKey || session?.scopeKey === scopeKey;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "process",
|
name: "process",
|
||||||
@ -473,32 +494,36 @@ export function createProcessTool(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (params.action === "list") {
|
if (params.action === "list") {
|
||||||
const running = listRunningSessions().map((s) => ({
|
const running = listRunningSessions()
|
||||||
sessionId: s.id,
|
.filter((s) => isInScope(s))
|
||||||
status: "running",
|
.map((s) => ({
|
||||||
pid: s.pid ?? undefined,
|
sessionId: s.id,
|
||||||
startedAt: s.startedAt,
|
status: "running",
|
||||||
runtimeMs: Date.now() - s.startedAt,
|
pid: s.pid ?? undefined,
|
||||||
cwd: s.cwd,
|
startedAt: s.startedAt,
|
||||||
command: s.command,
|
runtimeMs: Date.now() - s.startedAt,
|
||||||
name: deriveSessionName(s.command),
|
cwd: s.cwd,
|
||||||
tail: s.tail,
|
command: s.command,
|
||||||
truncated: s.truncated,
|
name: deriveSessionName(s.command),
|
||||||
}));
|
tail: s.tail,
|
||||||
const finished = listFinishedSessions().map((s) => ({
|
truncated: s.truncated,
|
||||||
sessionId: s.id,
|
}));
|
||||||
status: s.status,
|
const finished = listFinishedSessions()
|
||||||
startedAt: s.startedAt,
|
.filter((s) => isInScope(s))
|
||||||
endedAt: s.endedAt,
|
.map((s) => ({
|
||||||
runtimeMs: s.endedAt - s.startedAt,
|
sessionId: s.id,
|
||||||
cwd: s.cwd,
|
status: s.status,
|
||||||
command: s.command,
|
startedAt: s.startedAt,
|
||||||
name: deriveSessionName(s.command),
|
endedAt: s.endedAt,
|
||||||
tail: s.tail,
|
runtimeMs: s.endedAt - s.startedAt,
|
||||||
truncated: s.truncated,
|
cwd: s.cwd,
|
||||||
exitCode: s.exitCode ?? undefined,
|
command: s.command,
|
||||||
exitSignal: s.exitSignal ?? undefined,
|
name: deriveSessionName(s.command),
|
||||||
}));
|
tail: s.tail,
|
||||||
|
truncated: s.truncated,
|
||||||
|
exitCode: s.exitCode ?? undefined,
|
||||||
|
exitSignal: s.exitSignal ?? undefined,
|
||||||
|
}));
|
||||||
const lines = [...running, ...finished]
|
const lines = [...running, ...finished]
|
||||||
.sort((a, b) => b.startedAt - a.startedAt)
|
.sort((a, b) => b.startedAt - a.startedAt)
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
@ -532,34 +557,38 @@ export function createProcessTool(
|
|||||||
|
|
||||||
const session = getSession(params.sessionId);
|
const session = getSession(params.sessionId);
|
||||||
const finished = getFinishedSession(params.sessionId);
|
const finished = getFinishedSession(params.sessionId);
|
||||||
|
const scopedSession = isInScope(session) ? session : undefined;
|
||||||
|
const scopedFinished = isInScope(finished) ? finished : undefined;
|
||||||
|
|
||||||
switch (params.action) {
|
switch (params.action) {
|
||||||
case "poll": {
|
case "poll": {
|
||||||
if (!session) {
|
if (!scopedSession) {
|
||||||
if (finished) {
|
if (scopedFinished) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text:
|
text:
|
||||||
(finished.tail ||
|
(scopedFinished.tail ||
|
||||||
`(no output recorded${
|
`(no output recorded${
|
||||||
finished.truncated ? " — truncated to cap" : ""
|
scopedFinished.truncated ? " — truncated to cap" : ""
|
||||||
})`) +
|
})`) +
|
||||||
`\n\nProcess exited with ${
|
`\n\nProcess exited with ${
|
||||||
finished.exitSignal
|
scopedFinished.exitSignal
|
||||||
? `signal ${finished.exitSignal}`
|
? `signal ${scopedFinished.exitSignal}`
|
||||||
: `code ${finished.exitCode ?? 0}`
|
: `code ${scopedFinished.exitCode ?? 0}`
|
||||||
}.`,
|
}.`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
status:
|
status:
|
||||||
finished.status === "completed" ? "completed" : "failed",
|
scopedFinished.status === "completed"
|
||||||
|
? "completed"
|
||||||
|
: "failed",
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
exitCode: finished.exitCode ?? undefined,
|
exitCode: scopedFinished.exitCode ?? undefined,
|
||||||
aggregated: finished.aggregated,
|
aggregated: scopedFinished.aggregated,
|
||||||
name: deriveSessionName(finished.command),
|
name: deriveSessionName(scopedFinished.command),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -573,7 +602,7 @@ export function createProcessTool(
|
|||||||
details: { status: "failed" },
|
details: { status: "failed" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!session.backgrounded) {
|
if (!scopedSession.backgrounded) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@ -584,17 +613,17 @@ export function createProcessTool(
|
|||||||
details: { status: "failed" },
|
details: { status: "failed" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { stdout, stderr } = drainSession(session);
|
const { stdout, stderr } = drainSession(scopedSession);
|
||||||
const exited = session.exited;
|
const exited = scopedSession.exited;
|
||||||
const exitCode = session.exitCode ?? 0;
|
const exitCode = scopedSession.exitCode ?? 0;
|
||||||
const exitSignal = session.exitSignal ?? undefined;
|
const exitSignal = scopedSession.exitSignal ?? undefined;
|
||||||
if (exited) {
|
if (exited) {
|
||||||
const status =
|
const status =
|
||||||
exitCode === 0 && exitSignal == null ? "completed" : "failed";
|
exitCode === 0 && exitSignal == null ? "completed" : "failed";
|
||||||
markExited(
|
markExited(
|
||||||
session,
|
scopedSession,
|
||||||
session.exitCode ?? null,
|
scopedSession.exitCode ?? null,
|
||||||
session.exitSignal ?? null,
|
scopedSession.exitSignal ?? null,
|
||||||
status,
|
status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -624,15 +653,15 @@ export function createProcessTool(
|
|||||||
status,
|
status,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
exitCode: exited ? exitCode : undefined,
|
exitCode: exited ? exitCode : undefined,
|
||||||
aggregated: session.aggregated,
|
aggregated: scopedSession.aggregated,
|
||||||
name: deriveSessionName(session.command),
|
name: deriveSessionName(scopedSession.command),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "log": {
|
case "log": {
|
||||||
if (session) {
|
if (scopedSession) {
|
||||||
if (!session.backgrounded) {
|
if (!scopedSession.backgrounded) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@ -644,31 +673,31 @@ export function createProcessTool(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { slice, totalLines, totalChars } = sliceLogLines(
|
const { slice, totalLines, totalChars } = sliceLogLines(
|
||||||
session.aggregated,
|
scopedSession.aggregated,
|
||||||
params.offset,
|
params.offset,
|
||||||
params.limit,
|
params.limit,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: slice || "(no output yet)" }],
|
content: [{ type: "text", text: slice || "(no output yet)" }],
|
||||||
details: {
|
details: {
|
||||||
status: session.exited ? "completed" : "running",
|
status: scopedSession.exited ? "completed" : "running",
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
total: totalLines,
|
total: totalLines,
|
||||||
totalLines,
|
totalLines,
|
||||||
totalChars,
|
totalChars,
|
||||||
truncated: session.truncated,
|
truncated: scopedSession.truncated,
|
||||||
name: deriveSessionName(session.command),
|
name: deriveSessionName(scopedSession.command),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (finished) {
|
if (scopedFinished) {
|
||||||
const { slice, totalLines, totalChars } = sliceLogLines(
|
const { slice, totalLines, totalChars } = sliceLogLines(
|
||||||
finished.aggregated,
|
scopedFinished.aggregated,
|
||||||
params.offset,
|
params.offset,
|
||||||
params.limit,
|
params.limit,
|
||||||
);
|
);
|
||||||
const status =
|
const status =
|
||||||
finished.status === "completed" ? "completed" : "failed";
|
scopedFinished.status === "completed" ? "completed" : "failed";
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: slice || "(no output recorded)" },
|
{ type: "text", text: slice || "(no output recorded)" },
|
||||||
@ -679,10 +708,10 @@ export function createProcessTool(
|
|||||||
total: totalLines,
|
total: totalLines,
|
||||||
totalLines,
|
totalLines,
|
||||||
totalChars,
|
totalChars,
|
||||||
truncated: finished.truncated,
|
truncated: scopedFinished.truncated,
|
||||||
exitCode: finished.exitCode ?? undefined,
|
exitCode: scopedFinished.exitCode ?? undefined,
|
||||||
exitSignal: finished.exitSignal ?? undefined,
|
exitSignal: scopedFinished.exitSignal ?? undefined,
|
||||||
name: deriveSessionName(finished.command),
|
name: deriveSessionName(scopedFinished.command),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -698,7 +727,7 @@ export function createProcessTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "write": {
|
case "write": {
|
||||||
if (!session) {
|
if (!scopedSession) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@ -709,7 +738,7 @@ export function createProcessTool(
|
|||||||
details: { status: "failed" },
|
details: { status: "failed" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!session.backgrounded) {
|
if (!scopedSession.backgrounded) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@ -720,7 +749,10 @@ export function createProcessTool(
|
|||||||
details: { status: "failed" },
|
details: { status: "failed" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!session.child?.stdin || session.child.stdin.destroyed) {
|
if (
|
||||||
|
!scopedSession.child?.stdin ||
|
||||||
|
scopedSession.child.stdin.destroyed
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@ -732,13 +764,13 @@ export function createProcessTool(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
session.child?.stdin.write(params.data ?? "", (err) => {
|
scopedSession.child?.stdin.write(params.data ?? "", (err) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve();
|
else resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (params.eof) {
|
if (params.eof) {
|
||||||
session.child.stdin.end();
|
scopedSession.child.stdin.end();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@ -752,13 +784,15 @@ export function createProcessTool(
|
|||||||
details: {
|
details: {
|
||||||
status: "running",
|
status: "running",
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
name: session ? deriveSessionName(session.command) : undefined,
|
name: scopedSession
|
||||||
|
? deriveSessionName(scopedSession.command)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "kill": {
|
case "kill": {
|
||||||
if (!session) {
|
if (!scopedSession) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@ -769,7 +803,7 @@ export function createProcessTool(
|
|||||||
details: { status: "failed" },
|
details: { status: "failed" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!session.backgrounded) {
|
if (!scopedSession.backgrounded) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@ -780,21 +814,23 @@ export function createProcessTool(
|
|||||||
details: { status: "failed" },
|
details: { status: "failed" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
killSession(session);
|
killSession(scopedSession);
|
||||||
markExited(session, null, "SIGKILL", "failed");
|
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: `Killed session ${params.sessionId}.` },
|
{ type: "text", text: `Killed session ${params.sessionId}.` },
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
name: session ? deriveSessionName(session.command) : undefined,
|
name: scopedSession
|
||||||
|
? deriveSessionName(scopedSession.command)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "clear": {
|
case "clear": {
|
||||||
if (finished) {
|
if (scopedFinished) {
|
||||||
deleteSession(params.sessionId);
|
deleteSession(params.sessionId);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@ -815,20 +851,22 @@ export function createProcessTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "remove": {
|
case "remove": {
|
||||||
if (session) {
|
if (scopedSession) {
|
||||||
killSession(session);
|
killSession(scopedSession);
|
||||||
markExited(session, null, "SIGKILL", "failed");
|
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: `Removed session ${params.sessionId}.` },
|
{ type: "text", text: `Removed session ${params.sessionId}.` },
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
name: session ? deriveSessionName(session.command) : undefined,
|
name: scopedSession
|
||||||
|
? deriveSessionName(scopedSession.command)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (finished) {
|
if (scopedFinished) {
|
||||||
deleteSession(params.sessionId);
|
deleteSession(params.sessionId);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@ -19,17 +19,21 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
describe("subagents", () => {
|
describe("subagents", () => {
|
||||||
it("sessions_spawn announces back to the requester group provider", async () => {
|
it("sessions_spawn announces back to the requester group provider", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
let agentCallCount = 0;
|
let agentCallCount = 0;
|
||||||
let lastWaitedRunId: string | undefined;
|
|
||||||
const replyByRunId = new Map<string, string>();
|
|
||||||
let sendParams: { to?: string; provider?: string; message?: string } = {};
|
let sendParams: { to?: string; provider?: string; message?: string } = {};
|
||||||
let deletedKey: string | undefined;
|
let deletedKey: string | undefined;
|
||||||
|
let childRunId: string | undefined;
|
||||||
|
let childSessionKey: string | undefined;
|
||||||
|
const sessionLastAssistantText = new Map<string, string>();
|
||||||
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
const request = opts as { method?: string; params?: unknown };
|
const request = opts as { method?: string; params?: unknown };
|
||||||
@ -37,13 +41,21 @@ describe("subagents", () => {
|
|||||||
if (request.method === "agent") {
|
if (request.method === "agent") {
|
||||||
agentCallCount += 1;
|
agentCallCount += 1;
|
||||||
const runId = `run-${agentCallCount}`;
|
const runId = `run-${agentCallCount}`;
|
||||||
const params = request.params as
|
const params = request.params as {
|
||||||
| { message?: string; sessionKey?: string }
|
message?: string;
|
||||||
| undefined;
|
sessionKey?: string;
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
const message = params?.message ?? "";
|
const message = params?.message ?? "";
|
||||||
const reply =
|
const sessionKey = params?.sessionKey ?? "";
|
||||||
message === "Sub-agent announce step." ? "announce now" : "result";
|
if (message === "Sub-agent announce step.") {
|
||||||
replyByRunId.set(runId, reply);
|
sessionLastAssistantText.set(sessionKey, "announce now");
|
||||||
|
} else {
|
||||||
|
childRunId = runId;
|
||||||
|
childSessionKey = sessionKey;
|
||||||
|
sessionLastAssistantText.set(sessionKey, "result");
|
||||||
|
expect(params?.timeout).toBe(1);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
runId,
|
runId,
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
@ -51,13 +63,28 @@ describe("subagents", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (request.method === "agent.wait") {
|
if (request.method === "agent.wait") {
|
||||||
const params = request.params as { runId?: string } | undefined;
|
const params = request.params as
|
||||||
lastWaitedRunId = params?.runId;
|
| { runId?: string; timeoutMs?: number }
|
||||||
|
| undefined;
|
||||||
|
if (
|
||||||
|
params?.runId &&
|
||||||
|
params.runId === childRunId &&
|
||||||
|
typeof params.timeoutMs === "number" &&
|
||||||
|
params.timeoutMs > 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"sessions_spawn must not wait for sub-agent completion",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params?.timeoutMs === 0) {
|
||||||
|
return { runId: params?.runId ?? "run-1", status: "timeout" };
|
||||||
|
}
|
||||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
return { runId: params?.runId ?? "run-1", status: "ok" };
|
||||||
}
|
}
|
||||||
if (request.method === "chat.history") {
|
if (request.method === "chat.history") {
|
||||||
|
const params = request.params as { sessionKey?: string } | undefined;
|
||||||
const text =
|
const text =
|
||||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||||
return {
|
return {
|
||||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
||||||
};
|
};
|
||||||
@ -89,11 +116,26 @@ describe("subagents", () => {
|
|||||||
|
|
||||||
const result = await tool.execute("call1", {
|
const result = await tool.execute("call1", {
|
||||||
task: "do thing",
|
task: "do thing",
|
||||||
timeoutSeconds: 1,
|
runTimeoutSeconds: 1,
|
||||||
cleanup: "delete",
|
cleanup: "delete",
|
||||||
});
|
});
|
||||||
expect(result.details).toMatchObject({ status: "ok", reply: "result" });
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
runId: "run-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!childRunId) throw new Error("missing child runId");
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: childRunId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: {
|
||||||
|
phase: "end",
|
||||||
|
startedAt: 1234,
|
||||||
|
endedAt: 2345,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
@ -105,6 +147,7 @@ describe("subagents", () => {
|
|||||||
expect(first?.lane).toBe("subagent");
|
expect(first?.lane).toBe("subagent");
|
||||||
expect(first?.deliver).toBe(false);
|
expect(first?.deliver).toBe(false);
|
||||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
|
||||||
expect(sendParams.provider).toBe("discord");
|
expect(sendParams.provider).toBe("discord");
|
||||||
expect(sendParams.to).toBe("channel:req");
|
expect(sendParams.to).toBe("channel:req");
|
||||||
@ -114,12 +157,14 @@ describe("subagents", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sessions_spawn resolves main announce target from sessions.list", async () => {
|
it("sessions_spawn resolves main announce target from sessions.list", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
let agentCallCount = 0;
|
let agentCallCount = 0;
|
||||||
let lastWaitedRunId: string | undefined;
|
|
||||||
const replyByRunId = new Map<string, string>();
|
|
||||||
let sendParams: { to?: string; provider?: string; message?: string } = {};
|
let sendParams: { to?: string; provider?: string; message?: string } = {};
|
||||||
|
let childRunId: string | undefined;
|
||||||
|
let childSessionKey: string | undefined;
|
||||||
|
const sessionLastAssistantText = new Map<string, string>();
|
||||||
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
const request = opts as { method?: string; params?: unknown };
|
const request = opts as { method?: string; params?: unknown };
|
||||||
@ -138,13 +183,19 @@ describe("subagents", () => {
|
|||||||
if (request.method === "agent") {
|
if (request.method === "agent") {
|
||||||
agentCallCount += 1;
|
agentCallCount += 1;
|
||||||
const runId = `run-${agentCallCount}`;
|
const runId = `run-${agentCallCount}`;
|
||||||
const params = request.params as
|
const params = request.params as {
|
||||||
| { message?: string; sessionKey?: string }
|
message?: string;
|
||||||
| undefined;
|
sessionKey?: string;
|
||||||
|
};
|
||||||
const message = params?.message ?? "";
|
const message = params?.message ?? "";
|
||||||
const reply =
|
const sessionKey = params?.sessionKey ?? "";
|
||||||
message === "Sub-agent announce step." ? "hello from sub" : "done";
|
if (message === "Sub-agent announce step.") {
|
||||||
replyByRunId.set(runId, reply);
|
sessionLastAssistantText.set(sessionKey, "hello from sub");
|
||||||
|
} else {
|
||||||
|
childRunId = runId;
|
||||||
|
childSessionKey = sessionKey;
|
||||||
|
sessionLastAssistantText.set(sessionKey, "done");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
runId,
|
runId,
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
@ -152,13 +203,18 @@ describe("subagents", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (request.method === "agent.wait") {
|
if (request.method === "agent.wait") {
|
||||||
const params = request.params as { runId?: string } | undefined;
|
const params = request.params as
|
||||||
lastWaitedRunId = params?.runId;
|
| { runId?: string; timeoutMs?: number }
|
||||||
|
| undefined;
|
||||||
|
if (params?.timeoutMs === 0) {
|
||||||
|
return { runId: params?.runId ?? "run-1", status: "timeout" };
|
||||||
|
}
|
||||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
return { runId: params?.runId ?? "run-1", status: "ok" };
|
||||||
}
|
}
|
||||||
if (request.method === "chat.history") {
|
if (request.method === "chat.history") {
|
||||||
|
const params = request.params as { sessionKey?: string } | undefined;
|
||||||
const text =
|
const text =
|
||||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||||
return {
|
return {
|
||||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
||||||
};
|
};
|
||||||
@ -188,10 +244,25 @@ describe("subagents", () => {
|
|||||||
|
|
||||||
const result = await tool.execute("call2", {
|
const result = await tool.execute("call2", {
|
||||||
task: "do thing",
|
task: "do thing",
|
||||||
timeoutSeconds: 1,
|
runTimeoutSeconds: 1,
|
||||||
|
});
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
runId: "run-1",
|
||||||
});
|
});
|
||||||
expect(result.details).toMatchObject({ status: "ok", reply: "done" });
|
|
||||||
|
|
||||||
|
if (!childRunId) throw new Error("missing child runId");
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: childRunId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: {
|
||||||
|
phase: "end",
|
||||||
|
startedAt: 1000,
|
||||||
|
endedAt: 2000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
@ -199,14 +270,14 @@ describe("subagents", () => {
|
|||||||
expect(sendParams.to).toBe("+123");
|
expect(sendParams.to).toBe("+123");
|
||||||
expect(sendParams.message ?? "").toContain("hello from sub");
|
expect(sendParams.message ?? "").toContain("hello from sub");
|
||||||
expect(sendParams.message ?? "").toContain("Stats:");
|
expect(sendParams.message ?? "").toContain("Stats:");
|
||||||
|
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sessions_spawn applies a model to the child session", async () => {
|
it("sessions_spawn applies a model to the child session", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
let agentCallCount = 0;
|
let agentCallCount = 0;
|
||||||
let lastWaitedRunId: string | undefined;
|
|
||||||
const replyByRunId = new Map<string, string>();
|
|
||||||
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
const request = opts as { method?: string; params?: unknown };
|
const request = opts as { method?: string; params?: unknown };
|
||||||
@ -217,13 +288,6 @@ describe("subagents", () => {
|
|||||||
if (request.method === "agent") {
|
if (request.method === "agent") {
|
||||||
agentCallCount += 1;
|
agentCallCount += 1;
|
||||||
const runId = `run-${agentCallCount}`;
|
const runId = `run-${agentCallCount}`;
|
||||||
const params = request.params as
|
|
||||||
| { message?: string; sessionKey?: string }
|
|
||||||
| undefined;
|
|
||||||
const message = params?.message ?? "";
|
|
||||||
const reply =
|
|
||||||
message === "Sub-agent announce step." ? "ANNOUNCE_SKIP" : "done";
|
|
||||||
replyByRunId.set(runId, reply);
|
|
||||||
return {
|
return {
|
||||||
runId,
|
runId,
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
@ -231,16 +295,9 @@ describe("subagents", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (request.method === "agent.wait") {
|
if (request.method === "agent.wait") {
|
||||||
const params = request.params as { runId?: string } | undefined;
|
const params = request.params as { timeoutMs?: number } | undefined;
|
||||||
lastWaitedRunId = params?.runId;
|
if (params?.timeoutMs === 0) return { status: "timeout" };
|
||||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
return { status: "ok" };
|
||||||
}
|
|
||||||
if (request.method === "chat.history") {
|
|
||||||
const text =
|
|
||||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
|
||||||
return {
|
|
||||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (request.method === "sessions.delete") {
|
if (request.method === "sessions.delete") {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@ -256,11 +313,14 @@ describe("subagents", () => {
|
|||||||
|
|
||||||
const result = await tool.execute("call3", {
|
const result = await tool.execute("call3", {
|
||||||
task: "do thing",
|
task: "do thing",
|
||||||
timeoutSeconds: 1,
|
runTimeoutSeconds: 1,
|
||||||
model: "claude-haiku-4-5",
|
model: "claude-haiku-4-5",
|
||||||
cleanup: "keep",
|
cleanup: "keep",
|
||||||
});
|
});
|
||||||
expect(result.details).toMatchObject({ status: "ok", reply: "done" });
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
modelApplied: true,
|
||||||
|
});
|
||||||
|
|
||||||
const patchIndex = calls.findIndex(
|
const patchIndex = calls.findIndex(
|
||||||
(call) => call.method === "sessions.patch",
|
(call) => call.method === "sessions.patch",
|
||||||
@ -277,11 +337,10 @@ describe("subagents", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sessions_spawn skips invalid model overrides and continues", async () => {
|
it("sessions_spawn skips invalid model overrides and continues", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
let agentCallCount = 0;
|
let agentCallCount = 0;
|
||||||
let lastWaitedRunId: string | undefined;
|
|
||||||
const replyByRunId = new Map<string, string>();
|
|
||||||
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
const request = opts as { method?: string; params?: unknown };
|
const request = opts as { method?: string; params?: unknown };
|
||||||
@ -292,13 +351,6 @@ describe("subagents", () => {
|
|||||||
if (request.method === "agent") {
|
if (request.method === "agent") {
|
||||||
agentCallCount += 1;
|
agentCallCount += 1;
|
||||||
const runId = `run-${agentCallCount}`;
|
const runId = `run-${agentCallCount}`;
|
||||||
const params = request.params as
|
|
||||||
| { message?: string; sessionKey?: string }
|
|
||||||
| undefined;
|
|
||||||
const message = params?.message ?? "";
|
|
||||||
const reply =
|
|
||||||
message === "Sub-agent announce step." ? "ANNOUNCE_SKIP" : "done";
|
|
||||||
replyByRunId.set(runId, reply);
|
|
||||||
return {
|
return {
|
||||||
runId,
|
runId,
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
@ -306,16 +358,9 @@ describe("subagents", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (request.method === "agent.wait") {
|
if (request.method === "agent.wait") {
|
||||||
const params = request.params as { runId?: string } | undefined;
|
const params = request.params as { timeoutMs?: number } | undefined;
|
||||||
lastWaitedRunId = params?.runId;
|
if (params?.timeoutMs === 0) return { status: "timeout" };
|
||||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
return { status: "ok" };
|
||||||
}
|
|
||||||
if (request.method === "chat.history") {
|
|
||||||
const text =
|
|
||||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
|
||||||
return {
|
|
||||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (request.method === "sessions.delete") {
|
if (request.method === "sessions.delete") {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@ -331,11 +376,11 @@ describe("subagents", () => {
|
|||||||
|
|
||||||
const result = await tool.execute("call4", {
|
const result = await tool.execute("call4", {
|
||||||
task: "do thing",
|
task: "do thing",
|
||||||
timeoutSeconds: 1,
|
runTimeoutSeconds: 1,
|
||||||
model: "bad-model",
|
model: "bad-model",
|
||||||
});
|
});
|
||||||
expect(result.details).toMatchObject({
|
expect(result.details).toMatchObject({
|
||||||
status: "ok",
|
status: "accepted",
|
||||||
modelApplied: false,
|
modelApplied: false,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@ -343,4 +388,36 @@ describe("subagents", () => {
|
|||||||
).toContain("invalid model");
|
).toContain("invalid model");
|
||||||
expect(calls.some((call) => call.method === "agent")).toBe(true);
|
expect(calls.some((call) => call.method === "agent")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sessions_spawn supports legacy timeoutSeconds alias", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
let spawnedTimeout: number | undefined;
|
||||||
|
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const request = opts as { method?: string; params?: unknown };
|
||||||
|
if (request.method === "agent") {
|
||||||
|
const params = request.params as { timeout?: number } | undefined;
|
||||||
|
spawnedTimeout = params?.timeout;
|
||||||
|
return { runId: "run-1", status: "accepted", acceptedAt: 1000 };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createClawdbotTools({
|
||||||
|
agentSessionKey: "main",
|
||||||
|
agentProvider: "whatsapp",
|
||||||
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
|
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||||
|
|
||||||
|
const result = await tool.execute("call5", {
|
||||||
|
task: "do thing",
|
||||||
|
timeoutSeconds: 2,
|
||||||
|
});
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
runId: "run-1",
|
||||||
|
});
|
||||||
|
expect(spawnedTimeout).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
import { buildBootstrapContextFiles } from "./pi-embedded-helpers.js";
|
buildBootstrapContextFiles,
|
||||||
|
formatAssistantErrorText,
|
||||||
|
isContextOverflowError,
|
||||||
|
sanitizeGoogleTurnOrdering,
|
||||||
|
validateGeminiTurns,
|
||||||
|
} from "./pi-embedded-helpers.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENTS_FILENAME,
|
DEFAULT_AGENTS_FILENAME,
|
||||||
type WorkspaceBootstrapFile,
|
type WorkspaceBootstrapFile,
|
||||||
@ -16,6 +23,145 @@ const makeFile = (
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("validateGeminiTurns", () => {
|
||||||
|
it("should return empty array unchanged", () => {
|
||||||
|
const result = validateGeminiTurns([]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return single message unchanged", () => {
|
||||||
|
const msgs: AgentMessage[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "Hello",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = validateGeminiTurns(msgs);
|
||||||
|
expect(result).toEqual(msgs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should leave alternating user/assistant unchanged", () => {
|
||||||
|
const msgs: AgentMessage[] = [
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
{ role: "assistant", content: [{ type: "text", text: "Hi" }] },
|
||||||
|
{ role: "user", content: "How are you?" },
|
||||||
|
{ role: "assistant", content: [{ type: "text", text: "Good!" }] },
|
||||||
|
];
|
||||||
|
const result = validateGeminiTurns(msgs);
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result).toEqual(msgs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should merge consecutive assistant messages", () => {
|
||||||
|
const msgs: AgentMessage[] = [
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Part 1" }],
|
||||||
|
stopReason: "end_turn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Part 2" }],
|
||||||
|
stopReason: "end_turn",
|
||||||
|
},
|
||||||
|
{ role: "user", content: "How are you?" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = validateGeminiTurns(msgs);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0]).toEqual({ role: "user", content: "Hello" });
|
||||||
|
expect(result[1].role).toBe("assistant");
|
||||||
|
expect(result[1].content).toHaveLength(2);
|
||||||
|
expect(result[2]).toEqual({ role: "user", content: "How are you?" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve metadata from later message when merging", () => {
|
||||||
|
const msgs: AgentMessage[] = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Part 1" }],
|
||||||
|
usage: { input: 10, output: 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Part 2" }],
|
||||||
|
usage: { input: 10, output: 10 },
|
||||||
|
stopReason: "end_turn",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = validateGeminiTurns(msgs);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
const merged = result[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
|
expect(merged.usage).toEqual({ input: 10, output: 10 });
|
||||||
|
expect(merged.stopReason).toBe("end_turn");
|
||||||
|
expect(merged.content).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle toolResult messages without merging", () => {
|
||||||
|
const msgs: AgentMessage[] = [
|
||||||
|
{ role: "user", content: "Use tool" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolUseId: "tool-1",
|
||||||
|
content: [{ type: "text", text: "Result" }],
|
||||||
|
},
|
||||||
|
{ role: "user", content: "Next request" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = validateGeminiTurns(msgs);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result).toEqual(msgs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle real-world corrupted sequence", () => {
|
||||||
|
// This is the pattern that causes Gemini errors:
|
||||||
|
// user → assistant → assistant (consecutive, wrong!)
|
||||||
|
const msgs: AgentMessage[] = [
|
||||||
|
{ role: "user", content: "Request 1" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Response A" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolUse", id: "t1", name: "search", input: {} }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolUseId: "t1",
|
||||||
|
content: [{ type: "text", text: "Found data" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Here's the answer" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Extra thoughts" }],
|
||||||
|
},
|
||||||
|
{ role: "user", content: "Request 2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = validateGeminiTurns(msgs);
|
||||||
|
|
||||||
|
// Should merge the consecutive assistants
|
||||||
|
expect(result[0].role).toBe("user");
|
||||||
|
expect(result[1].role).toBe("assistant");
|
||||||
|
expect(result[2].role).toBe("toolResult");
|
||||||
|
expect(result[3].role).toBe("assistant");
|
||||||
|
expect(result[4].role).toBe("user");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildBootstrapContextFiles", () => {
|
describe("buildBootstrapContextFiles", () => {
|
||||||
it("keeps missing markers", () => {
|
it("keeps missing markers", () => {
|
||||||
const files = [makeFile({ missing: true, content: undefined })];
|
const files = [makeFile({ missing: true, content: undefined })];
|
||||||
@ -46,3 +192,58 @@ describe("buildBootstrapContextFiles", () => {
|
|||||||
expect(result?.content.endsWith(long.slice(-120))).toBe(true);
|
expect(result?.content.endsWith(long.slice(-120))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isContextOverflowError", () => {
|
||||||
|
it("matches known overflow hints", () => {
|
||||||
|
const samples = [
|
||||||
|
"request_too_large",
|
||||||
|
"Request exceeds the maximum size",
|
||||||
|
"context length exceeded",
|
||||||
|
"Maximum context length",
|
||||||
|
"413 Request Entity Too Large",
|
||||||
|
];
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(isContextOverflowError(sample)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated errors", () => {
|
||||||
|
expect(isContextOverflowError("rate limit exceeded")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatAssistantErrorText", () => {
|
||||||
|
const makeAssistantError = (errorMessage: string): AssistantMessage =>
|
||||||
|
({
|
||||||
|
stopReason: "error",
|
||||||
|
errorMessage,
|
||||||
|
}) as AssistantMessage;
|
||||||
|
|
||||||
|
it("returns a friendly message for context overflow", () => {
|
||||||
|
const msg = makeAssistantError("request_too_large");
|
||||||
|
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeGoogleTurnOrdering", () => {
|
||||||
|
it("prepends a synthetic user turn when history starts with assistant", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "call_1", name: "bash", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = sanitizeGoogleTurnOrdering(input);
|
||||||
|
expect(out[0]?.role).toBe("user");
|
||||||
|
expect(out[1]?.role).toBe("assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when history starts with user", () => {
|
||||||
|
const input = [{ role: "user", content: "hi" }] satisfies AgentMessage[];
|
||||||
|
const out = sanitizeGoogleTurnOrdering(input);
|
||||||
|
expect(out).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -104,6 +104,40 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT = "(session bootstrap)";
|
||||||
|
|
||||||
|
export function isGoogleModelApi(api?: string | null): boolean {
|
||||||
|
return api === "google-gemini-cli" || api === "google-generative-ai";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeGoogleTurnOrdering(
|
||||||
|
messages: AgentMessage[],
|
||||||
|
): AgentMessage[] {
|
||||||
|
const first = messages[0] as
|
||||||
|
| { role?: unknown; content?: unknown }
|
||||||
|
| undefined;
|
||||||
|
const role = first?.role;
|
||||||
|
const content = first?.content;
|
||||||
|
if (
|
||||||
|
role === "user" &&
|
||||||
|
typeof content === "string" &&
|
||||||
|
content.trim() === GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT
|
||||||
|
) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
if (role !== "assistant") return messages;
|
||||||
|
|
||||||
|
// Cloud Code Assist rejects histories that begin with a model turn (tool call or text).
|
||||||
|
// Prepend a tiny synthetic user turn so the rest of the transcript can be used.
|
||||||
|
const bootstrap: AgentMessage = {
|
||||||
|
role: "user",
|
||||||
|
content: GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as AgentMessage;
|
||||||
|
|
||||||
|
return [bootstrap, ...messages];
|
||||||
|
}
|
||||||
|
|
||||||
export function buildBootstrapContextFiles(
|
export function buildBootstrapContextFiles(
|
||||||
files: WorkspaceBootstrapFile[],
|
files: WorkspaceBootstrapFile[],
|
||||||
): EmbeddedContextFile[] {
|
): EmbeddedContextFile[] {
|
||||||
@ -126,6 +160,18 @@ export function buildBootstrapContextFiles(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isContextOverflowError(errorMessage?: string): boolean {
|
||||||
|
if (!errorMessage) return false;
|
||||||
|
const lower = errorMessage.toLowerCase();
|
||||||
|
return (
|
||||||
|
lower.includes("request_too_large") ||
|
||||||
|
lower.includes("request exceeds the maximum size") ||
|
||||||
|
lower.includes("context length exceeded") ||
|
||||||
|
lower.includes("maximum context length") ||
|
||||||
|
(lower.includes("413") && lower.includes("too large"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAssistantErrorText(
|
export function formatAssistantErrorText(
|
||||||
msg: AssistantMessage,
|
msg: AssistantMessage,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
@ -133,6 +179,14 @@ export function formatAssistantErrorText(
|
|||||||
const raw = (msg.errorMessage ?? "").trim();
|
const raw = (msg.errorMessage ?? "").trim();
|
||||||
if (!raw) return "LLM request failed with an unknown error.";
|
if (!raw) return "LLM request failed with an unknown error.";
|
||||||
|
|
||||||
|
// Check for context overflow (413) errors
|
||||||
|
if (isContextOverflowError(raw)) {
|
||||||
|
return (
|
||||||
|
"Context overflow: the conversation history is too large. " +
|
||||||
|
"Use /new or /reset to start a fresh session."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const invalidRequest = raw.match(
|
const invalidRequest = raw.match(
|
||||||
/"type":"invalid_request_error".*?"message":"([^"]+)"/,
|
/"type":"invalid_request_error".*?"message":"([^"]+)"/,
|
||||||
);
|
);
|
||||||
@ -218,3 +272,77 @@ export function pickFallbackThinkingLevel(params: {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and fixes conversation turn sequences for Gemini API.
|
||||||
|
* Gemini requires strict alternating user→assistant→tool→user pattern.
|
||||||
|
* This function:
|
||||||
|
* 1. Detects consecutive messages from the same role
|
||||||
|
* 2. Merges consecutive assistant messages together
|
||||||
|
* 3. Preserves metadata (usage, stopReason, etc.)
|
||||||
|
*
|
||||||
|
* This prevents the "function call turn comes immediately after a user turn or after a function response turn" error.
|
||||||
|
*/
|
||||||
|
export function validateGeminiTurns(messages: AgentMessage[]): AgentMessage[] {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: AgentMessage[] = [];
|
||||||
|
let lastRole: string | undefined;
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg || typeof msg !== "object") {
|
||||||
|
result.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgRole = (msg as { role?: unknown }).role as string | undefined;
|
||||||
|
if (!msgRole) {
|
||||||
|
result.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this message has the same role as the last one
|
||||||
|
if (msgRole === lastRole && lastRole === "assistant") {
|
||||||
|
// Merge consecutive assistant messages
|
||||||
|
const lastMsg = result[result.length - 1];
|
||||||
|
const currentMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
|
|
||||||
|
if (lastMsg && typeof lastMsg === "object") {
|
||||||
|
const lastAsst = lastMsg as Extract<
|
||||||
|
AgentMessage,
|
||||||
|
{ role: "assistant" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
// Merge content blocks
|
||||||
|
const mergedContent = [
|
||||||
|
...(Array.isArray(lastAsst.content) ? lastAsst.content : []),
|
||||||
|
...(Array.isArray(currentMsg.content) ? currentMsg.content : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Preserve metadata from the later message (more recent)
|
||||||
|
const merged: Extract<AgentMessage, { role: "assistant" }> = {
|
||||||
|
...lastAsst,
|
||||||
|
content: mergedContent,
|
||||||
|
// Take timestamps, usage, stopReason from the newer message if present
|
||||||
|
...(currentMsg.usage && { usage: currentMsg.usage }),
|
||||||
|
...(currentMsg.stopReason && { stopReason: currentMsg.stopReason }),
|
||||||
|
...(currentMsg.errorMessage && {
|
||||||
|
errorMessage: currentMsg.errorMessage,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace the last message with merged version
|
||||||
|
result[result.length - 1] = merged;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a consecutive duplicate, add normally
|
||||||
|
result.push(msg);
|
||||||
|
lastRole = msgRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
applyGoogleTurnOrderingFix,
|
||||||
buildEmbeddedSandboxInfo,
|
buildEmbeddedSandboxInfo,
|
||||||
splitSdkTools,
|
splitSdkTools,
|
||||||
} from "./pi-embedded-runner.js";
|
} from "./pi-embedded-runner.js";
|
||||||
@ -102,3 +104,64 @@ describe("splitSdkTools", () => {
|
|||||||
expect(customTools.map((tool) => tool.name)).toEqual(["browser"]);
|
expect(customTools.map((tool) => tool.name)).toEqual(["browser"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("applyGoogleTurnOrderingFix", () => {
|
||||||
|
const makeAssistantFirst = () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "call_1", name: "bash", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
it("prepends a bootstrap once and records a marker for Google models", () => {
|
||||||
|
const sessionManager = SessionManager.inMemory();
|
||||||
|
const warn = vi.fn();
|
||||||
|
const input = makeAssistantFirst();
|
||||||
|
const first = applyGoogleTurnOrderingFix({
|
||||||
|
messages: input,
|
||||||
|
modelApi: "google-generative-ai",
|
||||||
|
sessionManager,
|
||||||
|
sessionId: "session:1",
|
||||||
|
warn,
|
||||||
|
});
|
||||||
|
expect(first.messages[0]?.role).toBe("user");
|
||||||
|
expect(first.messages[1]?.role).toBe("assistant");
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
sessionManager
|
||||||
|
.getEntries()
|
||||||
|
.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.type === "custom" &&
|
||||||
|
entry.customType === "google-turn-ordering-bootstrap",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
applyGoogleTurnOrderingFix({
|
||||||
|
messages: input,
|
||||||
|
modelApi: "google-generative-ai",
|
||||||
|
sessionManager,
|
||||||
|
sessionId: "session:1",
|
||||||
|
warn,
|
||||||
|
});
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips non-Google models", () => {
|
||||||
|
const sessionManager = SessionManager.inMemory();
|
||||||
|
const warn = vi.fn();
|
||||||
|
const input = makeAssistantFirst();
|
||||||
|
const result = applyGoogleTurnOrderingFix({
|
||||||
|
messages: input,
|
||||||
|
modelApi: "openai",
|
||||||
|
sessionManager,
|
||||||
|
sessionId: "session:2",
|
||||||
|
warn,
|
||||||
|
});
|
||||||
|
expect(result.messages).toBe(input);
|
||||||
|
expect(warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentMessage,
|
AgentMessage,
|
||||||
@ -16,7 +18,6 @@ import {
|
|||||||
SettingsManager,
|
SettingsManager,
|
||||||
type Skill,
|
type Skill,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
import type { TSchema } from "@sinclair/typebox";
|
|
||||||
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
||||||
import type {
|
import type {
|
||||||
ReasoningLevel,
|
ReasoningLevel,
|
||||||
@ -24,6 +25,7 @@ import type {
|
|||||||
VerboseLevel,
|
VerboseLevel,
|
||||||
} from "../auto-reply/thinking.js";
|
} from "../auto-reply/thinking.js";
|
||||||
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
||||||
|
import { isCacheEnabled, resolveCacheTtlMs } from "../config/cache-utils.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
@ -40,7 +42,11 @@ import {
|
|||||||
markAuthProfileUsed,
|
markAuthProfileUsed,
|
||||||
} from "./auth-profiles.js";
|
} from "./auth-profiles.js";
|
||||||
import type { BashElevatedDefaults } from "./bash-tools.js";
|
import type { BashElevatedDefaults } from "./bash-tools.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
import {
|
||||||
|
DEFAULT_CONTEXT_TOKENS,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_PROVIDER,
|
||||||
|
} from "./defaults.js";
|
||||||
import {
|
import {
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
getApiKeyForModel,
|
getApiKeyForModel,
|
||||||
@ -53,10 +59,14 @@ import {
|
|||||||
formatAssistantErrorText,
|
formatAssistantErrorText,
|
||||||
isAuthAssistantError,
|
isAuthAssistantError,
|
||||||
isAuthErrorMessage,
|
isAuthErrorMessage,
|
||||||
|
isContextOverflowError,
|
||||||
|
isGoogleModelApi,
|
||||||
isRateLimitAssistantError,
|
isRateLimitAssistantError,
|
||||||
isRateLimitErrorMessage,
|
isRateLimitErrorMessage,
|
||||||
pickFallbackThinkingLevel,
|
pickFallbackThinkingLevel,
|
||||||
|
sanitizeGoogleTurnOrdering,
|
||||||
sanitizeSessionMessagesImages,
|
sanitizeSessionMessagesImages,
|
||||||
|
validateGeminiTurns,
|
||||||
} from "./pi-embedded-helpers.js";
|
} from "./pi-embedded-helpers.js";
|
||||||
import {
|
import {
|
||||||
type BlockReplyChunking,
|
type BlockReplyChunking,
|
||||||
@ -67,6 +77,9 @@ import {
|
|||||||
extractAssistantThinking,
|
extractAssistantThinking,
|
||||||
formatReasoningMarkdown,
|
formatReasoningMarkdown,
|
||||||
} from "./pi-embedded-utils.js";
|
} from "./pi-embedded-utils.js";
|
||||||
|
import { setContextPruningRuntime } from "./pi-extensions/context-pruning/runtime.js";
|
||||||
|
import { computeEffectiveSettings } from "./pi-extensions/context-pruning/settings.js";
|
||||||
|
import { makeToolPrunablePredicate } from "./pi-extensions/context-pruning/tools.js";
|
||||||
import { toToolDefinitions } from "./pi-tool-definition-adapter.js";
|
import { toToolDefinitions } from "./pi-tool-definition-adapter.js";
|
||||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
import { resolveSandboxContext } from "./sandbox.js";
|
import { resolveSandboxContext } from "./sandbox.js";
|
||||||
@ -82,6 +95,84 @@ import { buildAgentSystemPromptAppend } from "./system-prompt.js";
|
|||||||
import { normalizeUsage, type UsageLike } from "./usage.js";
|
import { normalizeUsage, type UsageLike } from "./usage.js";
|
||||||
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
||||||
|
|
||||||
|
// Optional features can be implemented as Pi extensions that run in the same Node process.
|
||||||
|
// We configure context pruning per-session via a WeakMap registry keyed by the SessionManager instance.
|
||||||
|
|
||||||
|
function resolvePiExtensionPath(id: string): string {
|
||||||
|
const self = fileURLToPath(import.meta.url);
|
||||||
|
const dir = path.dirname(self);
|
||||||
|
// In dev this file is `.ts` (tsx), in production it's `.js`.
|
||||||
|
const ext = path.extname(self) === ".ts" ? "ts" : "js";
|
||||||
|
return path.join(dir, "pi-extensions", `${id}.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveContextWindowTokens(params: {
|
||||||
|
cfg: ClawdbotConfig | undefined;
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
model: Model<Api> | undefined;
|
||||||
|
}): number {
|
||||||
|
const fromModel =
|
||||||
|
typeof params.model?.contextWindow === "number" &&
|
||||||
|
Number.isFinite(params.model.contextWindow) &&
|
||||||
|
params.model.contextWindow > 0
|
||||||
|
? params.model.contextWindow
|
||||||
|
: undefined;
|
||||||
|
if (fromModel) return fromModel;
|
||||||
|
|
||||||
|
const fromModelsConfig = (() => {
|
||||||
|
const providers = params.cfg?.models?.providers as
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
{ models?: Array<{ id?: string; contextWindow?: number }> }
|
||||||
|
>
|
||||||
|
| undefined;
|
||||||
|
const providerEntry = providers?.[params.provider];
|
||||||
|
const models = Array.isArray(providerEntry?.models)
|
||||||
|
? providerEntry.models
|
||||||
|
: [];
|
||||||
|
const match = models.find((m) => m?.id === params.modelId);
|
||||||
|
return typeof match?.contextWindow === "number" && match.contextWindow > 0
|
||||||
|
? match.contextWindow
|
||||||
|
: undefined;
|
||||||
|
})();
|
||||||
|
if (fromModelsConfig) return fromModelsConfig;
|
||||||
|
|
||||||
|
const fromAgentConfig =
|
||||||
|
typeof params.cfg?.agent?.contextTokens === "number" &&
|
||||||
|
Number.isFinite(params.cfg.agent.contextTokens) &&
|
||||||
|
params.cfg.agent.contextTokens > 0
|
||||||
|
? Math.floor(params.cfg.agent.contextTokens)
|
||||||
|
: undefined;
|
||||||
|
if (fromAgentConfig) return fromAgentConfig;
|
||||||
|
|
||||||
|
return DEFAULT_CONTEXT_TOKENS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContextPruningExtension(params: {
|
||||||
|
cfg: ClawdbotConfig | undefined;
|
||||||
|
sessionManager: SessionManager;
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
model: Model<Api> | undefined;
|
||||||
|
}): { additionalExtensionPaths?: string[] } {
|
||||||
|
const raw = params.cfg?.agent?.contextPruning;
|
||||||
|
if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {};
|
||||||
|
|
||||||
|
const settings = computeEffectiveSettings(raw);
|
||||||
|
if (!settings) return {};
|
||||||
|
|
||||||
|
setContextPruningRuntime(params.sessionManager, {
|
||||||
|
settings,
|
||||||
|
contextWindowTokens: resolveContextWindowTokens(params),
|
||||||
|
isToolPrunable: makeToolPrunablePredicate(settings.tools),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
additionalExtensionPaths: [resolvePiExtensionPath("context-pruning")],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type EmbeddedPiAgentMeta = {
|
export type EmbeddedPiAgentMeta = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
@ -155,6 +246,80 @@ type EmbeddedPiQueueHandle = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const log = createSubsystemLogger("agent/embedded");
|
const log = createSubsystemLogger("agent/embedded");
|
||||||
|
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
||||||
|
|
||||||
|
type CustomEntryLike = { type?: unknown; customType?: unknown };
|
||||||
|
|
||||||
|
function hasGoogleTurnOrderingMarker(sessionManager: SessionManager): boolean {
|
||||||
|
try {
|
||||||
|
return sessionManager
|
||||||
|
.getEntries()
|
||||||
|
.some(
|
||||||
|
(entry) =>
|
||||||
|
(entry as CustomEntryLike)?.type === "custom" &&
|
||||||
|
(entry as CustomEntryLike)?.customType ===
|
||||||
|
GOOGLE_TURN_ORDERING_CUSTOM_TYPE,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markGoogleTurnOrderingMarker(sessionManager: SessionManager): void {
|
||||||
|
try {
|
||||||
|
sessionManager.appendCustomEntry(GOOGLE_TURN_ORDERING_CUSTOM_TYPE, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore marker persistence failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyGoogleTurnOrderingFix(params: {
|
||||||
|
messages: AgentMessage[];
|
||||||
|
modelApi?: string | null;
|
||||||
|
sessionManager: SessionManager;
|
||||||
|
sessionId: string;
|
||||||
|
warn?: (message: string) => void;
|
||||||
|
}): { messages: AgentMessage[]; didPrepend: boolean } {
|
||||||
|
if (!isGoogleModelApi(params.modelApi)) {
|
||||||
|
return { messages: params.messages, didPrepend: false };
|
||||||
|
}
|
||||||
|
const first = params.messages[0] as
|
||||||
|
| { role?: unknown; content?: unknown }
|
||||||
|
| undefined;
|
||||||
|
if (first?.role !== "assistant") {
|
||||||
|
return { messages: params.messages, didPrepend: false };
|
||||||
|
}
|
||||||
|
const sanitized = sanitizeGoogleTurnOrdering(params.messages);
|
||||||
|
const didPrepend = sanitized !== params.messages;
|
||||||
|
if (didPrepend && !hasGoogleTurnOrderingMarker(params.sessionManager)) {
|
||||||
|
const warn = params.warn ?? ((message: string) => log.warn(message));
|
||||||
|
warn(
|
||||||
|
`google turn ordering fixup: prepended user bootstrap (sessionId=${params.sessionId})`,
|
||||||
|
);
|
||||||
|
markGoogleTurnOrderingMarker(params.sessionManager);
|
||||||
|
}
|
||||||
|
return { messages: sanitized, didPrepend };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sanitizeSessionHistory(params: {
|
||||||
|
messages: AgentMessage[];
|
||||||
|
modelApi?: string | null;
|
||||||
|
sessionManager: SessionManager;
|
||||||
|
sessionId: string;
|
||||||
|
}): Promise<AgentMessage[]> {
|
||||||
|
const sanitizedImages = await sanitizeSessionMessagesImages(
|
||||||
|
params.messages,
|
||||||
|
"session:history",
|
||||||
|
);
|
||||||
|
return applyGoogleTurnOrderingFix({
|
||||||
|
messages: sanitizedImages,
|
||||||
|
modelApi: params.modelApi,
|
||||||
|
sessionManager: params.sessionManager,
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
}).messages;
|
||||||
|
}
|
||||||
|
|
||||||
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();
|
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();
|
||||||
type EmbeddedRunWaiter = {
|
type EmbeddedRunWaiter = {
|
||||||
@ -163,6 +328,66 @@ type EmbeddedRunWaiter = {
|
|||||||
};
|
};
|
||||||
const EMBEDDED_RUN_WAITERS = new Map<string, Set<EmbeddedRunWaiter>>();
|
const EMBEDDED_RUN_WAITERS = new Map<string, Set<EmbeddedRunWaiter>>();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SessionManager Pre-warming Cache
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type SessionManagerCacheEntry = {
|
||||||
|
sessionFile: string;
|
||||||
|
loadedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSION_MANAGER_CACHE = new Map<string, SessionManagerCacheEntry>();
|
||||||
|
const DEFAULT_SESSION_MANAGER_TTL_MS = 45_000; // 45 seconds
|
||||||
|
|
||||||
|
function getSessionManagerTtl(): number {
|
||||||
|
return resolveCacheTtlMs({
|
||||||
|
envValue: process.env.CLAWDBOT_SESSION_MANAGER_CACHE_TTL_MS,
|
||||||
|
defaultTtlMs: DEFAULT_SESSION_MANAGER_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionManagerCacheEnabled(): boolean {
|
||||||
|
return isCacheEnabled(getSessionManagerTtl());
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackSessionManagerAccess(sessionFile: string): void {
|
||||||
|
if (!isSessionManagerCacheEnabled()) return;
|
||||||
|
const now = Date.now();
|
||||||
|
SESSION_MANAGER_CACHE.set(sessionFile, {
|
||||||
|
sessionFile,
|
||||||
|
loadedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionManagerCached(sessionFile: string): boolean {
|
||||||
|
if (!isSessionManagerCacheEnabled()) return false;
|
||||||
|
const entry = SESSION_MANAGER_CACHE.get(sessionFile);
|
||||||
|
if (!entry) return false;
|
||||||
|
const now = Date.now();
|
||||||
|
const ttl = getSessionManagerTtl();
|
||||||
|
return now - entry.loadedAt <= ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prewarmSessionFile(sessionFile: string): Promise<void> {
|
||||||
|
if (!isSessionManagerCacheEnabled()) return;
|
||||||
|
if (isSessionManagerCached(sessionFile)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read a small chunk to encourage OS page cache warmup.
|
||||||
|
const handle = await fs.open(sessionFile, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(4096);
|
||||||
|
await handle.read(buffer, 0, buffer.length, 0);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
trackSessionManagerAccess(sessionFile);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist yet, SessionManager will create it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAbortError = (err: unknown): boolean => {
|
const isAbortError = (err: unknown): boolean => {
|
||||||
if (!err || typeof err !== "object") return false;
|
if (!err || typeof err !== "object") return false;
|
||||||
const name = "name" in err ? String(err.name) : "";
|
const name = "name" in err ? String(err.name) : "";
|
||||||
@ -269,7 +494,7 @@ export function buildEmbeddedSandboxInfo(
|
|||||||
|
|
||||||
const BUILT_IN_TOOL_NAMES = new Set(["read", "bash", "edit", "write"]);
|
const BUILT_IN_TOOL_NAMES = new Set(["read", "bash", "edit", "write"]);
|
||||||
|
|
||||||
type AnyAgentTool = AgentTool<TSchema, unknown>;
|
type AnyAgentTool = AgentTool;
|
||||||
|
|
||||||
export function splitSdkTools(options: {
|
export function splitSdkTools(options: {
|
||||||
tools: AnyAgentTool[];
|
tools: AnyAgentTool[];
|
||||||
@ -573,18 +798,30 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
tools,
|
tools,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pre-warm session file to bring it into OS page cache
|
||||||
|
await prewarmSessionFile(params.sessionFile);
|
||||||
const sessionManager = SessionManager.open(params.sessionFile);
|
const sessionManager = SessionManager.open(params.sessionFile);
|
||||||
|
trackSessionManagerAccess(params.sessionFile);
|
||||||
const settingsManager = SettingsManager.create(
|
const settingsManager = SettingsManager.create(
|
||||||
effectiveWorkspace,
|
effectiveWorkspace,
|
||||||
agentDir,
|
agentDir,
|
||||||
);
|
);
|
||||||
|
const pruning = buildContextPruningExtension({
|
||||||
|
cfg: params.config,
|
||||||
|
sessionManager,
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
const additionalExtensionPaths = pruning.additionalExtensionPaths;
|
||||||
|
|
||||||
const { builtInTools, customTools } = splitSdkTools({
|
const { builtInTools, customTools } = splitSdkTools({
|
||||||
tools,
|
tools,
|
||||||
sandboxEnabled: !!sandbox?.enabled,
|
sandboxEnabled: !!sandbox?.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||||
|
({ session } = await createAgentSession({
|
||||||
cwd: resolvedWorkspace,
|
cwd: resolvedWorkspace,
|
||||||
agentDir,
|
agentDir,
|
||||||
authStorage,
|
authStorage,
|
||||||
@ -598,15 +835,19 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
settingsManager,
|
settingsManager,
|
||||||
skills: promptSkills,
|
skills: promptSkills,
|
||||||
contextFiles,
|
contextFiles,
|
||||||
});
|
additionalExtensionPaths,
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prior = await sanitizeSessionMessagesImages(
|
const prior = await sanitizeSessionHistory({
|
||||||
session.messages,
|
messages: session.messages,
|
||||||
"session:history",
|
modelApi: model.api,
|
||||||
);
|
sessionManager,
|
||||||
if (prior.length > 0) {
|
sessionId: params.sessionId,
|
||||||
session.agent.replaceMessages(prior);
|
});
|
||||||
|
const validated = validateGeminiTurns(prior);
|
||||||
|
if (validated.length > 0) {
|
||||||
|
session.agent.replaceMessages(validated);
|
||||||
}
|
}
|
||||||
const result = await session.compact(params.customInstructions);
|
const result = await session.compact(params.customInstructions);
|
||||||
return {
|
return {
|
||||||
@ -882,18 +1123,32 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
tools,
|
tools,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pre-warm session file to bring it into OS page cache
|
||||||
|
await prewarmSessionFile(params.sessionFile);
|
||||||
const sessionManager = SessionManager.open(params.sessionFile);
|
const sessionManager = SessionManager.open(params.sessionFile);
|
||||||
|
trackSessionManagerAccess(params.sessionFile);
|
||||||
const settingsManager = SettingsManager.create(
|
const settingsManager = SettingsManager.create(
|
||||||
effectiveWorkspace,
|
effectiveWorkspace,
|
||||||
agentDir,
|
agentDir,
|
||||||
);
|
);
|
||||||
|
const pruning = buildContextPruningExtension({
|
||||||
|
cfg: params.config,
|
||||||
|
sessionManager,
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
const additionalExtensionPaths = pruning.additionalExtensionPaths;
|
||||||
|
|
||||||
const { builtInTools, customTools } = splitSdkTools({
|
const { builtInTools, customTools } = splitSdkTools({
|
||||||
tools,
|
tools,
|
||||||
sandboxEnabled: !!sandbox?.enabled,
|
sandboxEnabled: !!sandbox?.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
let session: Awaited<
|
||||||
|
ReturnType<typeof createAgentSession>
|
||||||
|
>["session"];
|
||||||
|
({ session } = await createAgentSession({
|
||||||
cwd: resolvedWorkspace,
|
cwd: resolvedWorkspace,
|
||||||
agentDir,
|
agentDir,
|
||||||
authStorage,
|
authStorage,
|
||||||
@ -909,14 +1164,23 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
settingsManager,
|
settingsManager,
|
||||||
skills: promptSkills,
|
skills: promptSkills,
|
||||||
contextFiles,
|
contextFiles,
|
||||||
});
|
additionalExtensionPaths,
|
||||||
|
}));
|
||||||
|
|
||||||
const prior = await sanitizeSessionMessagesImages(
|
try {
|
||||||
session.messages,
|
const prior = await sanitizeSessionHistory({
|
||||||
"session:history",
|
messages: session.messages,
|
||||||
);
|
modelApi: model.api,
|
||||||
if (prior.length > 0) {
|
sessionManager,
|
||||||
session.agent.replaceMessages(prior);
|
sessionId: params.sessionId,
|
||||||
|
});
|
||||||
|
const validated = validateGeminiTurns(prior);
|
||||||
|
if (validated.length > 0) {
|
||||||
|
session.agent.replaceMessages(validated);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
session.dispose();
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
let aborted = Boolean(params.abortSignal?.aborted);
|
let aborted = Boolean(params.abortSignal?.aborted);
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
@ -925,21 +1189,27 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
if (isTimeout) timedOut = true;
|
if (isTimeout) timedOut = true;
|
||||||
void session.abort();
|
void session.abort();
|
||||||
};
|
};
|
||||||
const subscription = subscribeEmbeddedPiSession({
|
let subscription: ReturnType<typeof subscribeEmbeddedPiSession>;
|
||||||
session,
|
try {
|
||||||
runId: params.runId,
|
subscription = subscribeEmbeddedPiSession({
|
||||||
verboseLevel: params.verboseLevel,
|
session,
|
||||||
reasoningMode: params.reasoningLevel ?? "off",
|
runId: params.runId,
|
||||||
shouldEmitToolResult: params.shouldEmitToolResult,
|
verboseLevel: params.verboseLevel,
|
||||||
onToolResult: params.onToolResult,
|
reasoningMode: params.reasoningLevel ?? "off",
|
||||||
onReasoningStream: params.onReasoningStream,
|
shouldEmitToolResult: params.shouldEmitToolResult,
|
||||||
onBlockReply: params.onBlockReply,
|
onToolResult: params.onToolResult,
|
||||||
blockReplyBreak: params.blockReplyBreak,
|
onReasoningStream: params.onReasoningStream,
|
||||||
blockReplyChunking: params.blockReplyChunking,
|
onBlockReply: params.onBlockReply,
|
||||||
onPartialReply: params.onPartialReply,
|
blockReplyBreak: params.blockReplyBreak,
|
||||||
onAgentEvent: params.onAgentEvent,
|
blockReplyChunking: params.blockReplyChunking,
|
||||||
enforceFinalTag: params.enforceFinalTag,
|
onPartialReply: params.onPartialReply,
|
||||||
});
|
onAgentEvent: params.onAgentEvent,
|
||||||
|
enforceFinalTag: params.enforceFinalTag,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
session.dispose();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
assistantTexts,
|
assistantTexts,
|
||||||
toolMetas,
|
toolMetas,
|
||||||
@ -1033,6 +1303,26 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
}
|
}
|
||||||
if (promptError && !aborted) {
|
if (promptError && !aborted) {
|
||||||
const errorText = describeUnknownError(promptError);
|
const errorText = describeUnknownError(promptError);
|
||||||
|
if (isContextOverflowError(errorText)) {
|
||||||
|
return {
|
||||||
|
payloads: [
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
"Context overflow: the conversation history is too large for the model. " +
|
||||||
|
"Use /new or /reset to start a fresh session, or try a model with a larger context window.",
|
||||||
|
isError: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
agentMeta: {
|
||||||
|
sessionId: sessionIdUsed,
|
||||||
|
provider,
|
||||||
|
model: model.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(isAuthErrorMessage(errorText) ||
|
(isAuthErrorMessage(errorText) ||
|
||||||
isRateLimitErrorMessage(errorText)) &&
|
isRateLimitErrorMessage(errorText)) &&
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user