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
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
accept-android-sdk-licenses: false
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
@ -50,7 +50,7 @@
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing).
|
||||
- **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
|
||||
- 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).
|
||||
|
||||
### 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: 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).
|
||||
- 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.
|
||||
- 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: 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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: 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: 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: 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.
|
||||
- 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: 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 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.
|
||||
- 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.
|
||||
@ -49,6 +69,8 @@
|
||||
- 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: 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.
|
||||
- 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.
|
||||
@ -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: 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: 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.
|
||||
- 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.
|
||||
@ -70,6 +93,7 @@
|
||||
- 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: 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`).
|
||||
- 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.
|
||||
@ -78,6 +102,7 @@
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
@ -117,6 +142,7 @@
|
||||
- Control UI: show a reading indicator bubble while the assistant is responding.
|
||||
- Control UI: animate reading indicator dots (honors reduced-motion).
|
||||
- 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: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
|
||||
- Control UI: add Chat focus mode toggle to collapse header + sidebar.
|
||||
@ -171,6 +197,11 @@
|
||||
- Refactor: centralize group allowlist/mention policy across providers.
|
||||
- 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
|
||||
|
||||
### 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/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/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>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
@ -19,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "2.0.0-beta3"
|
||||
versionCode = 20260107
|
||||
versionName = "2026.1.7"
|
||||
}
|
||||
|
||||
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 {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
|
||||
@ -5,8 +5,10 @@ import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.util.Base64
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.ExifInterface
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
@ -86,18 +88,19 @@ class CameraCaptureManager(private val context: Context) {
|
||||
provider.unbindAll()
|
||||
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)
|
||||
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||
val rotated = rotateBitmapByExif(decoded, orientation)
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
|
||||
if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) {
|
||||
val h =
|
||||
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
decoded.scale(maxWidth, h)
|
||||
rotated.scale(maxWidth, h)
|
||||
} else {
|
||||
decoded
|
||||
rotated
|
||||
}
|
||||
|
||||
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? =
|
||||
when {
|
||||
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 ->
|
||||
val file = File.createTempFile("clawdbot-snap-", ".jpg")
|
||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||
@ -263,13 +292,19 @@ private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||
executor,
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
file.delete()
|
||||
cont.resumeWithException(exception)
|
||||
}
|
||||
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
try {
|
||||
val exif = ExifInterface(file.absolutePath)
|
||||
val orientation = exif.getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
val bytes = file.readBytes()
|
||||
cont.resume(bytes)
|
||||
cont.resume(Pair(bytes, orientation))
|
||||
} catch (e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
} finally {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
|
||||
@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"))
|
||||
}
|
||||
|
||||
let params: [String: AnyCodable]?
|
||||
let params: [String: ClawdbotProtocol.AnyCodable]?
|
||||
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
|
||||
guard let data = json.data(using: .utf8) else {
|
||||
return BridgeRPCResponse(
|
||||
@ -238,7 +238,7 @@ actor BridgeServer {
|
||||
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
|
||||
}
|
||||
do {
|
||||
params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
|
||||
params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||
} catch {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
@ -360,16 +360,16 @@ actor BridgeServer {
|
||||
"reason \(reason)",
|
||||
].compactMap(\.self).joined(separator: " · ")
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"text": AnyCodable(summary),
|
||||
"instanceId": AnyCodable(nodeId),
|
||||
"host": AnyCodable(host),
|
||||
"mode": AnyCodable("node"),
|
||||
"reason": AnyCodable(reason),
|
||||
"tags": AnyCodable(tags),
|
||||
var params: [String: ClawdbotProtocol.AnyCodable] = [
|
||||
"text": ClawdbotProtocol.AnyCodable(summary),
|
||||
"instanceId": ClawdbotProtocol.AnyCodable(nodeId),
|
||||
"host": ClawdbotProtocol.AnyCodable(host),
|
||||
"mode": ClawdbotProtocol.AnyCodable("node"),
|
||||
"reason": ClawdbotProtocol.AnyCodable(reason),
|
||||
"tags": ClawdbotProtocol.AnyCodable(tags),
|
||||
]
|
||||
if let ip { params["ip"] = AnyCodable(ip) }
|
||||
if let version { params["version"] = AnyCodable(version) }
|
||||
if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
|
||||
if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
|
||||
await GatewayConnection.shared.sendSystemEvent(params)
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
enum ClawdbotConfigFile {
|
||||
@ -32,7 +33,8 @@ enum ClawdbotConfigFile {
|
||||
}
|
||||
|
||||
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 {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
|
||||
@ -3,9 +3,9 @@ import Foundation
|
||||
enum ClawdbotEnv {
|
||||
static func path(_ key: String) -> String? {
|
||||
// Normalize env overrides once so UI + file IO stay consistent.
|
||||
guard let value = ProcessInfo.processInfo.environment[key]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty
|
||||
guard let raw = getenv(key) else { return nil }
|
||||
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
enum ConfigStore {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct CronJobEditor: View {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension CronSettings {
|
||||
|
||||
@ -16,12 +16,11 @@ enum GatewayAgentChannel: String, CaseIterable, Sendable {
|
||||
func shouldDeliver(_ isLast: Bool) -> Bool {
|
||||
switch self {
|
||||
case .webchat:
|
||||
return false
|
||||
false
|
||||
case .last:
|
||||
return isLast
|
||||
isLast
|
||||
case .whatsapp, .telegram:
|
||||
return true
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -208,9 +208,15 @@ final class GatewayDiscoveryModel {
|
||||
return merged
|
||||
}
|
||||
|
||||
static func parseGatewayTXT(_ txt: [String: String])
|
||||
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, gatewayPort: Int?, cliPath: String?)
|
||||
{
|
||||
struct GatewayTXT: Equatable {
|
||||
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 tailnetDns: String?
|
||||
var sshPort = 22
|
||||
@ -242,7 +248,12 @@ final class GatewayDiscoveryModel {
|
||||
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 {
|
||||
|
||||
@ -43,25 +43,52 @@ enum GatewayLaunchAgentManager {
|
||||
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 }
|
||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return result.status == 0
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
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)
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
|
||||
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)
|
||||
_ = 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 {
|
||||
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||
@ -69,20 +96,19 @@ enum GatewayLaunchAgentManager {
|
||||
? "Failed to bootstrap gateway launchd job"
|
||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
// Note: removed redundant `kickstart -k` that caused race condition.
|
||||
// bootstrap already starts the job; kickstart -k would kill it immediately
|
||||
// and with KeepAlive=true, cause a restart loop with port conflicts.
|
||||
await self.ensureEnabled()
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -208,30 +234,57 @@ enum GatewayLaunchAgentManager {
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private struct LaunchctlResult {
|
||||
let status: Int32
|
||||
let output: String
|
||||
private struct DesiredConfig: Equatable {
|
||||
let port: Int
|
||||
let bind: String
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
|
||||
await Task.detached(priority: .utility) { () -> LaunchctlResult 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 LaunchctlResult(status: process.terminationStatus, output: output)
|
||||
} catch {
|
||||
return LaunchctlResult(status: -1, output: error.localizedDescription)
|
||||
}
|
||||
}.value
|
||||
private struct InstalledConfig: Equatable {
|
||||
let port: Int?
|
||||
let bind: String?
|
||||
let token: String?
|
||||
let password: String?
|
||||
|
||||
func matches(_ desired: DesiredConfig) -> Bool {
|
||||
guard self.port == desired.port else { return false }
|
||||
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
||||
guard self.token == desired.token else { return false }
|
||||
guard self.password == desired.password else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func readPlistConfig() -> InstalledConfig? {
|
||||
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
||||
return InstalledConfig(
|
||||
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 {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||
guard !AppStateStore.attachExistingGatewayOnly else { return }
|
||||
let enabled = await GatewayLaunchAgentManager.status()
|
||||
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||
guard !enabled else { return }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
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 }
|
||||
let width = self.initialWidth(for: menu)
|
||||
|
||||
guard self.isControlChannelConnected else { return }
|
||||
let isConnected = self.isControlChannelConnected
|
||||
|
||||
var cursor = insertIndex
|
||||
var headerView: NSView?
|
||||
@ -132,7 +131,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
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,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
@ -163,16 +164,29 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let statusText = isConnected
|
||||
? (self.cachedErrorText ?? "Loading sessions…")
|
||||
: "Gateway disconnected"
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: 0,
|
||||
statusText: self.cachedErrorText ?? "Loading sessions…")),
|
||||
statusText: statusText)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
headerView = hosted
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
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)
|
||||
@ -253,7 +267,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
let rows = self.usageRows
|
||||
let errorText = self.cachedUsageErrorText
|
||||
|
||||
if rows.isEmpty && errorText == nil {
|
||||
if rows.isEmpty, errorText == nil {
|
||||
return cursor
|
||||
}
|
||||
|
||||
|
||||
@ -42,4 +42,3 @@ struct MenuUsageHeaderView: View {
|
||||
return "\(self.count) providers"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,8 +5,16 @@ import Foundation
|
||||
|
||||
actor MacNodeRuntime {
|
||||
private let cameraCapture = CameraCaptureService()
|
||||
@MainActor private let screenRecorder = ScreenRecordService()
|
||||
@MainActor private let locationService = MacNodeLocationService()
|
||||
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||
|
||||
init(
|
||||
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
|
||||
})
|
||||
{
|
||||
self.makeMainActorServices = makeMainActorServices
|
||||
}
|
||||
|
||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
let command = req.command
|
||||
@ -212,7 +220,8 @@ actor MacNodeRuntime {
|
||||
ClawdbotLocationGetParams()
|
||||
let desired = params.desiredAccuracy ??
|
||||
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
||||
let status = await self.locationService.authorizationStatus()
|
||||
let services = await self.mainActorServices()
|
||||
let status = await services.locationAuthorizationStatus()
|
||||
if status != .authorizedAlways {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
@ -222,11 +231,11 @@ actor MacNodeRuntime {
|
||||
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
|
||||
}
|
||||
do {
|
||||
let location = try await self.locationService.currentLocation(
|
||||
let location = try await services.currentLocation(
|
||||
desiredAccuracy: desired,
|
||||
maxAgeMs: params.maxAgeMs,
|
||||
timeoutMs: params.timeoutMs)
|
||||
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
|
||||
let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy
|
||||
let payload = ClawdbotLocationPayload(
|
||||
lat: location.coordinate.latitude,
|
||||
lon: location.coordinate.longitude,
|
||||
@ -265,7 +274,8 @@ actor MacNodeRuntime {
|
||||
code: .invalidRequest,
|
||||
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,
|
||||
durationMs: params.durationMs,
|
||||
fps: params.fps,
|
||||
@ -291,6 +301,13 @@ actor MacNodeRuntime {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
@ -41,8 +41,8 @@ final class RemotePortTunnel {
|
||||
static func create(
|
||||
remotePort: Int,
|
||||
preferredLocalPort: UInt16? = nil,
|
||||
allowRemoteUrlOverride: Bool = true
|
||||
) async throws -> RemotePortTunnel {
|
||||
allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
|
||||
{
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
|
||||
throw NSError(
|
||||
|
||||
@ -29,8 +29,8 @@ struct UsageRow: Identifiable {
|
||||
let error: String?
|
||||
|
||||
var titleText: String {
|
||||
if let plan, !plan.isEmpty { return "\(displayName) (\(plan))" }
|
||||
return displayName
|
||||
if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" }
|
||||
return self.displayName
|
||||
}
|
||||
|
||||
var remainingPercent: Int? {
|
||||
@ -107,4 +107,3 @@ enum UsageLoader {
|
||||
return try JSONDecoder().decode(GatewayUsageSummary.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ struct UsageMenuLabelView: View {
|
||||
}
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(row.titleText)
|
||||
Text(self.row.titleText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.primaryTextColor)
|
||||
.lineLimit(1)
|
||||
@ -30,7 +30,7 @@ struct UsageMenuLabelView: View {
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Text(row.detailText())
|
||||
Text(self.row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
@ -43,4 +43,3 @@ struct UsageMenuLabelView: View {
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
@ -53,7 +54,7 @@ final class WorkActivityStore {
|
||||
phase: String,
|
||||
name: String?,
|
||||
meta: String?,
|
||||
args: [String: AnyCodable]?)
|
||||
args: [String: ClawdbotProtocol.AnyCodable]?)
|
||||
{
|
||||
let toolKind = Self.mapToolKind(name)
|
||||
let label = Self.buildLabel(name: name, meta: meta, args: args)
|
||||
@ -211,7 +212,7 @@ final class WorkActivityStore {
|
||||
private static func buildLabel(
|
||||
name: String?,
|
||||
meta: String?,
|
||||
args: [String: AnyCodable]?) -> String
|
||||
args: [String: ClawdbotProtocol.AnyCodable]?) -> String
|
||||
{
|
||||
let wrappedArgs = self.wrapToolArgs(args)
|
||||
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
|
||||
@ -221,17 +222,17 @@ final class WorkActivityStore {
|
||||
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 }
|
||||
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
|
||||
return ClawdbotKit.AnyCodable(converted)
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
if let array = value as? [AnyCodable] {
|
||||
if let array = value as? [ClawdbotProtocol.AnyCodable] {
|
||||
return array.map { self.unwrapJSONValue($0.value) }
|
||||
}
|
||||
if let dict = value as? [String: Any] {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import ClawdbotProtocol
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite
|
||||
@ -15,7 +16,7 @@ struct AgentEventStoreTests {
|
||||
seq: 1,
|
||||
stream: "test",
|
||||
ts: 0,
|
||||
data: [:] as [String: AnyCodable],
|
||||
data: [:] as [String: ClawdbotProtocol.AnyCodable],
|
||||
summary: nil))
|
||||
#expect(store.events.count == 1)
|
||||
|
||||
@ -32,7 +33,7 @@ struct AgentEventStoreTests {
|
||||
seq: i,
|
||||
stream: "test",
|
||||
ts: Double(i),
|
||||
data: [:] as [String: AnyCodable],
|
||||
data: [:] as [String: ClawdbotProtocol.AnyCodable],
|
||||
summary: nil))
|
||||
}
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import Testing
|
||||
"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])
|
||||
|
||||
#expect(obj["tags"] as? [String] == ["node", "ios"])
|
||||
|
||||
@ -2,29 +2,29 @@ import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite
|
||||
@Suite(.serialized)
|
||||
struct ClawdbotConfigFileTests {
|
||||
@Test
|
||||
func configPathRespectsEnvOverride() {
|
||||
func configPathRespectsEnvOverride() async {
|
||||
let override = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
|
||||
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
|
||||
#expect(ClawdbotConfigFile.url().path == override)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func remoteGatewayPortParsesAndMatchesHost() {
|
||||
func remoteGatewayPortParsesAndMatchesHost() async {
|
||||
let override = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
|
||||
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
@ -41,13 +41,13 @@ struct ClawdbotConfigFileTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func setRemoteGatewayUrlPreservesScheme() {
|
||||
func setRemoteGatewayUrlPreservesScheme() async {
|
||||
let override = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
|
||||
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
@ -63,33 +63,17 @@ struct ClawdbotConfigFileTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func stateDirOverrideSetsConfigPath() {
|
||||
func stateDirOverrideSetsConfigPath() async {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
|
||||
.path
|
||||
|
||||
self.withEnv("CLAWDBOT_CONFIG_PATH", value: nil) {
|
||||
self.withEnv("CLAWDBOT_STATE_DIR", value: dir) {
|
||||
#expect(ClawdbotConfigFile.stateDirURL().path == dir)
|
||||
#expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json")
|
||||
}
|
||||
await TestIsolation.withEnvValues([
|
||||
"CLAWDBOT_CONFIG_PATH": nil,
|
||||
"CLAWDBOT_STATE_DIR": dir,
|
||||
]) {
|
||||
#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",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
provider: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
|
||||
@ -31,7 +31,7 @@ struct CronModelsTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 15,
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
provider: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: false)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
|
||||
@ -170,7 +170,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: nil)
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
@ -186,7 +186,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: "a")
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
@ -203,7 +203,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: nil)
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
async let r1: Data = conn.request(method: "status", params: nil)
|
||||
@ -218,7 +218,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: nil)
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
@ -239,7 +239,7 @@ import Testing
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let cfg = ConfigSource(token: nil)
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url, cfg.snapshotToken()) },
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@Test func gatewayPortDefaultsAndRespectsOverride() {
|
||||
let defaultPort = GatewayEnvironment.gatewayPort()
|
||||
#expect(defaultPort == 18789)
|
||||
@Test func gatewayPortDefaultsAndRespectsOverride() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(
|
||||
env: ["CLAWDBOT_CONFIG_PATH": configPath],
|
||||
defaults: ["gatewayPort": nil])
|
||||
{
|
||||
let defaultPort = GatewayEnvironment.gatewayPort()
|
||||
#expect(defaultPort == 18789)
|
||||
|
||||
UserDefaults.standard.set(19999, forKey: "gatewayPort")
|
||||
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
|
||||
#expect(GatewayEnvironment.gatewayPort() == 19999)
|
||||
UserDefaults.standard.set(19999, forKey: "gatewayPort")
|
||||
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
|
||||
#expect(GatewayEnvironment.gatewayPort() == 19999)
|
||||
}
|
||||
}
|
||||
|
||||
@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 Foundation
|
||||
import Testing
|
||||
import ClawdbotProtocol
|
||||
|
||||
@testable import Clawdbot
|
||||
|
||||
@ -23,7 +24,7 @@ struct LowCoverageHelperTests {
|
||||
#expect(dict["list"]?.arrayValue?.count == 2)
|
||||
|
||||
let foundation = any.foundationValue as? [String: Any]
|
||||
#expect(foundation?["title"] as? String == "Hello")
|
||||
#expect((foundation?["title"] as? String) == "Hello")
|
||||
}
|
||||
|
||||
@Test func attributedStringStripsForegroundColor() {
|
||||
@ -92,34 +93,22 @@ struct LowCoverageHelperTests {
|
||||
_ = PresenceReporter._testPrimaryIPv4Address()
|
||||
}
|
||||
|
||||
@Test func gatewayLaunchAgentHelpers() {
|
||||
let keyBind = "CLAWDBOT_GATEWAY_BIND"
|
||||
let keyToken = "CLAWDBOT_GATEWAY_TOKEN"
|
||||
let previousBind = ProcessInfo.processInfo.environment[keyBind]
|
||||
let previousToken = ProcessInfo.processInfo.environment[keyToken]
|
||||
defer {
|
||||
if let previousBind {
|
||||
setenv(keyBind, previousBind, 1)
|
||||
} else {
|
||||
unsetenv(keyBind)
|
||||
}
|
||||
if let previousToken {
|
||||
setenv(keyToken, previousToken, 1)
|
||||
} else {
|
||||
unsetenv(keyToken)
|
||||
}
|
||||
@Test func gatewayLaunchAgentHelpers() async throws {
|
||||
await TestIsolation.withEnvValues(
|
||||
[
|
||||
"CLAWDBOT_GATEWAY_BIND": "Lan",
|
||||
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||
])
|
||||
{
|
||||
#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")
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import ClawdbotProtocol
|
||||
|
||||
@testable import Clawdbot
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
struct MacNodeRuntimeTests {
|
||||
@Test func handleInvokeRejectsUnknownCommand() async {
|
||||
let runtime = MacNodeRuntime()
|
||||
@ -31,21 +31,58 @@ struct MacNodeRuntimeTests {
|
||||
}
|
||||
|
||||
@Test func handleInvokeCameraListRequiresEnabledCamera() async {
|
||||
let defaults = UserDefaults.standard
|
||||
let previous = defaults.object(forKey: cameraEnabledKey)
|
||||
defaults.set(false, forKey: cameraEnabledKey)
|
||||
defer {
|
||||
if let previous {
|
||||
defaults.set(previous, forKey: cameraEnabledKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: cameraEnabledKey)
|
||||
await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) {
|
||||
let runtime = MacNodeRuntime()
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
|
||||
BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == 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",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
provider: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
@ -47,7 +47,7 @@ struct MenuSessionsInjectorTests {
|
||||
key: "discord:group:alpha",
|
||||
kind: .group,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
provider: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
|
||||
@ -28,7 +28,7 @@ struct SessionDataTests {
|
||||
key: "user@example.com",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
provider: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
|
||||
@ -45,7 +45,7 @@ struct SettingsViewSmokeTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 30,
|
||||
deliver: true,
|
||||
channel: "sms",
|
||||
provider: "sms",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import Testing
|
||||
import ClawdbotProtocol
|
||||
@testable import Clawdbot
|
||||
|
||||
@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.deliver == true)
|
||||
#expect(opts.to == nil)
|
||||
#expect(opts.channel == .last)
|
||||
#expect(opts.provider == .last)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import ClawdbotProtocol
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite
|
||||
|
||||
@ -84,6 +84,52 @@
|
||||
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 {
|
||||
font-family: var(--font-body);
|
||||
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.
|
||||
|
||||
### `doctor`
|
||||
Health checks + quick fixes.
|
||||
Health checks + quick fixes (config + gateway + legacy services).
|
||||
|
||||
Options:
|
||||
- `--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
|
||||
|
||||
@ -362,6 +365,25 @@ Options:
|
||||
### `gateway-daemon`
|
||||
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 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 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 install`
|
||||
- `gateway uninstall`
|
||||
- `gateway start`
|
||||
- `gateway stop`
|
||||
- `gateway restart`
|
||||
- `gateway daemon status` (alias for `clawdbot daemon status`)
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
- `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)
|
||||
- `label?` (optional; used for logs/UI)
|
||||
- `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`)
|
||||
|
||||
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 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.
|
||||
- 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).
|
||||
|
||||
@ -16,11 +16,15 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl
|
||||
## Where state lives
|
||||
- On the **gateway host**:
|
||||
- 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.
|
||||
- Group entries may include `displayName`, `provider`, `subject`, `room`, and `space` to label sessions in UIs.
|
||||
- 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
|
||||
- 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.
|
||||
@ -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).
|
||||
- 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 `/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.
|
||||
|
||||
## 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-workspace",
|
||||
"concepts/multi-agent",
|
||||
"concepts/compaction",
|
||||
"concepts/session",
|
||||
"concepts/session-pruning",
|
||||
"concepts/sessions",
|
||||
"concepts/session-tool",
|
||||
"concepts/presence",
|
||||
"concepts/provider-routing",
|
||||
"concepts/groups",
|
||||
"concepts/group-messages",
|
||||
"concepts/typing-indicators",
|
||||
"concepts/queue",
|
||||
"concepts/models",
|
||||
"concepts/model-failover",
|
||||
@ -644,7 +647,17 @@
|
||||
{
|
||||
"group": "Platforms",
|
||||
"pages": [
|
||||
"platforms",
|
||||
"platforms/macos",
|
||||
"platforms/ios",
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
"platforms/linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "macOS Companion App",
|
||||
"pages": [
|
||||
"platforms/mac/dev-setup",
|
||||
"platforms/mac/menu-bar",
|
||||
"platforms/mac/voicewake",
|
||||
@ -662,11 +675,7 @@
|
||||
"platforms/mac/bun",
|
||||
"platforms/mac/xpc",
|
||||
"platforms/mac/skills",
|
||||
"platforms/mac/peekaboo",
|
||||
"platforms/ios",
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
"platforms/linux"
|
||||
"platforms/mac/peekaboo"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -24,6 +24,7 @@ Behavior:
|
||||
- Foreground runs return output directly.
|
||||
- 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.
|
||||
- If the `process` tool is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
|
||||
|
||||
Environment overrides:
|
||||
- `PI_BASH_YIELD_MS`: default yield (ms)
|
||||
@ -50,6 +51,7 @@ Notes:
|
||||
- Only backgrounded sessions are listed/persisted in memory.
|
||||
- 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.
|
||||
- `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 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"`
|
||||
- `workspaceRoot`: custom sandbox workspace root
|
||||
- `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
|
||||
- `deny`: array of denied tool names (deny wins)
|
||||
- `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.
|
||||
|
||||
#### 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:
|
||||
|
||||
```json5
|
||||
@ -493,6 +562,12 @@ Set `telegram.enabled: false` to disable automatic startup.
|
||||
streamMode: "partial", // off | partial | block (draft streaming)
|
||||
actions: { reactions: true }, // tool action gates (false disables)
|
||||
mediaMaxMb: 5,
|
||||
retry: { // outbound retry policy
|
||||
attempts: 3,
|
||||
minDelayMs: 400,
|
||||
maxDelayMs: 30000,
|
||||
jitter: 0.1
|
||||
},
|
||||
proxy: "socks5://localhost:9050",
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: "secret",
|
||||
@ -505,6 +580,7 @@ Draft streaming notes:
|
||||
- Uses Telegram `sendMessageDraft` (draft bubble, not a real message).
|
||||
- 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.
|
||||
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||
|
||||
### `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).
|
||||
- `all`: all reactions on all messages.
|
||||
- `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)
|
||||
|
||||
@ -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:
|
||||
- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on).
|
||||
- `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.
|
||||
|
||||
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`).
|
||||
Aliases come from `agent.models.*.alias` (e.g. `Opus`).
|
||||
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.
|
||||
- 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 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).
|
||||
- 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.
|
||||
|
||||
```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:
|
||||
|
||||
```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.
|
||||
- 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:
|
||||
- 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`).
|
||||
|
||||
@ -128,12 +128,13 @@ Consider running your AI on a separate phone number from your personal one:
|
||||
- Personal number: Your conversations stay private
|
||||
- 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:
|
||||
- Writing files outside a sandbox
|
||||
- Executing shell commands
|
||||
- Sending messages
|
||||
You can already build a read-only profile by combining:
|
||||
- `sandbox.workspaceAccess: "ro"` (or `"none"` for no workspace access)
|
||||
- tool allow/deny lists that block `write`, `edit`, `bash`, `process`, etc.
|
||||
|
||||
We may add a single `readOnlyMode` flag later to simplify this configuration.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
- Image: `clawdbot-sandbox:bookworm-slim`
|
||||
|
||||
@ -97,7 +97,7 @@ Runbook + exact service labels: [Gateway runbook](/gateway)
|
||||
Install a known-good version:
|
||||
|
||||
```bash
|
||||
npm i -g clawdbot@2026.1.5-3
|
||||
npm i -g clawdbot@2026.1.7
|
||||
```
|
||||
|
||||
Then restart + re-run doctor:
|
||||
|
||||
@ -8,6 +8,15 @@ read_when:
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
## 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
|
||||
|
||||
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:
|
||||
- Looking for Linux companion app status
|
||||
- Planning platform coverage or contributions
|
||||
---
|
||||
# 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.
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
- Single macOS menu-bar app named **Clawdbot** that:
|
||||
- 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:
|
||||
- Installing Clawdbot on Windows
|
||||
- Looking for Windows companion app status
|
||||
@ -7,14 +7,55 @@ read_when:
|
||||
---
|
||||
# Windows (WSL2)
|
||||
|
||||
Clawdbot runs on Windows **via WSL2** (Ubuntu recommended). WSL2 is **strongly
|
||||
recommended**; native Windows installs are untested and more problematic. Use
|
||||
WSL2 and follow the Linux flow inside it.
|
||||
Clawdbot core is supported on Windows **via WSL2** (Ubuntu recommended). The
|
||||
CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
|
||||
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
|
||||
|
||||
Start here (official WSL2 guide): https://learn.microsoft.com/windows/wsl/install
|
||||
|
||||
### 1) Install WSL2 + Ubuntu
|
||||
|
||||
Open PowerShell (Admin):
|
||||
|
||||
@ -5,7 +5,7 @@ read_when:
|
||||
---
|
||||
# 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.
|
||||
|
||||
@ -122,6 +122,12 @@ Example “single server, only allow me, only allow #help”:
|
||||
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).
|
||||
- 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
|
||||
|
||||
```json5
|
||||
@ -235,6 +244,7 @@ Ack reactions are controlled globally via `messages.ackReaction` +
|
||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||
- `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).
|
||||
- `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).
|
||||
- `reactions` (covers react + read reactions)
|
||||
- `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.
|
||||
- Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`).
|
||||
- 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)
|
||||
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.
|
||||
|
||||
## 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).
|
||||
- Approve via:
|
||||
- `clawdbot pairing list --provider telegram`
|
||||
- `clawdbot pairing approve --provider telegram <CODE>`
|
||||
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
||||
|
||||
Group gating:
|
||||
- `telegram.groupPolicy = open | allowlist | disabled`.
|
||||
- `telegram.groups` doubles as a group allowlist when set (include `"*"` to allow all).
|
||||
### Group access
|
||||
|
||||
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
|
||||
- Default: long-polling (no public URL required).
|
||||
@ -96,6 +163,9 @@ Reasoning stream (Telegram only):
|
||||
- If `telegram.streamMode` is `off`, reasoning stream is disabled.
|
||||
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)
|
||||
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
||||
- 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.
|
||||
- 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)
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
@ -128,6 +219,7 @@ Provider options:
|
||||
- `telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
- `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.webhookUrl`: enable webhook mode.
|
||||
- `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).
|
||||
- Open: requires `whatsapp.allowFrom` to include `"*"`.
|
||||
- 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`).
|
||||
- `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
|
||||
- **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
|
||||
- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
||||
- `whatsapp.selfChatMode` (same-phone setup; suppress pairing replies for outbound DMs).
|
||||
- `whatsapp.allowFrom` (DM allowlist).
|
||||
- `whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
||||
- `whatsapp.groupAllowFrom` (group sender allowlist).
|
||||
|
||||
@ -337,7 +337,7 @@ See [Groups](/concepts/groups) for details.
|
||||
|
||||
### 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:
|
||||
- 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)
|
||||
- [Streaming + chunking](/concepts/streaming)
|
||||
- [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 (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)
|
||||
- [Queue](https://docs.clawd.bot/concepts/queue)
|
||||
- [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
|
||||
|
||||
- [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 menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [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 skills](https://docs.clawd.bot/platforms/mac/skills)
|
||||
- [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
|
||||
|
||||
|
||||
@ -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, 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
|
||||
- **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.)
|
||||
|
||||
## 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.)
|
||||
- **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.)
|
||||
@ -26,11 +28,12 @@ Real projects from the community. Highlights from #showcase (Jan 2–5, 2026).
|
||||
## 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 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
|
||||
|
||||
## 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)
|
||||
- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/
|
||||
|
||||
@ -8,6 +8,8 @@ read_when:
|
||||
# Bash tool
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ Core parameters:
|
||||
Notes:
|
||||
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
||||
- Use `process` to poll/log/write/kill/clear background sessions.
|
||||
- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
|
||||
|
||||
### `process`
|
||||
Manage background bash sessions.
|
||||
@ -52,6 +53,7 @@ Core actions:
|
||||
Notes:
|
||||
- `poll` returns new output and exit status when complete.
|
||||
- `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`
|
||||
Control the dedicated clawd browser.
|
||||
@ -157,13 +159,14 @@ Core parameters:
|
||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_spawn`: `task`, `label?`, `model?`, `timeoutSeconds?`, `cleanup?`
|
||||
- `sessions_spawn`: `task`, `label?`, `model?`, `runTimeoutSeconds?`, `cleanup?`
|
||||
|
||||
Notes:
|
||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
||||
- `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` 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).
|
||||
- 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`)
|
||||
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
|
||||
- `/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`)
|
||||
|
||||
Text-only:
|
||||
- `/compact [instructions]`
|
||||
- `/compact [instructions]` (see [/concepts/compaction](/concepts/compaction))
|
||||
|
||||
## Surface notes
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# 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:
|
||||
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
||||
@ -25,7 +25,7 @@ Tool params:
|
||||
- `task` (required)
|
||||
- `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)
|
||||
- `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`)
|
||||
|
||||
Auto-archive:
|
||||
@ -33,7 +33,7 @@ Auto-archive:
|
||||
- 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).
|
||||
- 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
|
||||
|
||||
@ -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-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",
|
||||
"version": "2026.1.5-3",
|
||||
"version": "2026.1.7",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@ -85,6 +85,7 @@
|
||||
"dependencies": {
|
||||
"@buape/carbon": "0.0.0-beta-20260107085330",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
"@mariozechner/pi-agent-core": "^0.37.2",
|
||||
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@ -28,6 +28,9 @@ importers:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@grammyjs/runner':
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3(grammy@1.39.2)
|
||||
'@grammyjs/transformer-throttler':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1(grammy@1.39.2)
|
||||
@ -591,6 +594,12 @@ packages:
|
||||
'@modelcontextprotocol/sdk':
|
||||
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':
|
||||
resolution: {integrity: sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==}
|
||||
engines: {node: ^12.20.0 || >=14.13.1}
|
||||
@ -3411,6 +3420,11 @@ snapshots:
|
||||
- supports-color
|
||||
- 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)':
|
||||
dependencies:
|
||||
bottleneck: 2.19.5
|
||||
|
||||
@ -2,6 +2,12 @@
|
||||
|
||||
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
|
||||
- **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
|
||||
|
||||
@ -19,26 +19,29 @@ Follow the official CLI get-started steps. Don't guess install commands.
|
||||
1. Check OS + shell.
|
||||
2. Verify CLI present: `op --version`.
|
||||
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).
|
||||
5. If multiple accounts: use `--account` or `OP_ACCOUNT`.
|
||||
6. Verify access: `op whoami` or `op account list`.
|
||||
4. REQUIRED: create a fresh tmux session for all `op` commands (no direct `op` calls outside tmux).
|
||||
5. Sign in / authorize inside tmux: `op signin` (expect app prompt).
|
||||
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
|
||||
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
|
||||
mkdir -p "$SOCKET_DIR"
|
||||
SOCKET="$SOCKET_DIR/clawdbot.sock"
|
||||
SESSION=op-auth
|
||||
SOCKET="$SOCKET_DIR/clawdbot-op.sock"
|
||||
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
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 whoami" 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" kill-session -t "$SESSION"
|
||||
```
|
||||
|
||||
## 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.
|
||||
- Prefer `op run` / `op inject` over writing secrets to disk.
|
||||
- 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 {
|
||||
id: string;
|
||||
command: string;
|
||||
scopeKey?: string;
|
||||
child?: ChildProcessWithoutNullStreams;
|
||||
pid?: number;
|
||||
startedAt: number;
|
||||
@ -38,6 +39,7 @@ export interface ProcessSession {
|
||||
export interface FinishedSession {
|
||||
id: string;
|
||||
command: string;
|
||||
scopeKey?: string;
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
cwd?: string;
|
||||
@ -126,6 +128,7 @@ function moveToFinished(session: ProcessSession, status: ProcessStatus) {
|
||||
finishedSessions.set(session.id, {
|
||||
id: session.id,
|
||||
command: session.command,
|
||||
scopeKey: session.scopeKey,
|
||||
startedAt: session.startedAt,
|
||||
endedAt: Date.now(),
|
||||
cwd: session.cwd,
|
||||
|
||||
@ -185,4 +185,36 @@ describe("bash tool backgrounding", () => {
|
||||
const textBlock = log.content.find((c) => c.type === "text");
|
||||
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 ??
|
||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
const stringEnum = (
|
||||
values: readonly string[],
|
||||
options?: Parameters<typeof Type.Union>[1],
|
||||
// NOTE: Using Type.Unsafe with enum instead of Type.Union([Type.Literal(...)])
|
||||
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
|
||||
// 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(
|
||||
values.map((value) => Type.Literal(value)) as [
|
||||
ReturnType<typeof Type.Literal>,
|
||||
...ReturnType<typeof Type.Literal>[],
|
||||
],
|
||||
options,
|
||||
);
|
||||
Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: values as unknown as string[],
|
||||
...options,
|
||||
});
|
||||
|
||||
export type BashToolDefaults = {
|
||||
backgroundMs?: number;
|
||||
timeoutSec?: number;
|
||||
sandbox?: BashSandboxConfig;
|
||||
elevated?: BashElevatedDefaults;
|
||||
allowBackground?: boolean;
|
||||
scopeKey?: string;
|
||||
};
|
||||
|
||||
export type ProcessToolDefaults = {
|
||||
cleanupMs?: number;
|
||||
scopeKey?: string;
|
||||
};
|
||||
|
||||
export type BashSandboxConfig = {
|
||||
@ -126,6 +131,7 @@ export function createBashTool(
|
||||
10,
|
||||
120_000,
|
||||
);
|
||||
const allowBackground = defaults?.allowBackground ?? true;
|
||||
const defaultTimeoutSec =
|
||||
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
|
||||
? defaults.timeoutSec
|
||||
@ -152,18 +158,27 @@ export function createBashTool(
|
||||
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 startedAt = Date.now();
|
||||
const sessionId = randomUUID();
|
||||
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 elevatedDefaultOn =
|
||||
elevatedDefaults?.defaultLevel === "on" &&
|
||||
@ -238,6 +253,7 @@ export function createBashTool(
|
||||
const session = {
|
||||
id: sessionId,
|
||||
command: params.command,
|
||||
scopeKey: defaults?.scopeKey,
|
||||
child,
|
||||
pid: child?.pid,
|
||||
startedAt,
|
||||
@ -351,15 +367,17 @@ export function createBashTool(
|
||||
resolveRunning();
|
||||
};
|
||||
|
||||
if (yieldWindow === 0) {
|
||||
onYieldNow();
|
||||
} else {
|
||||
yieldTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
yielded = true;
|
||||
markBackgrounded(session);
|
||||
resolveRunning();
|
||||
}, yieldWindow);
|
||||
if (allowBackground && yieldWindow !== null) {
|
||||
if (yieldWindow === 0) {
|
||||
onYieldNow();
|
||||
} else {
|
||||
yieldTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
yielded = true;
|
||||
markBackgrounded(session);
|
||||
resolveRunning();
|
||||
}, yieldWindow);
|
||||
}
|
||||
}
|
||||
|
||||
const handleExit = (
|
||||
@ -456,6 +474,9 @@ export function createProcessTool(
|
||||
if (defaults?.cleanupMs !== undefined) {
|
||||
setJobTtlMs(defaults.cleanupMs);
|
||||
}
|
||||
const scopeKey = defaults?.scopeKey;
|
||||
const isInScope = (session?: { scopeKey?: string } | null) =>
|
||||
!scopeKey || session?.scopeKey === scopeKey;
|
||||
|
||||
return {
|
||||
name: "process",
|
||||
@ -473,32 +494,36 @@ export function createProcessTool(
|
||||
};
|
||||
|
||||
if (params.action === "list") {
|
||||
const running = listRunningSessions().map((s) => ({
|
||||
sessionId: s.id,
|
||||
status: "running",
|
||||
pid: s.pid ?? undefined,
|
||||
startedAt: s.startedAt,
|
||||
runtimeMs: Date.now() - s.startedAt,
|
||||
cwd: s.cwd,
|
||||
command: s.command,
|
||||
name: deriveSessionName(s.command),
|
||||
tail: s.tail,
|
||||
truncated: s.truncated,
|
||||
}));
|
||||
const finished = listFinishedSessions().map((s) => ({
|
||||
sessionId: s.id,
|
||||
status: s.status,
|
||||
startedAt: s.startedAt,
|
||||
endedAt: s.endedAt,
|
||||
runtimeMs: s.endedAt - s.startedAt,
|
||||
cwd: s.cwd,
|
||||
command: s.command,
|
||||
name: deriveSessionName(s.command),
|
||||
tail: s.tail,
|
||||
truncated: s.truncated,
|
||||
exitCode: s.exitCode ?? undefined,
|
||||
exitSignal: s.exitSignal ?? undefined,
|
||||
}));
|
||||
const running = listRunningSessions()
|
||||
.filter((s) => isInScope(s))
|
||||
.map((s) => ({
|
||||
sessionId: s.id,
|
||||
status: "running",
|
||||
pid: s.pid ?? undefined,
|
||||
startedAt: s.startedAt,
|
||||
runtimeMs: Date.now() - s.startedAt,
|
||||
cwd: s.cwd,
|
||||
command: s.command,
|
||||
name: deriveSessionName(s.command),
|
||||
tail: s.tail,
|
||||
truncated: s.truncated,
|
||||
}));
|
||||
const finished = listFinishedSessions()
|
||||
.filter((s) => isInScope(s))
|
||||
.map((s) => ({
|
||||
sessionId: s.id,
|
||||
status: s.status,
|
||||
startedAt: s.startedAt,
|
||||
endedAt: s.endedAt,
|
||||
runtimeMs: s.endedAt - s.startedAt,
|
||||
cwd: s.cwd,
|
||||
command: s.command,
|
||||
name: deriveSessionName(s.command),
|
||||
tail: s.tail,
|
||||
truncated: s.truncated,
|
||||
exitCode: s.exitCode ?? undefined,
|
||||
exitSignal: s.exitSignal ?? undefined,
|
||||
}));
|
||||
const lines = [...running, ...finished]
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.map((s) => {
|
||||
@ -532,34 +557,38 @@ export function createProcessTool(
|
||||
|
||||
const session = getSession(params.sessionId);
|
||||
const finished = getFinishedSession(params.sessionId);
|
||||
const scopedSession = isInScope(session) ? session : undefined;
|
||||
const scopedFinished = isInScope(finished) ? finished : undefined;
|
||||
|
||||
switch (params.action) {
|
||||
case "poll": {
|
||||
if (!session) {
|
||||
if (finished) {
|
||||
if (!scopedSession) {
|
||||
if (scopedFinished) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
(finished.tail ||
|
||||
(scopedFinished.tail ||
|
||||
`(no output recorded${
|
||||
finished.truncated ? " — truncated to cap" : ""
|
||||
scopedFinished.truncated ? " — truncated to cap" : ""
|
||||
})`) +
|
||||
`\n\nProcess exited with ${
|
||||
finished.exitSignal
|
||||
? `signal ${finished.exitSignal}`
|
||||
: `code ${finished.exitCode ?? 0}`
|
||||
scopedFinished.exitSignal
|
||||
? `signal ${scopedFinished.exitSignal}`
|
||||
: `code ${scopedFinished.exitCode ?? 0}`
|
||||
}.`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status:
|
||||
finished.status === "completed" ? "completed" : "failed",
|
||||
scopedFinished.status === "completed"
|
||||
? "completed"
|
||||
: "failed",
|
||||
sessionId: params.sessionId,
|
||||
exitCode: finished.exitCode ?? undefined,
|
||||
aggregated: finished.aggregated,
|
||||
name: deriveSessionName(finished.command),
|
||||
exitCode: scopedFinished.exitCode ?? undefined,
|
||||
aggregated: scopedFinished.aggregated,
|
||||
name: deriveSessionName(scopedFinished.command),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -573,7 +602,7 @@ export function createProcessTool(
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
if (!session.backgrounded) {
|
||||
if (!scopedSession.backgrounded) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@ -584,17 +613,17 @@ export function createProcessTool(
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
const { stdout, stderr } = drainSession(session);
|
||||
const exited = session.exited;
|
||||
const exitCode = session.exitCode ?? 0;
|
||||
const exitSignal = session.exitSignal ?? undefined;
|
||||
const { stdout, stderr } = drainSession(scopedSession);
|
||||
const exited = scopedSession.exited;
|
||||
const exitCode = scopedSession.exitCode ?? 0;
|
||||
const exitSignal = scopedSession.exitSignal ?? undefined;
|
||||
if (exited) {
|
||||
const status =
|
||||
exitCode === 0 && exitSignal == null ? "completed" : "failed";
|
||||
markExited(
|
||||
session,
|
||||
session.exitCode ?? null,
|
||||
session.exitSignal ?? null,
|
||||
scopedSession,
|
||||
scopedSession.exitCode ?? null,
|
||||
scopedSession.exitSignal ?? null,
|
||||
status,
|
||||
);
|
||||
}
|
||||
@ -624,15 +653,15 @@ export function createProcessTool(
|
||||
status,
|
||||
sessionId: params.sessionId,
|
||||
exitCode: exited ? exitCode : undefined,
|
||||
aggregated: session.aggregated,
|
||||
name: deriveSessionName(session.command),
|
||||
aggregated: scopedSession.aggregated,
|
||||
name: deriveSessionName(scopedSession.command),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "log": {
|
||||
if (session) {
|
||||
if (!session.backgrounded) {
|
||||
if (scopedSession) {
|
||||
if (!scopedSession.backgrounded) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@ -644,31 +673,31 @@ export function createProcessTool(
|
||||
};
|
||||
}
|
||||
const { slice, totalLines, totalChars } = sliceLogLines(
|
||||
session.aggregated,
|
||||
scopedSession.aggregated,
|
||||
params.offset,
|
||||
params.limit,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: slice || "(no output yet)" }],
|
||||
details: {
|
||||
status: session.exited ? "completed" : "running",
|
||||
status: scopedSession.exited ? "completed" : "running",
|
||||
sessionId: params.sessionId,
|
||||
total: totalLines,
|
||||
totalLines,
|
||||
totalChars,
|
||||
truncated: session.truncated,
|
||||
name: deriveSessionName(session.command),
|
||||
truncated: scopedSession.truncated,
|
||||
name: deriveSessionName(scopedSession.command),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (finished) {
|
||||
if (scopedFinished) {
|
||||
const { slice, totalLines, totalChars } = sliceLogLines(
|
||||
finished.aggregated,
|
||||
scopedFinished.aggregated,
|
||||
params.offset,
|
||||
params.limit,
|
||||
);
|
||||
const status =
|
||||
finished.status === "completed" ? "completed" : "failed";
|
||||
scopedFinished.status === "completed" ? "completed" : "failed";
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: slice || "(no output recorded)" },
|
||||
@ -679,10 +708,10 @@ export function createProcessTool(
|
||||
total: totalLines,
|
||||
totalLines,
|
||||
totalChars,
|
||||
truncated: finished.truncated,
|
||||
exitCode: finished.exitCode ?? undefined,
|
||||
exitSignal: finished.exitSignal ?? undefined,
|
||||
name: deriveSessionName(finished.command),
|
||||
truncated: scopedFinished.truncated,
|
||||
exitCode: scopedFinished.exitCode ?? undefined,
|
||||
exitSignal: scopedFinished.exitSignal ?? undefined,
|
||||
name: deriveSessionName(scopedFinished.command),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -698,7 +727,7 @@ export function createProcessTool(
|
||||
}
|
||||
|
||||
case "write": {
|
||||
if (!session) {
|
||||
if (!scopedSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@ -709,7 +738,7 @@ export function createProcessTool(
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
if (!session.backgrounded) {
|
||||
if (!scopedSession.backgrounded) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@ -720,7 +749,10 @@ export function createProcessTool(
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
if (!session.child?.stdin || session.child.stdin.destroyed) {
|
||||
if (
|
||||
!scopedSession.child?.stdin ||
|
||||
scopedSession.child.stdin.destroyed
|
||||
) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@ -732,13 +764,13 @@ export function createProcessTool(
|
||||
};
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.child?.stdin.write(params.data ?? "", (err) => {
|
||||
scopedSession.child?.stdin.write(params.data ?? "", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
if (params.eof) {
|
||||
session.child.stdin.end();
|
||||
scopedSession.child.stdin.end();
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
@ -752,13 +784,15 @@ export function createProcessTool(
|
||||
details: {
|
||||
status: "running",
|
||||
sessionId: params.sessionId,
|
||||
name: session ? deriveSessionName(session.command) : undefined,
|
||||
name: scopedSession
|
||||
? deriveSessionName(scopedSession.command)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "kill": {
|
||||
if (!session) {
|
||||
if (!scopedSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@ -769,7 +803,7 @@ export function createProcessTool(
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
if (!session.backgrounded) {
|
||||
if (!scopedSession.backgrounded) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@ -780,21 +814,23 @@ export function createProcessTool(
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
killSession(session);
|
||||
markExited(session, null, "SIGKILL", "failed");
|
||||
killSession(scopedSession);
|
||||
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Killed session ${params.sessionId}.` },
|
||||
],
|
||||
details: {
|
||||
status: "failed",
|
||||
name: session ? deriveSessionName(session.command) : undefined,
|
||||
name: scopedSession
|
||||
? deriveSessionName(scopedSession.command)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "clear": {
|
||||
if (finished) {
|
||||
if (scopedFinished) {
|
||||
deleteSession(params.sessionId);
|
||||
return {
|
||||
content: [
|
||||
@ -815,20 +851,22 @@ export function createProcessTool(
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
if (session) {
|
||||
killSession(session);
|
||||
markExited(session, null, "SIGKILL", "failed");
|
||||
if (scopedSession) {
|
||||
killSession(scopedSession);
|
||||
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Removed session ${params.sessionId}.` },
|
||||
],
|
||||
details: {
|
||||
status: "failed",
|
||||
name: session ? deriveSessionName(session.command) : undefined,
|
||||
name: scopedSession
|
||||
? deriveSessionName(scopedSession.command)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (finished) {
|
||||
if (scopedFinished) {
|
||||
deleteSession(params.sessionId);
|
||||
return {
|
||||
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 { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
describe("subagents", () => {
|
||||
it("sessions_spawn announces back to the requester group provider", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let lastWaitedRunId: string | undefined;
|
||||
const replyByRunId = new Map<string, string>();
|
||||
let sendParams: { to?: string; provider?: string; message?: string } = {};
|
||||
let deletedKey: string | undefined;
|
||||
let childRunId: string | undefined;
|
||||
let childSessionKey: string | undefined;
|
||||
const sessionLastAssistantText = new Map<string, string>();
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
@ -37,13 +41,21 @@ describe("subagents", () => {
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as
|
||||
| { message?: string; sessionKey?: string }
|
||||
| undefined;
|
||||
const params = request.params as {
|
||||
message?: string;
|
||||
sessionKey?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
const message = params?.message ?? "";
|
||||
const reply =
|
||||
message === "Sub-agent announce step." ? "announce now" : "result";
|
||||
replyByRunId.set(runId, reply);
|
||||
const sessionKey = params?.sessionKey ?? "";
|
||||
if (message === "Sub-agent announce step.") {
|
||||
sessionLastAssistantText.set(sessionKey, "announce now");
|
||||
} else {
|
||||
childRunId = runId;
|
||||
childSessionKey = sessionKey;
|
||||
sessionLastAssistantText.set(sessionKey, "result");
|
||||
expect(params?.timeout).toBe(1);
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
@ -51,13 +63,28 @@ describe("subagents", () => {
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string } | undefined;
|
||||
lastWaitedRunId = params?.runId;
|
||||
const params = request.params as
|
||||
| { 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" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
const params = request.params as { sessionKey?: string } | undefined;
|
||||
const text =
|
||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||
return {
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
||||
};
|
||||
@ -89,11 +116,26 @@ describe("subagents", () => {
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
task: "do thing",
|
||||
timeoutSeconds: 1,
|
||||
runTimeoutSeconds: 1,
|
||||
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));
|
||||
|
||||
@ -105,6 +147,7 @@ describe("subagents", () => {
|
||||
expect(first?.lane).toBe("subagent");
|
||||
expect(first?.deliver).toBe(false);
|
||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
|
||||
expect(sendParams.provider).toBe("discord");
|
||||
expect(sendParams.to).toBe("channel:req");
|
||||
@ -114,12 +157,14 @@ describe("subagents", () => {
|
||||
});
|
||||
|
||||
it("sessions_spawn resolves main announce target from sessions.list", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let lastWaitedRunId: string | undefined;
|
||||
const replyByRunId = new Map<string, 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) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
@ -138,13 +183,19 @@ describe("subagents", () => {
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as
|
||||
| { message?: string; sessionKey?: string }
|
||||
| undefined;
|
||||
const params = request.params as {
|
||||
message?: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
const message = params?.message ?? "";
|
||||
const reply =
|
||||
message === "Sub-agent announce step." ? "hello from sub" : "done";
|
||||
replyByRunId.set(runId, reply);
|
||||
const sessionKey = params?.sessionKey ?? "";
|
||||
if (message === "Sub-agent announce step.") {
|
||||
sessionLastAssistantText.set(sessionKey, "hello from sub");
|
||||
} else {
|
||||
childRunId = runId;
|
||||
childSessionKey = sessionKey;
|
||||
sessionLastAssistantText.set(sessionKey, "done");
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
@ -152,13 +203,18 @@ describe("subagents", () => {
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string } | undefined;
|
||||
lastWaitedRunId = params?.runId;
|
||||
const params = request.params as
|
||||
| { 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" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
const params = request.params as { sessionKey?: string } | undefined;
|
||||
const text =
|
||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||
return {
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
||||
};
|
||||
@ -188,10 +244,25 @@ describe("subagents", () => {
|
||||
|
||||
const result = await tool.execute("call2", {
|
||||
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));
|
||||
|
||||
@ -199,14 +270,14 @@ describe("subagents", () => {
|
||||
expect(sendParams.to).toBe("+123");
|
||||
expect(sendParams.message ?? "").toContain("hello from sub");
|
||||
expect(sendParams.message ?? "").toContain("Stats:");
|
||||
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
|
||||
it("sessions_spawn applies a model to the child session", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let lastWaitedRunId: string | undefined;
|
||||
const replyByRunId = new Map<string, string>();
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
@ -217,13 +288,6 @@ describe("subagents", () => {
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
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 {
|
||||
runId,
|
||||
status: "accepted",
|
||||
@ -231,16 +295,9 @@ describe("subagents", () => {
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string } | undefined;
|
||||
lastWaitedRunId = params?.runId;
|
||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
const text =
|
||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
return {
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
||||
};
|
||||
const params = request.params as { timeoutMs?: number } | undefined;
|
||||
if (params?.timeoutMs === 0) return { status: "timeout" };
|
||||
return { status: "ok" };
|
||||
}
|
||||
if (request.method === "sessions.delete") {
|
||||
return { ok: true };
|
||||
@ -256,11 +313,14 @@ describe("subagents", () => {
|
||||
|
||||
const result = await tool.execute("call3", {
|
||||
task: "do thing",
|
||||
timeoutSeconds: 1,
|
||||
runTimeoutSeconds: 1,
|
||||
model: "claude-haiku-4-5",
|
||||
cleanup: "keep",
|
||||
});
|
||||
expect(result.details).toMatchObject({ status: "ok", reply: "done" });
|
||||
expect(result.details).toMatchObject({
|
||||
status: "accepted",
|
||||
modelApplied: true,
|
||||
});
|
||||
|
||||
const patchIndex = calls.findIndex(
|
||||
(call) => call.method === "sessions.patch",
|
||||
@ -277,11 +337,10 @@ describe("subagents", () => {
|
||||
});
|
||||
|
||||
it("sessions_spawn skips invalid model overrides and continues", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let lastWaitedRunId: string | undefined;
|
||||
const replyByRunId = new Map<string, string>();
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
@ -292,13 +351,6 @@ describe("subagents", () => {
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
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 {
|
||||
runId,
|
||||
status: "accepted",
|
||||
@ -306,16 +358,9 @@ describe("subagents", () => {
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string } | undefined;
|
||||
lastWaitedRunId = params?.runId;
|
||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
const text =
|
||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
return {
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
||||
};
|
||||
const params = request.params as { timeoutMs?: number } | undefined;
|
||||
if (params?.timeoutMs === 0) return { status: "timeout" };
|
||||
return { status: "ok" };
|
||||
}
|
||||
if (request.method === "sessions.delete") {
|
||||
return { ok: true };
|
||||
@ -331,11 +376,11 @@ describe("subagents", () => {
|
||||
|
||||
const result = await tool.execute("call4", {
|
||||
task: "do thing",
|
||||
timeoutSeconds: 1,
|
||||
runTimeoutSeconds: 1,
|
||||
model: "bad-model",
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
status: "ok",
|
||||
status: "accepted",
|
||||
modelApplied: false,
|
||||
});
|
||||
expect(
|
||||
@ -343,4 +388,36 @@ describe("subagents", () => {
|
||||
).toContain("invalid model");
|
||||
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 { buildBootstrapContextFiles } from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
formatAssistantErrorText,
|
||||
isContextOverflowError,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
validateGeminiTurns,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
type WorkspaceBootstrapFile,
|
||||
@ -16,6 +23,145 @@ const makeFile = (
|
||||
...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", () => {
|
||||
it("keeps missing markers", () => {
|
||||
const files = [makeFile({ missing: true, content: undefined })];
|
||||
@ -46,3 +192,58 @@ describe("buildBootstrapContextFiles", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
files: WorkspaceBootstrapFile[],
|
||||
): EmbeddedContextFile[] {
|
||||
@ -126,6 +160,18 @@ export function buildBootstrapContextFiles(
|
||||
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(
|
||||
msg: AssistantMessage,
|
||||
): string | undefined {
|
||||
@ -133,6 +179,14 @@ export function formatAssistantErrorText(
|
||||
const raw = (msg.errorMessage ?? "").trim();
|
||||
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(
|
||||
/"type":"invalid_request_error".*?"message":"([^"]+)"/,
|
||||
);
|
||||
@ -218,3 +272,77 @@ export function pickFallbackThinkingLevel(params: {
|
||||
}
|
||||
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 { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyGoogleTurnOrderingFix,
|
||||
buildEmbeddedSandboxInfo,
|
||||
splitSdkTools,
|
||||
} from "./pi-embedded-runner.js";
|
||||
@ -102,3 +104,64 @@ describe("splitSdkTools", () => {
|
||||
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 os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type {
|
||||
AgentMessage,
|
||||
@ -16,7 +18,6 @@ import {
|
||||
SettingsManager,
|
||||
type Skill,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
||||
import type {
|
||||
ReasoningLevel,
|
||||
@ -24,6 +25,7 @@ import type {
|
||||
VerboseLevel,
|
||||
} from "../auto-reply/thinking.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 { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
@ -40,7 +42,11 @@ import {
|
||||
markAuthProfileUsed,
|
||||
} from "./auth-profiles.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 {
|
||||
ensureAuthProfileStore,
|
||||
getApiKeyForModel,
|
||||
@ -53,10 +59,14 @@ import {
|
||||
formatAssistantErrorText,
|
||||
isAuthAssistantError,
|
||||
isAuthErrorMessage,
|
||||
isContextOverflowError,
|
||||
isGoogleModelApi,
|
||||
isRateLimitAssistantError,
|
||||
isRateLimitErrorMessage,
|
||||
pickFallbackThinkingLevel,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
sanitizeSessionMessagesImages,
|
||||
validateGeminiTurns,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
type BlockReplyChunking,
|
||||
@ -67,6 +77,9 @@ import {
|
||||
extractAssistantThinking,
|
||||
formatReasoningMarkdown,
|
||||
} 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 { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { resolveSandboxContext } from "./sandbox.js";
|
||||
@ -82,6 +95,84 @@ import { buildAgentSystemPromptAppend } from "./system-prompt.js";
|
||||
import { normalizeUsage, type UsageLike } from "./usage.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 = {
|
||||
sessionId: string;
|
||||
provider: string;
|
||||
@ -155,6 +246,80 @@ type EmbeddedPiQueueHandle = {
|
||||
};
|
||||
|
||||
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>();
|
||||
type EmbeddedRunWaiter = {
|
||||
@ -163,6 +328,66 @@ type 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 => {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
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"]);
|
||||
|
||||
type AnyAgentTool = AgentTool<TSchema, unknown>;
|
||||
type AnyAgentTool = AgentTool;
|
||||
|
||||
export function splitSdkTools(options: {
|
||||
tools: AnyAgentTool[];
|
||||
@ -573,18 +798,30 @@ export async function compactEmbeddedPiSession(params: {
|
||||
tools,
|
||||
});
|
||||
|
||||
// Pre-warm session file to bring it into OS page cache
|
||||
await prewarmSessionFile(params.sessionFile);
|
||||
const sessionManager = SessionManager.open(params.sessionFile);
|
||||
trackSessionManagerAccess(params.sessionFile);
|
||||
const settingsManager = SettingsManager.create(
|
||||
effectiveWorkspace,
|
||||
agentDir,
|
||||
);
|
||||
const pruning = buildContextPruningExtension({
|
||||
cfg: params.config,
|
||||
sessionManager,
|
||||
provider,
|
||||
modelId,
|
||||
model,
|
||||
});
|
||||
const additionalExtensionPaths = pruning.additionalExtensionPaths;
|
||||
|
||||
const { builtInTools, customTools } = splitSdkTools({
|
||||
tools,
|
||||
sandboxEnabled: !!sandbox?.enabled,
|
||||
});
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||
({ session } = await createAgentSession({
|
||||
cwd: resolvedWorkspace,
|
||||
agentDir,
|
||||
authStorage,
|
||||
@ -598,15 +835,19 @@ export async function compactEmbeddedPiSession(params: {
|
||||
settingsManager,
|
||||
skills: promptSkills,
|
||||
contextFiles,
|
||||
});
|
||||
additionalExtensionPaths,
|
||||
}));
|
||||
|
||||
try {
|
||||
const prior = await sanitizeSessionMessagesImages(
|
||||
session.messages,
|
||||
"session:history",
|
||||
);
|
||||
if (prior.length > 0) {
|
||||
session.agent.replaceMessages(prior);
|
||||
const prior = await sanitizeSessionHistory({
|
||||
messages: session.messages,
|
||||
modelApi: model.api,
|
||||
sessionManager,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
const validated = validateGeminiTurns(prior);
|
||||
if (validated.length > 0) {
|
||||
session.agent.replaceMessages(validated);
|
||||
}
|
||||
const result = await session.compact(params.customInstructions);
|
||||
return {
|
||||
@ -882,18 +1123,32 @@ export async function runEmbeddedPiAgent(params: {
|
||||
tools,
|
||||
});
|
||||
|
||||
// Pre-warm session file to bring it into OS page cache
|
||||
await prewarmSessionFile(params.sessionFile);
|
||||
const sessionManager = SessionManager.open(params.sessionFile);
|
||||
trackSessionManagerAccess(params.sessionFile);
|
||||
const settingsManager = SettingsManager.create(
|
||||
effectiveWorkspace,
|
||||
agentDir,
|
||||
);
|
||||
const pruning = buildContextPruningExtension({
|
||||
cfg: params.config,
|
||||
sessionManager,
|
||||
provider,
|
||||
modelId,
|
||||
model,
|
||||
});
|
||||
const additionalExtensionPaths = pruning.additionalExtensionPaths;
|
||||
|
||||
const { builtInTools, customTools } = splitSdkTools({
|
||||
tools,
|
||||
sandboxEnabled: !!sandbox?.enabled,
|
||||
});
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
let session: Awaited<
|
||||
ReturnType<typeof createAgentSession>
|
||||
>["session"];
|
||||
({ session } = await createAgentSession({
|
||||
cwd: resolvedWorkspace,
|
||||
agentDir,
|
||||
authStorage,
|
||||
@ -909,14 +1164,23 @@ export async function runEmbeddedPiAgent(params: {
|
||||
settingsManager,
|
||||
skills: promptSkills,
|
||||
contextFiles,
|
||||
});
|
||||
additionalExtensionPaths,
|
||||
}));
|
||||
|
||||
const prior = await sanitizeSessionMessagesImages(
|
||||
session.messages,
|
||||
"session:history",
|
||||
);
|
||||
if (prior.length > 0) {
|
||||
session.agent.replaceMessages(prior);
|
||||
try {
|
||||
const prior = await sanitizeSessionHistory({
|
||||
messages: session.messages,
|
||||
modelApi: model.api,
|
||||
sessionManager,
|
||||
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 timedOut = false;
|
||||
@ -925,21 +1189,27 @@ export async function runEmbeddedPiAgent(params: {
|
||||
if (isTimeout) timedOut = true;
|
||||
void session.abort();
|
||||
};
|
||||
const subscription = subscribeEmbeddedPiSession({
|
||||
session,
|
||||
runId: params.runId,
|
||||
verboseLevel: params.verboseLevel,
|
||||
reasoningMode: params.reasoningLevel ?? "off",
|
||||
shouldEmitToolResult: params.shouldEmitToolResult,
|
||||
onToolResult: params.onToolResult,
|
||||
onReasoningStream: params.onReasoningStream,
|
||||
onBlockReply: params.onBlockReply,
|
||||
blockReplyBreak: params.blockReplyBreak,
|
||||
blockReplyChunking: params.blockReplyChunking,
|
||||
onPartialReply: params.onPartialReply,
|
||||
onAgentEvent: params.onAgentEvent,
|
||||
enforceFinalTag: params.enforceFinalTag,
|
||||
});
|
||||
let subscription: ReturnType<typeof subscribeEmbeddedPiSession>;
|
||||
try {
|
||||
subscription = subscribeEmbeddedPiSession({
|
||||
session,
|
||||
runId: params.runId,
|
||||
verboseLevel: params.verboseLevel,
|
||||
reasoningMode: params.reasoningLevel ?? "off",
|
||||
shouldEmitToolResult: params.shouldEmitToolResult,
|
||||
onToolResult: params.onToolResult,
|
||||
onReasoningStream: params.onReasoningStream,
|
||||
onBlockReply: params.onBlockReply,
|
||||
blockReplyBreak: params.blockReplyBreak,
|
||||
blockReplyChunking: params.blockReplyChunking,
|
||||
onPartialReply: params.onPartialReply,
|
||||
onAgentEvent: params.onAgentEvent,
|
||||
enforceFinalTag: params.enforceFinalTag,
|
||||
});
|
||||
} catch (err) {
|
||||
session.dispose();
|
||||
throw err;
|
||||
}
|
||||
const {
|
||||
assistantTexts,
|
||||
toolMetas,
|
||||
@ -1033,6 +1303,26 @@ export async function runEmbeddedPiAgent(params: {
|
||||
}
|
||||
if (promptError && !aborted) {
|
||||
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 (
|
||||
(isAuthErrorMessage(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