Merge remote-tracking branch 'origin/main' into feature/per-agent-sandbox-tools

This commit is contained in:
Peter Steinberger 2026-01-08 00:13:03 +01:00
commit abf43f6db1
196 changed files with 8656 additions and 1103 deletions

View File

@ -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

View File

@ -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.

View File

@ -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 (dont 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

View File

@ -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>

View File

@ -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)

View File

@ -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 {

View File

@ -1,3 +1,4 @@
import ClawdbotProtocol
import SwiftUI
@MainActor

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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
}

View File

@ -1,3 +1,4 @@
import ClawdbotProtocol
import Foundation
enum ConfigStore {

View File

@ -1,3 +1,4 @@
import ClawdbotProtocol
import Foundation
import SwiftUI

View File

@ -1,3 +1,4 @@
import ClawdbotProtocol
import SwiftUI
struct CronJobEditor: View {

View File

@ -1,3 +1,4 @@
import ClawdbotProtocol
import Foundation
extension CronSettings {

View File

@ -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
}
}
}

View File

@ -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 {

View File

@ -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: "&apos;")
}
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)")
}
}
}

View File

@ -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()

View 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
}
}

View File

@ -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
}

View File

@ -42,4 +42,3 @@ struct MenuUsageHeaderView: View {
return "\(self.count) providers"
}
}

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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")
}

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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] {

View File

@ -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))
}

View File

@ -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"])

View File

@ -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()
}
}

View File

@ -35,7 +35,7 @@ struct CronJobEditorSmokeTests {
thinking: "low",
timeoutSeconds: 120,
deliver: true,
channel: "whatsapp",
provider: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),

View File

@ -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)

View File

@ -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)

View File

@ -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() {

View File

@ -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)
}
}

View File

@ -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&amp;b&lt;c&gt;&quot;&apos;")
#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&amp;b&lt;c&gt;&quot;&apos;")
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
}
@Test func portGuardianParsesListenersAndBuildsReports() {

View File

@ -1,6 +1,7 @@
import AppKit
import SwiftUI
import Testing
import ClawdbotProtocol
@testable import Clawdbot

View File

@ -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)
}
}

View File

@ -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,

View File

@ -28,7 +28,7 @@ struct SessionDataTests {
key: "user@example.com",
kind: .direct,
displayName: nil,
surface: nil,
provider: nil,
subject: nil,
room: nil,
space: nil,

View File

@ -45,7 +45,7 @@ struct SettingsViewSmokeTests {
thinking: "low",
timeoutSeconds: 30,
deliver: true,
channel: "sms",
provider: "sms",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),

View File

@ -1,4 +1,5 @@
import Testing
import ClawdbotProtocol
@testable import Clawdbot
@Suite(.serialized)

View 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
}
}

View File

@ -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)
}
}

View File

@ -1,5 +1,6 @@
import Foundation
import Testing
import ClawdbotProtocol
@testable import Clawdbot
@Suite

View File

@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@ -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

View 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 sessions JSONL history.
## Auto-compaction (default on)
When a session nears or exceeds the models context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context.
Youll 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.

View File

@ -71,3 +71,4 @@ Only the owner number (from `whatsapp.allowFrom`, or the bots 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 hasnt triggered a run yet.
- Typing indicators in groups follow `agent.typingMode` (default: `message` when unmentioned).

View File

@ -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 **cant** 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 whats allowed.
Related:
- Context limits are model-specific; long sessions may trigger compaction. See [/concepts/compaction](/concepts/compaction).
## Model recommendations
Through testing, weve 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 youre 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 45, 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
View 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.

View 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 arent 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)

View File

@ -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).

View File

@ -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

View 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 wont 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 doesnt emit reasoning deltas, typing wont start.
- Heartbeats never show typing, regardless of mode.
- `typingIntervalSeconds` controls the **refresh cadence**, not the start time.
The default is 6 seconds.

View File

@ -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"
]
},
{

View File

@ -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).

View File

@ -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**
theres 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 doesnt 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

View File

@ -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

View File

@ -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`).

View File

@ -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 dont 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:

View File

@ -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`

View File

@ -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:

View File

@ -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
View 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

View File

@ -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:

View File

@ -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
```

View File

@ -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.

View File

@ -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):

View File

@ -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`

View File

@ -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).

View File

@ -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).

View File

@ -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.

View File

@ -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

View File

@ -11,9 +11,11 @@ Real projects from the community. Highlights from #showcase (Jan 25, 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, crosschecks 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
- **InsideOut2 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 25, 2026).
## Infrastructure & deployment
- **Home Assistant OS gateway addon** — 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** — Batteriesincluded nixified clawdis config. https://github.com/joshp123/nix-clawdis
- **Nix packaging** — Batteriesincluded 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 (nonClawdis but made with/around it)
- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/

View File

@ -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

View File

@ -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 replyback pingpong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 05).
- After the pingpong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.

View File

@ -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

View File

@ -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.

View File

@ -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
View File

@ -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

View File

@ -2,6 +2,12 @@
Highlights from #showcase (Jan 25, 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

View File

@ -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.

View File

@ -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,

View File

@ -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");
});
});

View File

@ -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: [

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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 userassistanttooluser 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;
}

View File

@ -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();
});
});

View File

@ -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